mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-10 18:56:53 +00:00
feat(snapshots): preserve stack dossiers with fleet snapshots (#1339)
* feat(snapshots): preserve stack dossiers with fleet snapshots Fleet snapshots can now optionally capture each stack's Dossier notes alongside its compose and .env files, so a recovery restores the operational knowledge around a stack, not just its configuration. - Opt-in global setting "snapshot_documentation" (default off), toggled from the renamed Fleet settings section. - Capture reads local dossiers from the database and remote dossiers over the Distributed API proxy; only stacks with notes are recorded, and secret values are never included. - Captured notes are stored encrypted at rest in a new fleet_snapshots column and surfaced in the snapshot detail view behind a badge. - Cloud and downloaded archives gain a documentation.json (archive_version 2). - Restore stays conservative: dossier notes are written back only when the operator explicitly opts in, on both single-stack and restore-all paths. - Existing snapshots and archives remain valid; behavior is unchanged when the setting is off. * fix(snapshots): harden dossier-notes restore against bad input and partial failures Address review findings on the documentation-snapshots restore path: - Parse `restoreNotes` strictly (=== true) on single-stack restore, matching restore-all, so a stray non-boolean can never opt in to overwriting notes. - Guard findSnapshotDossier: require an array of stacks and real dossier content, so a malformed or all-blank entry can't clobber current notes. - Make the dossier-notes write non-fatal relative to the file restore: a notes failure (e.g. a remote dossier PUT) is caught, reported via `notesError`, and no longer 500s the single restore or fails the stack in restore-all once the files are already written. - Surface the partial outcome in the UI: a warning toast on single restore, a summary note on restore-all, and gate the "Documentation captured" badge and restore-all notes control on captured stacks while rendering capture warnings. Adds tests for strict parsing, malformed/blank blobs, remote notes restore (success + non-fatal failure, single and bulk), and scheduled capture-on. * fix(snapshots): drop unused binding in restore-all remote notes test The restore-all remote notes test destructured a node id it never uses (restore-all is driven by snapshot id alone), tripping no-unused-vars and failing the lint step. Bind only the snapshot id.
This commit is contained in:
@@ -3,18 +3,79 @@
|
||||
* and the SchedulerService for fleet-wide snapshot operations.
|
||||
*/
|
||||
|
||||
import type { NodeMode } from '../services/DatabaseService';
|
||||
import type { NodeMode, StackDossierFields } from '../services/DatabaseService';
|
||||
import { DatabaseService } from '../services/DatabaseService';
|
||||
import { FileSystemService } from '../services/FileSystemService';
|
||||
import { NodeRegistry } from '../services/NodeRegistry';
|
||||
import { formatNoTargetError } from './remoteTarget';
|
||||
import { isDebugEnabled } from './debug';
|
||||
|
||||
// Presence map over every operator-authored dossier field. Typing it as
|
||||
// Record<keyof StackDossierFields, true> makes the build fail if a field is
|
||||
// added to StackDossierFields without being listed here, so capture can never
|
||||
// silently start omitting a new field.
|
||||
const DOSSIER_FIELD_PRESENCE: Record<keyof StackDossierFields, true> = {
|
||||
purpose: true, owner: true, access_urls: true, static_ip: true, vlan: true,
|
||||
firewall_notes: true, reverse_proxy_notes: true, backup_notes: true,
|
||||
upgrade_notes: true, recovery_notes: true, custom_notes: true,
|
||||
};
|
||||
const DOSSIER_FIELD_KEYS = Object.keys(DOSSIER_FIELD_PRESENCE) as Array<keyof StackDossierFields>;
|
||||
|
||||
/**
|
||||
* Project an arbitrary object (a DB row or a remote JSON payload) down to the
|
||||
* eleven operator-authored dossier fields, coercing anything non-string to ''.
|
||||
* Never carries identity, hashes, or timestamps into the snapshot.
|
||||
*/
|
||||
export function pickDossierFields(src: Partial<Record<keyof StackDossierFields, unknown>> | null | undefined): StackDossierFields {
|
||||
const out = {} as StackDossierFields;
|
||||
for (const key of DOSSIER_FIELD_KEYS) {
|
||||
const value = src?.[key];
|
||||
out[key] = typeof value === 'string' ? value : '';
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** True when the operator typed at least one non-blank dossier field. */
|
||||
export function dossierHasContent(fields: StackDossierFields): boolean {
|
||||
return DOSSIER_FIELD_KEYS.some(key => fields[key].trim() !== '');
|
||||
}
|
||||
|
||||
/** A single stack's preserved dossier notes inside a snapshot. */
|
||||
export interface SnapshotDocumentationStack {
|
||||
nodeId: number;
|
||||
nodeName: string;
|
||||
stackName: string;
|
||||
dossier: StackDossierFields;
|
||||
}
|
||||
|
||||
/** A non-fatal problem fetching a stack's dossier during capture. */
|
||||
export interface SnapshotDocumentationWarning {
|
||||
nodeId: number;
|
||||
nodeName: string;
|
||||
stackName: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stack Dossier metadata preserved alongside a fleet snapshot's files. Captured
|
||||
* only when the `snapshot_documentation` setting is on; absent (and the column
|
||||
* left empty) otherwise, so existing snapshots stay byte-for-byte unchanged.
|
||||
*/
|
||||
export interface SnapshotDocumentation {
|
||||
generated_at: string;
|
||||
stacks: SnapshotDocumentationStack[];
|
||||
warnings: SnapshotDocumentationWarning[];
|
||||
}
|
||||
|
||||
export interface SnapshotNodeData {
|
||||
nodeId: number;
|
||||
nodeName: string;
|
||||
stacks: Array<{
|
||||
stackName: string;
|
||||
files: Array<{ filename: string; content: string }>;
|
||||
/** Operator dossier notes for this stack, present only when documentation
|
||||
* capture is on and the stack has at least one non-blank field. */
|
||||
dossier?: StackDossierFields;
|
||||
}>;
|
||||
/**
|
||||
* Per-stack capture problems that did not fail the whole node: a stack whose
|
||||
@@ -24,6 +85,12 @@ export interface SnapshotNodeData {
|
||||
* for a complete backup.
|
||||
*/
|
||||
warnings: Array<{ stackName: string; reason: string }>;
|
||||
/**
|
||||
* Per-stack dossier-fetch problems during documentation capture. Distinct
|
||||
* from `warnings`: the stack's files captured fine, only its notes did not.
|
||||
* Empty unless documentation capture was requested.
|
||||
*/
|
||||
docWarnings: Array<{ stackName: string; reason: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -53,12 +120,13 @@ export interface CaptureNode {
|
||||
* A stack whose compose file cannot be read is omitted from `stacks` and
|
||||
* recorded in `warnings`, so a partial capture is never mistaken for complete.
|
||||
*/
|
||||
export async function captureLocalNodeFiles(node: CaptureNode): Promise<SnapshotNodeData> {
|
||||
export async function captureLocalNodeFiles(node: CaptureNode, captureDocs = false): Promise<SnapshotNodeData> {
|
||||
const start = Date.now();
|
||||
const fsService = FileSystemService.getInstance(node.id);
|
||||
const stackNames = await fsService.getStacks();
|
||||
const stacks: SnapshotNodeData['stacks'] = [];
|
||||
const warnings: SnapshotNodeData['warnings'] = [];
|
||||
const docWarnings: SnapshotNodeData['docWarnings'] = [];
|
||||
|
||||
for (const stackName of stackNames) {
|
||||
const files: Array<{ filename: string; content: string }> = [];
|
||||
@@ -94,7 +162,17 @@ export async function captureLocalNodeFiles(node: CaptureNode): Promise<Snapshot
|
||||
warnings.push({ stackName, reason: `.env could not be read: ${(e as Error).message}; captured without it` });
|
||||
}
|
||||
}
|
||||
stacks.push({ stackName, files });
|
||||
|
||||
let dossier: StackDossierFields | undefined;
|
||||
if (captureDocs) {
|
||||
try {
|
||||
const fields = pickDossierFields(DatabaseService.getInstance().getStackDossier(node.id, stackName));
|
||||
if (dossierHasContent(fields)) dossier = fields;
|
||||
} catch (e) {
|
||||
docWarnings.push({ stackName, reason: `dossier could not be read: ${(e as Error).message}` });
|
||||
}
|
||||
}
|
||||
stacks.push({ stackName, files, dossier });
|
||||
}
|
||||
|
||||
if (isDebugEnabled()) {
|
||||
@@ -102,7 +180,7 @@ export async function captureLocalNodeFiles(node: CaptureNode): Promise<Snapshot
|
||||
console.debug(`[Fleet:debug] Local capture "${node.name}": ${stacks.length} stack(s), ${fileCount} file(s), ${warnings.length} warning(s) in ${Date.now() - start}ms`);
|
||||
}
|
||||
|
||||
return { nodeId: node.id, nodeName: node.name, stacks, warnings };
|
||||
return { nodeId: node.id, nodeName: node.name, stacks, warnings, docWarnings };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -111,7 +189,7 @@ export async function captureLocalNodeFiles(node: CaptureNode): Promise<Snapshot
|
||||
* fetched is omitted from `stacks` and recorded in `warnings`, so a partial
|
||||
* capture is never mistaken for complete.
|
||||
*/
|
||||
export async function captureRemoteNodeFiles(node: CaptureNode): Promise<SnapshotNodeData> {
|
||||
export async function captureRemoteNodeFiles(node: CaptureNode, captureDocs = false): Promise<SnapshotNodeData> {
|
||||
const target = NodeRegistry.getInstance().getProxyTarget(node.id);
|
||||
if (!target) {
|
||||
throw new Error(formatNoTargetError(node));
|
||||
@@ -131,6 +209,7 @@ export async function captureRemoteNodeFiles(node: CaptureNode): Promise<Snapsho
|
||||
|
||||
const stacks: SnapshotNodeData['stacks'] = [];
|
||||
const warnings: SnapshotNodeData['warnings'] = [];
|
||||
const docWarnings: SnapshotNodeData['docWarnings'] = [];
|
||||
|
||||
for (const stackName of stackNames) {
|
||||
const files: Array<{ filename: string; content: string }> = [];
|
||||
@@ -182,7 +261,25 @@ export async function captureRemoteNodeFiles(node: CaptureNode): Promise<Snapsho
|
||||
} catch (e) {
|
||||
warnings.push({ stackName, reason: `.env fetch error: ${(e as Error).message}; captured without it` });
|
||||
}
|
||||
stacks.push({ stackName, files });
|
||||
|
||||
let dossier: StackDossierFields | undefined;
|
||||
if (captureDocs) {
|
||||
try {
|
||||
const dossierRes = await fetch(`${baseUrl}/api/stacks/${encodeURIComponent(stackName)}/dossier`, {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
if (dossierRes.ok) {
|
||||
const fields = pickDossierFields(await dossierRes.json() as Record<string, unknown>);
|
||||
if (dossierHasContent(fields)) dossier = fields;
|
||||
} else if (dossierRes.status !== 404) {
|
||||
docWarnings.push({ stackName, reason: `dossier fetch failed (HTTP ${dossierRes.status})` });
|
||||
}
|
||||
} catch (e) {
|
||||
docWarnings.push({ stackName, reason: `dossier fetch error: ${(e as Error).message}` });
|
||||
}
|
||||
}
|
||||
stacks.push({ stackName, files, dossier });
|
||||
}
|
||||
|
||||
if (isDebugEnabled()) {
|
||||
@@ -190,5 +287,28 @@ export async function captureRemoteNodeFiles(node: CaptureNode): Promise<Snapsho
|
||||
console.debug(`[Fleet:debug] Remote capture "${node.name}": ${stacks.length} stack(s), ${fileCount} file(s), ${warnings.length} warning(s) in ${Date.now() - start}ms`);
|
||||
}
|
||||
|
||||
return { nodeId: node.id, nodeName: node.name, stacks, warnings };
|
||||
return { nodeId: node.id, nodeName: node.name, stacks, warnings, docWarnings };
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse captured per-node dossier notes and dossier-fetch warnings into a
|
||||
* single snapshot documentation record. Returns `null` when nothing was
|
||||
* captured (no stack carried notes and nothing failed), so the caller stores an
|
||||
* empty documentation column and `has_documentation` stays 0.
|
||||
*/
|
||||
export function buildSnapshotDocumentation(capturedNodes: SnapshotNodeData[], generatedAt: string): SnapshotDocumentation | null {
|
||||
const stacks: SnapshotDocumentationStack[] = [];
|
||||
const warnings: SnapshotDocumentationWarning[] = [];
|
||||
for (const node of capturedNodes) {
|
||||
for (const stack of node.stacks) {
|
||||
if (stack.dossier) {
|
||||
stacks.push({ nodeId: node.nodeId, nodeName: node.nodeName, stackName: stack.stackName, dossier: stack.dossier });
|
||||
}
|
||||
}
|
||||
for (const warning of node.docWarnings) {
|
||||
warnings.push({ nodeId: node.nodeId, nodeName: node.nodeName, stackName: warning.stackName, reason: warning.reason });
|
||||
}
|
||||
}
|
||||
if (stacks.length === 0 && warnings.length === 0) return null;
|
||||
return { generated_at: generatedAt, stacks, warnings };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user