mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-24 08:57:25 +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:
@@ -278,6 +278,10 @@ export interface FleetSnapshot {
|
||||
skipped_nodes: string; // JSON: Array<{ nodeId; nodeName; reason }>
|
||||
skipped_stacks: string; // JSON: Array<{ nodeId; nodeName; stackName; reason }>
|
||||
created_at: number;
|
||||
/** 1 when the snapshot captured Stack Dossier metadata, 0 otherwise. The
|
||||
* encrypted blob itself is never projected into list/detail rows; read it
|
||||
* with getSnapshotDocumentation(). */
|
||||
has_documentation: number;
|
||||
}
|
||||
|
||||
export interface FleetSnapshotFile {
|
||||
@@ -867,6 +871,7 @@ export class DatabaseService {
|
||||
stack_count INTEGER NOT NULL,
|
||||
skipped_nodes TEXT NOT NULL DEFAULT '[]',
|
||||
skipped_stacks TEXT NOT NULL DEFAULT '[]',
|
||||
documentation TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
@@ -1280,6 +1285,8 @@ export class DatabaseService {
|
||||
|
||||
// Fleet snapshot per-stack capture warnings (partial-capture surfacing)
|
||||
maybeAddCol('fleet_snapshots', 'skipped_stacks', "TEXT NOT NULL DEFAULT '[]'");
|
||||
// Captured Stack Dossier metadata (opt-in documentation snapshots)
|
||||
maybeAddCol('fleet_snapshots', 'documentation', "TEXT NOT NULL DEFAULT ''");
|
||||
|
||||
// Scheduled operations migrations
|
||||
maybeAddCol('scheduled_task_runs', 'triggered_by', "TEXT NOT NULL DEFAULT 'scheduler'");
|
||||
@@ -3084,10 +3091,14 @@ export class DatabaseService {
|
||||
|
||||
// --- Fleet Snapshots ---
|
||||
|
||||
public createSnapshot(description: string, createdBy: string, nodeCount: number, stackCount: number, skippedNodes: string, skippedStacks = '[]'): number {
|
||||
public createSnapshot(description: string, createdBy: string, nodeCount: number, stackCount: number, skippedNodes: string, skippedStacks = '[]', documentation = ''): number {
|
||||
// Dossier metadata can carry operational notes (static IPs, firewall
|
||||
// rules); encrypt it at rest with the same instance key as the file
|
||||
// bodies. An empty string means the snapshot captured no documentation.
|
||||
const storedDocs = documentation === '' ? '' : CryptoService.getInstance().encrypt(documentation);
|
||||
const result = this.db.prepare(
|
||||
'INSERT INTO fleet_snapshots (description, created_by, node_count, stack_count, skipped_nodes, skipped_stacks, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)'
|
||||
).run(description, createdBy, nodeCount, stackCount, skippedNodes, skippedStacks, Date.now());
|
||||
'INSERT INTO fleet_snapshots (description, created_by, node_count, stack_count, skipped_nodes, skipped_stacks, documentation, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
).run(description, createdBy, nodeCount, stackCount, skippedNodes, skippedStacks, storedDocs, Date.now());
|
||||
return result.lastInsertRowid as number;
|
||||
}
|
||||
|
||||
@@ -3108,14 +3119,37 @@ export class DatabaseService {
|
||||
insertMany(files);
|
||||
}
|
||||
|
||||
// The encrypted `documentation` blob is deliberately excluded from these
|
||||
// projections (it can be large and is decrypted only on demand). Callers
|
||||
// get a cheap `has_documentation` flag; read the blob with
|
||||
// getSnapshotDocumentation().
|
||||
private static readonly SNAPSHOT_COLUMNS =
|
||||
"id, description, created_by, node_count, stack_count, skipped_nodes, skipped_stacks, created_at, (documentation != '') AS has_documentation";
|
||||
|
||||
public getSnapshots(limit = 50, offset = 0): FleetSnapshot[] {
|
||||
return this.db.prepare(
|
||||
'SELECT * FROM fleet_snapshots ORDER BY created_at DESC LIMIT ? OFFSET ?'
|
||||
`SELECT ${DatabaseService.SNAPSHOT_COLUMNS} FROM fleet_snapshots ORDER BY created_at DESC LIMIT ? OFFSET ?`
|
||||
).all(limit, offset) as FleetSnapshot[];
|
||||
}
|
||||
|
||||
public getSnapshot(id: number): FleetSnapshot | undefined {
|
||||
return this.db.prepare('SELECT * FROM fleet_snapshots WHERE id = ?').get(id) as FleetSnapshot | undefined;
|
||||
return this.db.prepare(`SELECT ${DatabaseService.SNAPSHOT_COLUMNS} FROM fleet_snapshots WHERE id = ?`).get(id) as FleetSnapshot | undefined;
|
||||
}
|
||||
|
||||
/** Decrypted Stack Dossier metadata JSON captured with the snapshot, or '' when none. */
|
||||
public getSnapshotDocumentation(id: number): string {
|
||||
const row = this.db.prepare('SELECT documentation FROM fleet_snapshots WHERE id = ?').get(id) as { documentation: string } | undefined;
|
||||
if (!row || row.documentation === '') return '';
|
||||
try {
|
||||
// decrypt() returns non-ciphertext input unchanged, mirroring the file path.
|
||||
return CryptoService.getInstance().decrypt(row.documentation);
|
||||
} catch (e) {
|
||||
// A corrupt blob or a key rotation must not break the primary backup
|
||||
// flows: documentation is an optional side payload, so degrade to
|
||||
// "no documentation" rather than failing upload/detail/restore.
|
||||
console.error(`[DatabaseService] Failed to decrypt documentation for snapshot ${id}:`, (e as Error).message);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
public getSnapshotFiles(snapshotId: number): FleetSnapshotFile[] {
|
||||
|
||||
Reference in New Issue
Block a user