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
+127 -7
View File
@@ -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 };
}