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:
Anso
2026-06-08 08:44:59 -04:00
committed by GitHub
parent 842ee7dd0c
commit 710647a44f
16 changed files with 1020 additions and 55 deletions
+9 -3
View File
@@ -11,7 +11,7 @@ import type { ImageCheckResult } from './ImageUpdateService';
import { isDebugEnabled } from '../utils/debug';
import { getErrorMessage } from '../utils/errors';
import { sanitizeForLog } from '../utils/safeLog';
import { captureLocalNodeFiles, captureRemoteNodeFiles, type SnapshotNodeData } from '../utils/snapshot-capture';
import { captureLocalNodeFiles, captureRemoteNodeFiles, buildSnapshotDocumentation, type SnapshotNodeData } from '../utils/snapshot-capture';
import { NodeRegistry } from './NodeRegistry';
import { NotificationService } from './NotificationService';
import TrivyService from './TrivyService';
@@ -533,13 +533,14 @@ export class SchedulerService {
private async executeSnapshot(task: ScheduledTask): Promise<string> {
const db = DatabaseService.getInstance();
const nodes = db.getNodes();
const captureDocs = db.getGlobalSettings().snapshot_documentation === '1';
const results = await Promise.allSettled(
nodes.map(async (node) => {
if (node.type === 'remote') {
return captureRemoteNodeFiles(node);
return captureRemoteNodeFiles(node, captureDocs);
}
return captureLocalNodeFiles(node);
return captureLocalNodeFiles(node, captureDocs);
})
);
@@ -585,6 +586,10 @@ export class SchedulerService {
}
}
const documentation = captureDocs
? buildSnapshotDocumentation(capturedNodes, new Date().toISOString())
: null;
const description = `Scheduled snapshot: ${task.name}`;
const snapshotId = db.createSnapshot(
description,
@@ -593,6 +598,7 @@ export class SchedulerService {
totalStacks,
JSON.stringify(skippedNodes),
JSON.stringify(skippedStacks),
documentation ? JSON.stringify(documentation) : '',
);
if (allFiles.length > 0) {