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
@@ -190,7 +190,10 @@ describe('CloudBackupService — uploadSnapshot', () => {
const parsed = JSON.parse(meta!.content);
expect(parsed.id).toBe(snapshotId);
expect(parsed.instance_id).toBe('test-instance-id');
expect(parsed.archive_version).toBe(1);
expect(parsed.archive_version).toBe(2);
expect(parsed.has_documentation).toBe(false);
// No dossier metadata was captured, so the archive omits documentation.json.
expect(entries.find(e => e.name === 'documentation.json')).toBeUndefined();
// Content is encrypted at rest but the archive must carry plaintext so a
// downloaded snapshot restores on any instance (portability contract).
@@ -202,6 +205,48 @@ describe('CloudBackupService — uploadSnapshot', () => {
expect(CloudBackupService.getInstance().getUploadStatus(snapshotId).status).toBe('success');
});
it('includes documentation.json when the snapshot captured dossier metadata', async () => {
seedCustomProvider();
const db = DatabaseService.getInstance();
const docJson = JSON.stringify({
generated_at: '2026-01-01T00:00:00Z',
stacks: [{ nodeId: 1, nodeName: 'gateway', stackName: 'web', dossier: { purpose: 'edge proxy' } }],
warnings: [],
});
const snapshotId = db.createSnapshot('Documented backup', 'admin', 1, 1, '[]', '[]', docJson);
db.insertSnapshotFiles(snapshotId, [
{ nodeId: 1, nodeName: 'gateway', stackName: 'web', filename: 'compose.yaml', content: 'services: {}\n' },
]);
sentSpy.mockResolvedValue({});
await CloudBackupService.getInstance().uploadSnapshot(snapshotId);
const putCall = sentSpy.mock.calls.find(c => c[0].name === 'PutObjectCommand');
const input = putCall![0].input as { Body: Buffer };
const decompressed = zlib.gunzipSync(input.Body);
const entries: Array<{ name: string; content: string }> = await new Promise((resolve, reject) => {
const extract = tar.extract();
const list: Array<{ name: string; content: string }> = [];
extract.on('entry', (header, stream, next) => {
const chunks: Buffer[] = [];
stream.on('data', (c: Buffer) => chunks.push(c));
stream.on('end', () => { list.push({ name: header.name, content: Buffer.concat(chunks).toString('utf-8') }); next(); });
stream.resume();
});
extract.on('finish', () => resolve(list));
extract.on('error', reject);
Readable.from(decompressed).pipe(extract);
});
const meta = JSON.parse(entries.find(e => e.name === 'metadata.json')!.content);
expect(meta.has_documentation).toBe(true);
const docEntry = entries.find(e => e.name === 'documentation.json');
expect(docEntry).toBeDefined();
// The archive carries plaintext dossier metadata for portability.
expect(JSON.parse(docEntry!.content).stacks[0].dossier.purpose).toBe('edge proxy');
});
it('records failure status when upload throws', async () => {
seedCustomProvider();
const db = DatabaseService.getInstance();
@@ -17,6 +17,7 @@ let DatabaseService: typeof import('../services/DatabaseService').DatabaseServic
let CryptoService: typeof import('../services/CryptoService').CryptoService;
let ComposeService: typeof import('../services/ComposeService').ComposeService;
let LicenseService: typeof import('../services/LicenseService').LicenseService;
let NodeRegistry: typeof import('../services/NodeRegistry').NodeRegistry;
let adminCookie: string;
let viewerCookie: string;
let snapshotId: number;
@@ -25,12 +26,19 @@ const VIEWER_USER = 'viewer-snap';
const VIEWER_PASS = 'viewer-pass-123';
const ENV_SECRET = 'DB_PASSWORD=s3cr3t-value\n';
// Every operator-authored dossier field, blank, for building test dossiers.
const BLANK_FIELDS = {
purpose: '', owner: '', access_urls: '', static_ip: '', vlan: '', firewall_notes: '',
reverse_proxy_notes: '', backup_notes: '', upgrade_notes: '', recovery_notes: '', custom_notes: '',
};
beforeAll(async () => {
tmpDir = await setupTestDb();
({ DatabaseService } = await import('../services/DatabaseService'));
({ CryptoService } = await import('../services/CryptoService'));
({ ComposeService } = await import('../services/ComposeService'));
({ LicenseService } = await import('../services/LicenseService'));
({ NodeRegistry } = await import('../services/NodeRegistry'));
({ app } = await import('../index'));
adminCookie = await loginAsTestAdmin(app);
@@ -232,6 +240,280 @@ describe('Single-stack snapshot restore (behavior lock)', () => {
});
});
describe('Snapshot documentation capture (persistence)', () => {
const docJson = JSON.stringify({
generated_at: '2026-01-01T00:00:00Z',
stacks: [{ nodeId: LOCAL_NODE_ID, nodeName: 'local', stackName: 'doc-web', dossier: { ...BLANK_FIELDS, purpose: 'edge', owner: 'ops' } }],
warnings: [],
});
it('stores documentation encrypted at rest and flags has_documentation', () => {
const db = DatabaseService.getInstance();
const id = db.createSnapshot('doc-snap', 'admin', 1, 1, '[]', '[]', docJson);
expect(db.getSnapshot(id)!.has_documentation).toBe(1);
const raw = db.getDb().prepare('SELECT documentation FROM fleet_snapshots WHERE id = ?').get(id) as { documentation: string };
expect(CryptoService.getInstance().isEncrypted(raw.documentation)).toBe(true);
expect(raw.documentation).not.toContain('edge');
expect(db.getSnapshotDocumentation(id)).toBe(docJson);
});
it('leaves documentation empty and has_documentation 0 when none captured', () => {
const db = DatabaseService.getInstance();
const id = db.createSnapshot('no-doc', 'admin', 1, 1, '[]', '[]');
expect(db.getSnapshot(id)!.has_documentation).toBe(0);
expect(db.getSnapshotDocumentation(id)).toBe('');
});
it('GET detail surfaces the documentation object for an admin', async () => {
const db = DatabaseService.getInstance();
const id = db.createSnapshot('doc-detail', 'admin', 1, 1, '[]', '[]', docJson);
db.insertSnapshotFiles(id, [
{ nodeId: LOCAL_NODE_ID, nodeName: 'local', stackName: 'doc-web', filename: 'compose.yaml', content: 'services: {}\n' },
]);
const res = await request(app).get(`/api/fleet/snapshots/${id}`).set('Cookie', adminCookie);
expect(res.status).toBe(200);
expect(res.body.has_documentation).toBe(1);
expect(res.body.documentation.stacks[0]).toMatchObject({ nodeId: LOCAL_NODE_ID, stackName: 'doc-web', dossier: { purpose: 'edge' } });
});
it('detail degrades to no documentation (still 200) when the blob is unparseable', async () => {
const db = DatabaseService.getInstance();
const id = db.createSnapshot('doc-corrupt', 'admin', 1, 1, '[]', '[]', JSON.stringify({ generated_at: 'x', stacks: [], warnings: [] }));
// Overwrite the encrypted column with non-JSON plaintext to simulate a
// corrupt or tampered blob; getSnapshotDocumentation returns it verbatim
// (decrypt passes non-ciphertext through) and the route's JSON.parse fails.
db.getDb().prepare('UPDATE fleet_snapshots SET documentation = ? WHERE id = ?').run('not-json', id);
const res = await request(app).get(`/api/fleet/snapshots/${id}`).set('Cookie', adminCookie);
expect(res.status).toBe(200);
expect(res.body.documentation).toBeUndefined();
});
});
describe('Snapshot restore: dossier notes opt-in', () => {
afterEach(() => vi.restoreAllMocks());
function snapWithNotes(stackName: string, purpose: string): number {
const db = DatabaseService.getInstance();
const docJson = JSON.stringify({
generated_at: 'x',
stacks: [{ nodeId: LOCAL_NODE_ID, nodeName: 'local', stackName, dossier: { ...BLANK_FIELDS, purpose } }],
warnings: [],
});
const id = db.createSnapshot(`notes-${stackName}`, 'admin', 1, 1, '[]', '[]', docJson);
db.insertSnapshotFiles(id, [
{ nodeId: LOCAL_NODE_ID, nodeName: 'local', stackName, filename: 'compose.yaml', content: 'services: {}\n' },
]);
seedStackDir(stackName);
return id;
}
it('does NOT overwrite current notes when restoreNotes is omitted', async () => {
const db = DatabaseService.getInstance();
db.upsertStackDossier(LOCAL_NODE_ID, 'notes-keep', { ...BLANK_FIELDS, purpose: 'current' });
const id = snapWithNotes('notes-keep', 'snapshot-version');
const res = await request(app)
.post(`/api/fleet/snapshots/${id}/restore`)
.set('Cookie', adminCookie)
.send({ nodeId: LOCAL_NODE_ID, stackName: 'notes-keep' });
expect(res.status).toBe(200);
expect(res.body.notesRestored).toBe(false);
expect(db.getStackDossier(LOCAL_NODE_ID, 'notes-keep')?.purpose).toBe('current');
});
it('restores notes when restoreNotes is true', async () => {
const db = DatabaseService.getInstance();
db.upsertStackDossier(LOCAL_NODE_ID, 'notes-overwrite', { ...BLANK_FIELDS, purpose: 'current' });
const id = snapWithNotes('notes-overwrite', 'snapshot-version');
const res = await request(app)
.post(`/api/fleet/snapshots/${id}/restore`)
.set('Cookie', adminCookie)
.send({ nodeId: LOCAL_NODE_ID, stackName: 'notes-overwrite', restoreNotes: true });
expect(res.status).toBe(200);
expect(res.body.notesRestored).toBe(true);
expect(db.getStackDossier(LOCAL_NODE_ID, 'notes-overwrite')?.purpose).toBe('snapshot-version');
});
it('reports notesRestored false when the snapshot has no documentation', async () => {
const db = DatabaseService.getInstance();
const id = db.createSnapshot('notes-none', 'admin', 1, 1, '[]', '[]');
db.insertSnapshotFiles(id, [
{ nodeId: LOCAL_NODE_ID, nodeName: 'local', stackName: 'notes-none-web', filename: 'compose.yaml', content: 'services: {}\n' },
]);
seedStackDir('notes-none-web');
const res = await request(app)
.post(`/api/fleet/snapshots/${id}/restore`)
.set('Cookie', adminCookie)
.send({ nodeId: LOCAL_NODE_ID, stackName: 'notes-none-web', restoreNotes: true });
expect(res.status).toBe(200);
expect(res.body.notesRestored).toBe(false);
});
it('ignores a non-boolean restoreNotes (string "false") and leaves notes untouched', async () => {
const db = DatabaseService.getInstance();
db.upsertStackDossier(LOCAL_NODE_ID, 'notes-strict', { ...BLANK_FIELDS, purpose: 'current' });
const id = snapWithNotes('notes-strict', 'snapshot-version');
const res = await request(app)
.post(`/api/fleet/snapshots/${id}/restore`)
.set('Cookie', adminCookie)
.send({ nodeId: LOCAL_NODE_ID, stackName: 'notes-strict', restoreNotes: 'false' });
expect(res.status).toBe(200);
expect(res.body.notesRestored).toBe(false);
expect(db.getStackDossier(LOCAL_NODE_ID, 'notes-strict')?.purpose).toBe('current');
});
it('does not restore notes from a malformed documentation blob', async () => {
const db = DatabaseService.getInstance();
db.upsertStackDossier(LOCAL_NODE_ID, 'notes-malformed', { ...BLANK_FIELDS, purpose: 'current' });
const id = db.createSnapshot('notes-bad-doc', 'admin', 1, 1, '[]', '[]', JSON.stringify({ generated_at: 'x', stacks: null, warnings: [] }));
db.insertSnapshotFiles(id, [
{ nodeId: LOCAL_NODE_ID, nodeName: 'local', stackName: 'notes-malformed', filename: 'compose.yaml', content: 'services: {}\n' },
]);
seedStackDir('notes-malformed');
const res = await request(app)
.post(`/api/fleet/snapshots/${id}/restore`)
.set('Cookie', adminCookie)
.send({ nodeId: LOCAL_NODE_ID, stackName: 'notes-malformed', restoreNotes: true });
expect(res.status).toBe(200);
expect(res.body.notesRestored).toBe(false);
expect(db.getStackDossier(LOCAL_NODE_ID, 'notes-malformed')?.purpose).toBe('current');
});
it('does not overwrite current notes with an all-blank captured dossier', async () => {
const db = DatabaseService.getInstance();
db.upsertStackDossier(LOCAL_NODE_ID, 'notes-blank', { ...BLANK_FIELDS, purpose: 'current' });
const docJson = JSON.stringify({
generated_at: 'x',
stacks: [{ nodeId: LOCAL_NODE_ID, nodeName: 'local', stackName: 'notes-blank', dossier: { ...BLANK_FIELDS } }],
warnings: [],
});
const id = db.createSnapshot('notes-blank-doc', 'admin', 1, 1, '[]', '[]', docJson);
db.insertSnapshotFiles(id, [
{ nodeId: LOCAL_NODE_ID, nodeName: 'local', stackName: 'notes-blank', filename: 'compose.yaml', content: 'services: {}\n' },
]);
seedStackDir('notes-blank');
const res = await request(app)
.post(`/api/fleet/snapshots/${id}/restore`)
.set('Cookie', adminCookie)
.send({ nodeId: LOCAL_NODE_ID, stackName: 'notes-blank', restoreNotes: true });
expect(res.status).toBe(200);
expect(res.body.notesRestored).toBe(false);
expect(db.getStackDossier(LOCAL_NODE_ID, 'notes-blank')?.purpose).toBe('current');
});
it('restore-all restores notes only for stacks the snapshot documented', async () => {
const db = DatabaseService.getInstance();
// Only 'all-a' carries dossier notes; 'all-b' has files but no notes.
const docJson = JSON.stringify({
generated_at: 'x',
stacks: [{ nodeId: LOCAL_NODE_ID, nodeName: 'local', stackName: 'allnotes-a', dossier: { ...BLANK_FIELDS, purpose: 'documented' } }],
warnings: [],
});
const id = db.createSnapshot('restore-all-notes', 'admin', 1, 2, '[]', '[]', docJson);
db.insertSnapshotFiles(id, [
{ nodeId: LOCAL_NODE_ID, nodeName: 'local', stackName: 'allnotes-a', filename: 'compose.yaml', content: 'services: {}\n' },
{ nodeId: LOCAL_NODE_ID, nodeName: 'local', stackName: 'allnotes-b', filename: 'compose.yaml', content: 'services: {}\n' },
]);
seedStackDir('allnotes-a');
seedStackDir('allnotes-b');
const res = await request(app)
.post(`/api/fleet/snapshots/${id}/restore-all`)
.set('Cookie', adminCookie)
.send({ restoreNotes: true });
expect(res.status).toBe(200);
expect(res.body.restored).toBe(2);
const results = res.body.results as Array<{ stackName: string; notesRestored: boolean }>;
expect(results.find(r => r.stackName === 'allnotes-a')?.notesRestored).toBe(true);
expect(results.find(r => r.stackName === 'allnotes-b')?.notesRestored).toBe(false);
expect(db.getStackDossier(LOCAL_NODE_ID, 'allnotes-a')?.purpose).toBe('documented');
expect(db.getStackDossier(LOCAL_NODE_ID, 'allnotes-b')).toBeUndefined();
});
});
describe('Snapshot restore: remote dossier notes (proxy PUT)', () => {
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); });
function remoteDocSnapshot(stackName: string, purpose: string): { id: number; remoteId: number } {
const db = DatabaseService.getInstance();
const remoteId = db.addNode({ name: `remote-${stackName}`, type: 'remote', api_url: 'http://remote:1852', api_token: 'tok', compose_dir: '/app/compose', is_default: false });
const docJson = JSON.stringify({
generated_at: 'x',
stacks: [{ nodeId: remoteId, nodeName: `remote-${stackName}`, stackName, dossier: { ...BLANK_FIELDS, purpose } }],
warnings: [],
});
const id = db.createSnapshot(`remote-notes-${stackName}`, 'admin', 1, 1, '[]', '[]', docJson);
db.insertSnapshotFiles(id, [
{ nodeId: remoteId, nodeName: `remote-${stackName}`, stackName, filename: 'compose.yaml', content: 'services: {}\n' },
]);
return { id, remoteId };
}
it('writes notes to a remote node via the proxy dossier PUT when opted in', async () => {
const { id, remoteId } = remoteDocSnapshot('rweb', 'documented');
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'http://remote:1852', apiToken: 'tok' });
const calls: Array<{ url: string; method?: string }> = [];
vi.stubGlobal('fetch', vi.fn(async (url: string, opts?: { method?: string }) => {
calls.push({ url, method: opts?.method });
return { ok: true, status: 200, text: async () => '' } as unknown as Response;
}));
const res = await request(app)
.post(`/api/fleet/snapshots/${id}/restore`)
.set('Cookie', adminCookie)
.send({ nodeId: remoteId, stackName: 'rweb', restoreNotes: true });
expect(res.status).toBe(200);
expect(res.body.notesRestored).toBe(true);
expect(res.body.notesError).toBeUndefined();
expect(calls.some(c => /\/dossier$/.test(c.url) && c.method === 'PUT')).toBe(true);
});
it('reports a non-fatal notesError when the remote dossier PUT fails but files restored', async () => {
const { id, remoteId } = remoteDocSnapshot('rweb2', 'documented');
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'http://remote:1852', apiToken: 'tok' });
vi.stubGlobal('fetch', vi.fn(async (url: string) => {
if (/\/dossier$/.test(url)) return { ok: false, status: 500, text: async () => 'boom' } as unknown as Response;
return { ok: true, status: 200, text: async () => '' } as unknown as Response;
}));
const res = await request(app)
.post(`/api/fleet/snapshots/${id}/restore`)
.set('Cookie', adminCookie)
.send({ nodeId: remoteId, stackName: 'rweb2', restoreNotes: true });
// File restore succeeded; only the optional notes write failed.
expect(res.status).toBe(200);
expect(res.body.notesRestored).toBe(false);
expect(res.body.notesError).toBeTruthy();
});
it('restore-all records a per-row notesError but keeps the stack success when the remote notes PUT fails', async () => {
// restore-all is driven by snapshot id; the target node is resolved from
// the snapshot's stored files, so the returned remoteId is not needed here.
const { id } = remoteDocSnapshot('rweb3', 'documented');
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'http://remote:1852', apiToken: 'tok' });
vi.stubGlobal('fetch', vi.fn(async (url: string) => {
if (/\/dossier$/.test(url)) return { ok: false, status: 500, text: async () => 'boom' } as unknown as Response;
return { ok: true, status: 200, text: async () => '' } as unknown as Response;
}));
const res = await request(app)
.post(`/api/fleet/snapshots/${id}/restore-all`)
.set('Cookie', adminCookie)
.send({ restoreNotes: true });
expect(res.status).toBe(200);
expect(res.body.restored).toBe(1);
expect(res.body.failed).toBe(0);
const row = (res.body.results as Array<{ stackName: string; success: boolean; notesRestored: boolean; notesError?: string }>)
.find(r => r.stackName === 'rweb3');
expect(row?.success).toBe(true);
expect(row?.notesRestored).toBe(false);
expect(row?.notesError).toBeTruthy();
});
});
describe('Restore-all', () => {
afterEach(() => vi.restoreAllMocks());
@@ -10,6 +10,7 @@ import type { ScheduledTask } from '../services/DatabaseService';
const {
mockGetDueScheduledTasks, mockCreateScheduledTaskRun, mockUpdateScheduledTaskRun,
mockUpdateScheduledTask, mockCleanupOldTaskRuns, mockGetScheduledTask, mockGetNodes, mockGetNode,
mockGetGlobalSettings, mockGetStackDossier,
mockCreateSnapshot, mockInsertSnapshotFiles, mockClearStackUpdateStatus,
mockMarkStaleRunsAsFailed, mockDeleteOldScans,
mockGetTier, mockGetProxyHeaders,
@@ -36,6 +37,8 @@ const {
mockGetScheduledTask: vi.fn(),
mockGetNodes: vi.fn().mockReturnValue([]),
mockGetNode: vi.fn().mockReturnValue({ id: 1, name: 'local', type: 'local', status: 'online' }),
mockGetGlobalSettings: vi.fn().mockReturnValue({}),
mockGetStackDossier: vi.fn().mockReturnValue(undefined),
mockCreateSnapshot: vi.fn().mockReturnValue(1),
mockInsertSnapshotFiles: vi.fn(),
mockClearStackUpdateStatus: vi.fn(),
@@ -80,6 +83,8 @@ vi.mock('../services/DatabaseService', () => ({
getScheduledTask: mockGetScheduledTask,
getNodes: mockGetNodes,
getNode: mockGetNode,
getGlobalSettings: mockGetGlobalSettings,
getStackDossier: mockGetStackDossier,
createSnapshot: mockCreateSnapshot,
insertSnapshotFiles: mockInsertSnapshotFiles,
clearStackUpdateStatus: mockClearStackUpdateStatus,
@@ -201,6 +206,10 @@ beforeEach(() => {
mockGetTier.mockReturnValue('paid');
mockGetNode.mockReturnValue({ id: 1, name: 'local', type: 'local', status: 'online' });
mockGetProxyTarget.mockReturnValue(null);
// Documentation capture is opt-in and off by default; reset so a test that
// enables it does not leak into later snapshot tests.
mockGetGlobalSettings.mockReturnValue({});
mockGetStackDossier.mockReturnValue(undefined);
// Default: the scan-policy gate allows. Individual tests override to a block.
mockEnforcePolicyPreDeploy.mockResolvedValue({ ok: true, bypassed: false, violations: [] });
(SchedulerService as any).instance = undefined;
@@ -1391,6 +1400,7 @@ describe('SchedulerService - executeSnapshot', () => {
1,
expect.any(String),
expect.any(String),
'',
);
expect(mockUpdateScheduledTaskRun).toHaveBeenCalledWith(
1,
@@ -1423,6 +1433,30 @@ describe('SchedulerService - executeSnapshot', () => {
expect.objectContaining({ status: 'success' })
);
});
it('captures dossier documentation when the snapshot_documentation setting is on', async () => {
mockGetScheduledTask.mockReturnValue({
id: 77,
name: 'documented-snapshot',
action: 'snapshot',
target_type: 'fleet',
cron_expression: '0 3 * * *',
enabled: true,
created_by: 'admin',
last_status: null,
});
mockGetNodes.mockReturnValue([{ id: 1, name: 'local', type: 'local' }]);
mockGetStacks.mockResolvedValue(['app1']);
mockGetStackContent.mockResolvedValue('services: {}\n');
mockGetGlobalSettings.mockReturnValue({ snapshot_documentation: '1' });
mockGetStackDossier.mockReturnValue({ purpose: 'documented', owner: '', access_urls: '', static_ip: '', vlan: '', firewall_notes: '', reverse_proxy_notes: '', backup_notes: '', upgrade_notes: '', recovery_notes: '', custom_notes: '' });
await SchedulerService.getInstance().triggerTask(77);
const docArg = mockCreateSnapshot.mock.calls.at(-1)?.[6] as string;
expect(docArg).toBeTruthy();
expect(JSON.parse(docArg).stacks[0]).toMatchObject({ stackName: 'app1', dossier: { purpose: 'documented' } });
});
});
// ── executeUpdateRemote (T4) ─────────────────────────────────────────
@@ -10,6 +10,7 @@ const mockGetStacks = vi.fn();
const mockGetStackContent = vi.fn();
const mockGetEnvContent = vi.fn();
const mockGetProxyTarget = vi.fn();
const mockGetStackDossier = vi.fn();
vi.mock('../services/FileSystemService', () => ({
FileSystemService: { getInstance: () => ({
@@ -23,11 +24,29 @@ vi.mock('../services/NodeRegistry', () => ({
NodeRegistry: { getInstance: () => ({ getProxyTarget: mockGetProxyTarget }) },
}));
vi.mock('../services/DatabaseService', () => ({
DatabaseService: { getInstance: () => ({ getStackDossier: mockGetStackDossier }) },
}));
import {
captureLocalNodeFiles,
captureRemoteNodeFiles,
buildSnapshotDocumentation,
pickDossierFields,
dossierHasContent,
MAX_SNAPSHOT_FILE_BYTES,
type SnapshotNodeData,
} from '../utils/snapshot-capture';
import type { StackDossierFields } from '../services/DatabaseService';
const BLANK_DOSSIER: StackDossierFields = {
purpose: '', owner: '', access_urls: '', static_ip: '', vlan: '', firewall_notes: '',
reverse_proxy_notes: '', backup_notes: '', upgrade_notes: '', recovery_notes: '', custom_notes: '',
};
function dossier(partial: Partial<StackDossierFields>): StackDossierFields {
return { ...BLANK_DOSSIER, ...partial };
}
const localNode = { id: 1, name: 'local', mode: 'proxy' as const };
const remoteNode = { id: 2, name: 'remote', mode: 'proxy' as const };
@@ -221,3 +240,137 @@ describe('captureRemoteNodeFiles', () => {
expect(result.warnings[0].reason).toContain('fetch error');
});
});
describe('documentation capture', () => {
function mockFetchRoutes(routes: Record<string, Partial<Response> & { jsonValue?: unknown; textValue?: string; xEnvExists?: string }>) {
const fetchMock = vi.fn(async (url: string) => {
const match = Object.keys(routes).find(k => url.endsWith(k));
const r = match ? routes[match] : { ok: false, status: 404 };
return {
ok: r.ok ?? true,
status: r.status ?? 200,
headers: { get: (name: string) => name.toLowerCase() === 'x-env-exists' ? ((r as { xEnvExists?: string }).xEnvExists ?? null) : null },
json: async () => (r as { jsonValue?: unknown }).jsonValue,
text: async () => (r as { textValue?: string }).textValue ?? '',
} as unknown as Response;
});
vi.stubGlobal('fetch', fetchMock);
return fetchMock;
}
it('captures local dossier notes when captureDocs is on and the stack has content', async () => {
mockGetStacks.mockResolvedValue(['web']);
mockGetStackContent.mockResolvedValue('services: {}\n');
mockGetEnvContent.mockRejectedValue(Object.assign(new Error('no file'), { code: 'ENOENT' }));
mockGetStackDossier.mockReturnValue(dossier({ purpose: 'edge proxy', owner: 'ops' }));
const result = await captureLocalNodeFiles(localNode, true);
expect(result.stacks[0].dossier).toMatchObject({ purpose: 'edge proxy', owner: 'ops' });
expect(result.docWarnings).toHaveLength(0);
});
it('omits the local dossier when every field is blank', async () => {
mockGetStacks.mockResolvedValue(['web']);
mockGetStackContent.mockResolvedValue('services: {}\n');
mockGetEnvContent.mockRejectedValue(Object.assign(new Error('no file'), { code: 'ENOENT' }));
mockGetStackDossier.mockReturnValue(undefined);
const result = await captureLocalNodeFiles(localNode, true);
expect(result.stacks[0].dossier).toBeUndefined();
});
it('does not read dossiers when captureDocs is off (default)', async () => {
mockGetStacks.mockResolvedValue(['web']);
mockGetStackContent.mockResolvedValue('services: {}\n');
mockGetEnvContent.mockRejectedValue(Object.assign(new Error('no file'), { code: 'ENOENT' }));
const result = await captureLocalNodeFiles(localNode);
expect(result.stacks[0].dossier).toBeUndefined();
expect(mockGetStackDossier).not.toHaveBeenCalled();
});
it('captures dossier notes from the remote dossier endpoint', async () => {
mockFetchRoutes({
'/api/stacks': { ok: true, jsonValue: ['web'] },
'/api/stacks/web': { ok: true, textValue: 'services: {}\n' },
'/api/stacks/web/env': { ok: false, status: 404 },
'/api/stacks/web/dossier': { ok: true, jsonValue: dossier({ purpose: 'edge' }) },
});
const result = await captureRemoteNodeFiles(remoteNode, true);
expect(result.stacks[0].dossier).toMatchObject({ purpose: 'edge' });
expect(result.docWarnings).toHaveLength(0);
});
it('records a doc warning when the remote dossier fetch fails (non-404)', async () => {
mockFetchRoutes({
'/api/stacks': { ok: true, jsonValue: ['web'] },
'/api/stacks/web': { ok: true, textValue: 'services: {}\n' },
'/api/stacks/web/env': { ok: false, status: 404 },
'/api/stacks/web/dossier': { ok: false, status: 500 },
});
const result = await captureRemoteNodeFiles(remoteNode, true);
expect(result.stacks).toHaveLength(1);
expect(result.stacks[0].dossier).toBeUndefined();
expect(result.docWarnings[0].reason).toContain('HTTP 500');
});
it('treats a 404 dossier as absent without warning', async () => {
mockFetchRoutes({
'/api/stacks': { ok: true, jsonValue: ['web'] },
'/api/stacks/web': { ok: true, textValue: 'services: {}\n' },
'/api/stacks/web/env': { ok: false, status: 404 },
'/api/stacks/web/dossier': { ok: false, status: 404 },
});
const result = await captureRemoteNodeFiles(remoteNode, true);
expect(result.stacks[0].dossier).toBeUndefined();
expect(result.docWarnings).toHaveLength(0);
});
it('pickDossierFields keeps only the eleven string fields and drops extras', () => {
const f = pickDossierFields({ purpose: 'p', owner: 'o', node_id: 5, source_hash: 'x' } as Record<string, unknown>);
expect(f.purpose).toBe('p');
expect(f.owner).toBe('o');
expect(Object.keys(f)).toHaveLength(11);
expect((f as unknown as Record<string, unknown>).node_id).toBeUndefined();
});
it('dossierHasContent is false for all-blank or whitespace, true for any real value', () => {
expect(dossierHasContent(BLANK_DOSSIER)).toBe(false);
expect(dossierHasContent(dossier({ vlan: ' ' }))).toBe(false);
expect(dossierHasContent(dossier({ purpose: 'x' }))).toBe(true);
});
it('buildSnapshotDocumentation returns null when nothing was captured', () => {
const nodes: SnapshotNodeData[] = [
{ nodeId: 1, nodeName: 'a', stacks: [{ stackName: 'web', files: [] }], warnings: [], docWarnings: [] },
];
expect(buildSnapshotDocumentation(nodes, 'now')).toBeNull();
});
it('buildSnapshotDocumentation aggregates dossiers and dossier warnings', () => {
const nodes: SnapshotNodeData[] = [{
nodeId: 1,
nodeName: 'a',
stacks: [{ stackName: 'web', files: [], dossier: dossier({ purpose: 'p' }) }],
warnings: [],
docWarnings: [{ stackName: 'db', reason: 'boom' }],
}];
const doc = buildSnapshotDocumentation(nodes, '2026-01-01T00:00:00Z');
expect(doc).not.toBeNull();
expect(doc!.stacks).toHaveLength(1);
expect(doc!.stacks[0]).toMatchObject({ nodeId: 1, nodeName: 'a', stackName: 'web' });
expect(doc!.warnings[0]).toMatchObject({ nodeId: 1, stackName: 'db', reason: 'boom' });
expect(doc!.generated_at).toBe('2026-01-01T00:00:00Z');
});
});
+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();
+12 -2
View File
@@ -252,11 +252,12 @@ export class CloudBackupService {
const snapshot = db.getSnapshot(snapshotId);
if (!snapshot) throw new Error(`Snapshot ${snapshotId} not found.`);
const files = db.getSnapshotFiles(snapshotId);
const documentation = db.getSnapshotDocumentation(snapshotId);
const objectKey = this.buildObjectKey(cfg, snapshot.id, snapshot.description, snapshot.created_at);
this.setStatus(snapshotId, { status: 'uploading', objectKey, updatedAt: Date.now() });
try {
const archive = await this.buildArchive(snapshot, files);
const archive = await this.buildArchive(snapshot, files, documentation);
const { client, sdk } = await this.buildS3Client(cfg);
await client.send(new sdk.PutObjectCommand({
Bucket: cfg.bucket,
@@ -364,6 +365,7 @@ export class CloudBackupService {
private async buildArchive(
snapshot: { id: number; description: string; created_by: string; node_count: number; stack_count: number; skipped_nodes: string; created_at: number },
files: FleetSnapshotFile[],
documentation = '',
): Promise<Buffer> {
const pack = tar.pack();
const metadata = {
@@ -374,11 +376,19 @@ export class CloudBackupService {
node_count: snapshot.node_count,
stack_count: snapshot.stack_count,
skipped_nodes: safeParseJson(snapshot.skipped_nodes, []),
has_documentation: documentation !== '',
instance_id: this.getInstanceId(),
archive_version: 1,
// Version 2 adds the optional documentation.json entry; readers of
// version 1 archives simply will not find that file.
archive_version: 2,
};
pack.entry({ name: 'metadata.json' }, JSON.stringify(metadata, null, 2));
// Captured Stack Dossier metadata travels with the archive when present.
if (documentation !== '') {
pack.entry({ name: 'documentation.json' }, documentation);
}
for (const file of files) {
const safeNodeName = sanitizePathSegment(file.node_name);
const safeStackName = sanitizePathSegment(file.stack_name);
+39 -5
View File
@@ -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[] {
+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) {
+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 };
}