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
+121 -8
View File
@@ -3,7 +3,7 @@ import path from 'path';
import semver from 'semver';
import si from 'systeminformation';
import type Dockerode from 'dockerode';
import { DatabaseService, type Node } from '../services/DatabaseService';
import { DatabaseService, type Node, type StackDossierFields } from '../services/DatabaseService';
import { ControlIdentityMismatchError, FleetSyncService, StaleSyncPushError } from '../services/FleetSyncService';
import { MAX_SYNC_ROWS, SYNC_ERROR_CODES } from '../services/fleetSyncConstants';
import { FleetUpdateTrackerService, type UpdateTracker, type TerminalStatus, UPDATE_TIMEOUT_MS, UPDATE_TIMEOUT_MSG, TERMINAL_TTL_MS } from '../services/FleetUpdateTrackerService';
@@ -17,7 +17,7 @@ import { authMiddleware } from '../middleware/auth';
import { requirePaid, requireAdmin, requireNodeProxy } from '../middleware/tierGates';
import { scheduleLocalUpdate } from './license';
import { runPolicyGate, assertPolicyGateAllows, buildPolicyGateOptions } from '../helpers/policyGate';
import { captureLocalNodeFiles, captureRemoteNodeFiles, type SnapshotNodeData } from '../utils/snapshot-capture';
import { captureLocalNodeFiles, captureRemoteNodeFiles, buildSnapshotDocumentation, pickDossierFields, dossierHasContent, type SnapshotNodeData, type SnapshotDocumentation } from '../utils/snapshot-capture';
import { getLatestVersion } from '../utils/version-check';
import { isValidStackName } from '../utils/validation';
import { isDebugEnabled } from '../utils/debug';
@@ -1629,14 +1629,15 @@ fleetRouter.post('/snapshots', authMiddleware, async (req: Request, res: Respons
const db = DatabaseService.getInstance();
const nodes = db.getNodes();
const username = req.user?.username || 'admin';
const captureDocs = db.getGlobalSettings().snapshot_documentation === '1';
const captureStart = Date.now();
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);
}),
);
@@ -1683,6 +1684,10 @@ fleetRouter.post('/snapshots', authMiddleware, async (req: Request, res: Respons
}
}
const documentation = captureDocs
? buildSnapshotDocumentation(capturedNodes, new Date().toISOString())
: null;
const snapshotId = db.createSnapshot(
description,
username,
@@ -1690,6 +1695,7 @@ fleetRouter.post('/snapshots', authMiddleware, async (req: Request, res: Respons
totalStacks,
JSON.stringify(skippedNodes),
JSON.stringify(skippedStacks),
documentation ? JSON.stringify(documentation) : '',
);
if (allFiles.length > 0) {
@@ -1785,8 +1791,30 @@ fleetRouter.get('/snapshots/:id', authMiddleware, async (req: Request, res: Resp
})),
}));
// Surface the captured dossier metadata when present so the detail view can
// render notes and offer to restore them. The list payload stays lean (only
// the has_documentation flag); the blob is decrypted here on demand.
let documentation: SnapshotDocumentation | undefined;
if (snapshot.has_documentation) {
const raw = db.getSnapshotDocumentation(id);
if (raw) {
try {
const parsed = JSON.parse(raw) as SnapshotDocumentation;
// Re-project each dossier through pickDossierFields so the client
// receives the same shape restore writes back, and never any field
// outside the eleven operator notes.
documentation = {
...parsed,
stacks: parsed.stacks.map(s => ({ ...s, dossier: pickDossierFields(s.dossier) })),
};
} catch (e) {
console.error(`[Fleet Snapshot] Failed to parse documentation for snapshot ${id}:`, getErrorMessage(e, 'parse error'));
}
}
}
if (isDebugEnabled()) console.debug('[Fleet:debug] Snapshot detail:', id, files.length, 'files');
res.json({ ...snapshot, nodes });
res.json({ ...snapshot, nodes, documentation });
} catch (error) {
console.error('[Fleet Snapshot] Detail error:', error);
res.status(500).json({ error: 'Failed to fetch snapshot details' });
@@ -1905,6 +1933,48 @@ async function redeploySnapshotStack(node: Node, stackName: string): Promise<voi
if (!deployRes.ok) throw await remoteStackError('Failed to redeploy stack', deployRes);
}
// Looks up the dossier notes a snapshot preserved for one stack, or undefined
// when no usable dossier entry exists for that stack (no documentation, an
// unparseable blob, or no matching entry).
function findSnapshotDossier(snapshotId: number, nodeId: number, stackName: string): StackDossierFields | undefined {
const raw = DatabaseService.getInstance().getSnapshotDocumentation(snapshotId);
if (!raw) return undefined;
let doc: SnapshotDocumentation;
try {
doc = JSON.parse(raw) as SnapshotDocumentation;
} catch (e) {
console.error(`[Fleet Snapshot] Failed to parse documentation for snapshot ${snapshotId}:`, getErrorMessage(e, 'parse error'));
return undefined;
}
// Guard against a malformed-but-parseable blob: `stacks` must be an array, and
// only restore notes that actually carry content, so a blank or tampered entry
// can never silently clobber the operator's current notes with empty fields.
if (!Array.isArray(doc.stacks)) return undefined;
const entry = doc.stacks.find(s => s?.nodeId === nodeId && s?.stackName === stackName);
if (!entry) return undefined;
const fields = pickDossierFields(entry.dossier);
return dossierHasContent(fields) ? fields : undefined;
}
// Writes captured dossier notes back to a stack: local nodes upsert into the
// DB, remote nodes receive them over the proxy. Only ever called when the
// operator explicitly opted in to restoring notes.
async function restoreSnapshotStackDossier(node: Node, stackName: string, fields: StackDossierFields): Promise<void> {
if (node.type === 'local') {
DatabaseService.getInstance().upsertStackDossier(node.id, stackName, fields);
return;
}
const ctx = buildRemoteProxyContext(node);
if (!ctx) throw new SnapshotProxyTargetError(formatNoTargetError(node));
const putRes = await fetch(`${ctx.baseUrl}/api/stacks/${encodeURIComponent(stackName)}/dossier`, {
method: 'PUT',
headers: ctx.headers,
body: JSON.stringify(fields),
signal: AbortSignal.timeout(15000),
});
if (!putRes.ok) throw await remoteStackError('Failed to restore dossier notes', putRes);
}
fleetRouter.post('/snapshots/:id/restore', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requireAdmin(req, res)) return;
@@ -1912,6 +1982,9 @@ fleetRouter.post('/snapshots/:id/restore', authMiddleware, async (req: Request,
const snapshotId = parseIntParam(req, res, 'id', 'snapshot ID');
if (snapshotId === null) return;
const { nodeId, stackName, redeploy = false } = req.body;
// Strict boolean: only an explicit `true` opts in, so a stray `"false"` or
// truthy value can never trigger an overwrite of the operator's notes.
const restoreNotes: boolean = req.body?.restoreNotes === true;
if (!nodeId || !stackName) {
res.status(400).json({ error: 'nodeId and stackName are required' });
@@ -1948,6 +2021,25 @@ fleetRouter.post('/snapshots/:id/restore', authMiddleware, async (req: Request,
await applySnapshotStackFiles(node, stackName, files);
// Dossier notes are restored only on explicit opt-in, so a routine file
// restore never clobbers the operator's current notes. The note write is
// best-effort relative to the file restore: the files are already on disk,
// so a notes failure (e.g. a remote dossier PUT) is reported, not fatal.
let notesRestored = false;
let notesError: string | undefined;
if (restoreNotes) {
const fields = findSnapshotDossier(snapshotId, nodeId, stackName);
if (fields) {
try {
await restoreSnapshotStackDossier(node, stackName, fields);
notesRestored = true;
} catch (e) {
notesError = getErrorMessage(e, 'Failed to restore documentation notes');
console.error(`[Fleet Snapshot] Note restore failed for stack "${sanitizeForLog(stackName)}":`, notesError);
}
}
}
if (redeploy) {
// Local deploys are gated centrally here; remote deploys are gated by the
// remote node's own deploy endpoint.
@@ -1956,7 +2048,7 @@ fleetRouter.post('/snapshots/:id/restore', authMiddleware, async (req: Request,
}
console.log('[Fleet] Snapshot restore: snapshot=%s node=%s stack=%s', snapshotId, sanitizeForLog(nodeId), sanitizeForLog(stackName));
res.json({ message: 'Stack restored successfully', redeployed: redeploy });
res.json({ message: 'Stack restored successfully', redeployed: redeploy, notesRestored, notesError });
} catch (error) {
if (error instanceof SnapshotProxyTargetError) {
res.status(503).json({ error: error.message });
@@ -1975,7 +2067,10 @@ interface SnapshotRestoreResult {
stackName: string;
success: boolean;
redeployed: boolean;
notesRestored: boolean;
error?: string;
/** A non-fatal documentation-notes restore failure; files still restored. */
notesError?: string;
}
fleetRouter.post('/snapshots/:id/restore-all', authMiddleware, async (req: Request, res: Response): Promise<void> => {
@@ -1985,6 +2080,7 @@ fleetRouter.post('/snapshots/:id/restore-all', authMiddleware, async (req: Reque
const snapshotId = parseIntParam(req, res, 'id', 'snapshot ID');
if (snapshotId === null) return;
const redeploy: boolean = req.body?.redeploy === true;
const restoreNotes: boolean = req.body?.restoreNotes === true;
const db = DatabaseService.getInstance();
const snapshot = db.getSnapshot(snapshotId);
@@ -2024,15 +2120,32 @@ fleetRouter.post('/snapshots/:id/restore-all', authMiddleware, async (req: Reque
await applySnapshotStackFiles(node, group.stackName, group.files);
// Files are restored; a notes failure is recorded but does not fail the
// stack (and must not block the redeploy below).
let notesRestored = false;
let notesError: string | undefined;
if (restoreNotes) {
const fields = findSnapshotDossier(snapshotId, group.nodeId, group.stackName);
if (fields) {
try {
await restoreSnapshotStackDossier(node, group.stackName, fields);
notesRestored = true;
} catch (e) {
notesError = getErrorMessage(e, 'Failed to restore documentation notes');
console.error(`[Fleet Snapshot] Note restore failed for stack "${sanitizeForLog(group.stackName)}":`, notesError);
}
}
}
let redeployed = false;
if (redeploy) {
if (node.type === 'local') await assertPolicyGateAllows(group.stackName, node.id, policyOptions);
await redeploySnapshotStack(node, group.stackName);
redeployed = true;
}
results.push({ nodeId: group.nodeId, nodeName: group.nodeName, stackName: group.stackName, success: true, redeployed });
results.push({ nodeId: group.nodeId, nodeName: group.nodeName, stackName: group.stackName, success: true, redeployed, notesRestored, notesError });
} catch (e) {
results.push({ nodeId: group.nodeId, nodeName: group.nodeName, stackName: group.stackName, success: false, redeployed: false, error: getErrorMessage(e, 'Restore failed') });
results.push({ nodeId: group.nodeId, nodeName: group.nodeName, stackName: group.stackName, success: false, redeployed: false, notesRestored: false, error: getErrorMessage(e, 'Restore failed') });
}
}
+2
View File
@@ -26,6 +26,7 @@ const ALLOWED_SETTING_KEYS = new Set([
'scan_history_per_image_limit',
'prune_on_update',
'reclaim_hero',
'snapshot_documentation',
]);
// Keys whose write requires a paid license, not just an admin role.
@@ -50,6 +51,7 @@ const SettingsPatchSchema = z.object({
scan_history_per_image_limit: z.coerce.number().int().min(5).max(1000).transform(String),
prune_on_update: z.enum(['0', '1']),
reclaim_hero: z.enum(['0', '1']),
snapshot_documentation: z.enum(['0', '1']),
}).partial();
export const settingsRouter = Router();