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:
+121
-8
@@ -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') });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user