mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +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:
@@ -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
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -30,6 +30,12 @@ If a remote node is offline or unreachable, it is **skipped gracefully**. The sn
|
||||
|
||||
Fleet snapshots can also be created automatically on a recurring schedule. Navigate to the **Schedules** view, create a new scheduled operation, and select **Fleet Snapshot** as the action type. Scheduled snapshots appear in the snapshot list with a "Scheduled snapshot" prefix in their description.
|
||||
|
||||
### Capturing stack documentation
|
||||
|
||||
Snapshots can also preserve each stack's [Dossier](/features/stack-dossier) notes (purpose, owner, access URLs, firewall and recovery notes, and the rest) alongside the captured files, so a recovery restores the operational knowledge around a stack, not just its configuration.
|
||||
|
||||
This is off by default. Turn it on in **Settings → Infrastructure → Fleet** with **Capture stack documentation in snapshots**. Once enabled, every manual and scheduled snapshot records the current Dossier notes for each captured stack. Stacks with no notes are simply omitted, and a stack's notes are never required. Secret values are never included: a Dossier only ever holds the notes an operator typed, never the contents of a `.env`.
|
||||
|
||||
## Browsing snapshots
|
||||
|
||||
The snapshot list shows each snapshot in a table with the following columns:
|
||||
@@ -48,9 +54,9 @@ The snapshot list shows each snapshot in a table with the following columns:
|
||||
|
||||
Click **View** on any snapshot to open the detail view. Use **Back to Snapshots** in the top-left to return to the list.
|
||||
|
||||
The header shows the snapshot's title (or "Untitled Snapshot"), who created it, when, and badge counts for nodes and stacks captured.
|
||||
The header shows the snapshot's title (or "Untitled Snapshot"), who created it, when, and badge counts for nodes and stacks captured. When the snapshot preserved Dossier notes, a **Documentation captured** badge appears alongside those counts.
|
||||
|
||||
Below the header, each node appears as a collapsible card. Expand a node to see its stacks, then expand a stack to see individual files. Each file has a **Preview** button that renders the full file contents inline in a scrollable panel, and a **Download** button that saves that single file to your machine (named `<stack>-<filename>`, for example `web-compose.yaml`).
|
||||
Below the header, each node appears as a collapsible card. Expand a node to see its stacks, then expand a stack to see individual files. If documentation was captured for a stack, its preserved Dossier notes are shown read-only beneath the file list. Each file has a **Preview** button that renders the full file contents inline in a scrollable panel, and a **Download** button that saves that single file to your machine (named `<stack>-<filename>`, for example `web-compose.yaml`).
|
||||
|
||||
<Frame>
|
||||
<img src="/images/fleet-backups/snapshot-detail.png" alt="Snapshot detail view with a node expanded, showing a stack expanded with a compose file, Preview button, and Restore button" />
|
||||
@@ -65,9 +71,11 @@ Admins can restore individual stacks from any snapshot:
|
||||
1. Open a snapshot's detail view
|
||||
2. Expand the node and stack you want to restore
|
||||
3. Click **Restore** below the stack's file list
|
||||
4. A confirmation dialog appears. Optionally check **Redeploy stack after restore** to immediately apply the restored configuration.
|
||||
4. A confirmation dialog appears. Optionally check **Redeploy stack after restore** to immediately apply the restored configuration. When the snapshot preserved Dossier notes for this stack, a **Restore documentation notes** option also appears.
|
||||
5. Click **Restore** to confirm
|
||||
|
||||
By default, restoring a stack only writes back its files and leaves the stack's current Dossier notes untouched. Notes are restored only when you explicitly check **Restore documentation notes**, which overwrites the current notes with the captured ones. The same option appears on **Restore all** when the snapshot has documentation.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/fleet-backups/restore-dialog.png" alt="Restore confirmation dialog showing the target stack and node name, a redeploy checkbox, and Cancel and Restore buttons" />
|
||||
</Frame>
|
||||
@@ -133,7 +141,7 @@ To upload a single snapshot on demand, open the **Snapshots** tab in Fleet View.
|
||||
|
||||
### Browsing and downloading cloud snapshots
|
||||
|
||||
The **Cloud Snapshots** panel in **Settings → Infrastructure → Cloud Backup** lists every archive currently in your bucket, with size and last-modified timestamp. Click the download icon to save a `.tar.gz` archive locally for off-host disaster recovery. Each archive contains a `metadata.json` describing the snapshot and a `nodes/` tree with the captured compose and environment files, organised by node and stack.
|
||||
The **Cloud Snapshots** panel in **Settings → Infrastructure → Cloud Backup** lists every archive currently in your bucket, with size and last-modified timestamp. Click the download icon to save a `.tar.gz` archive locally for off-host disaster recovery. Each archive contains a `metadata.json` describing the snapshot and a `nodes/` tree with the captured compose and environment files, organised by node and stack. When the snapshot preserved Dossier notes, the archive also includes a `documentation.json` with those notes.
|
||||
|
||||
### Restoring from a cloud snapshot
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Camera, ArrowLeft, Server, Layers, FileText, AlertTriangle, Trash2,
|
||||
Eye, ChevronDown, ChevronLeft, ChevronRight, Plus, Loader2, RotateCcw,
|
||||
Cloud, CloudUpload, Download,
|
||||
Cloud, CloudUpload, Download, BookText,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -31,6 +31,7 @@ interface FleetSnapshot {
|
||||
skipped_nodes: string; // JSON string
|
||||
skipped_stacks: string; // JSON string
|
||||
created_at: number;
|
||||
has_documentation?: number;
|
||||
}
|
||||
|
||||
interface SnapshotStackFile {
|
||||
@@ -49,10 +50,42 @@ interface SnapshotNode {
|
||||
stacks: SnapshotStack[];
|
||||
}
|
||||
|
||||
/** Operator-authored dossier fields preserved with the snapshot. */
|
||||
type SnapshotDossierFields = Record<string, string>;
|
||||
|
||||
interface SnapshotDocumentationStack {
|
||||
nodeId: number;
|
||||
nodeName: string;
|
||||
stackName: string;
|
||||
dossier: SnapshotDossierFields;
|
||||
}
|
||||
|
||||
interface SnapshotDocumentation {
|
||||
generated_at: string;
|
||||
stacks: SnapshotDocumentationStack[];
|
||||
warnings: Array<{ nodeId: number; nodeName: string; stackName: string; reason: string }>;
|
||||
}
|
||||
|
||||
interface FleetSnapshotDetail extends FleetSnapshot {
|
||||
nodes: SnapshotNode[];
|
||||
documentation?: SnapshotDocumentation;
|
||||
}
|
||||
|
||||
// Ordered labels for the read-only dossier block; only non-empty fields render.
|
||||
const DOSSIER_FIELD_LABELS: ReadonlyArray<[string, string]> = [
|
||||
['purpose', 'Purpose'],
|
||||
['owner', 'Owner'],
|
||||
['access_urls', 'Access URLs'],
|
||||
['static_ip', 'Static IP'],
|
||||
['vlan', 'VLAN'],
|
||||
['firewall_notes', 'Firewall'],
|
||||
['reverse_proxy_notes', 'Reverse proxy'],
|
||||
['backup_notes', 'Backup'],
|
||||
['upgrade_notes', 'Upgrade'],
|
||||
['recovery_notes', 'Recovery'],
|
||||
['custom_notes', 'Notes'],
|
||||
];
|
||||
|
||||
interface SkippedNode {
|
||||
nodeId: number;
|
||||
nodeName: string;
|
||||
@@ -248,7 +281,7 @@ export default function FleetSnapshots() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestore = async (nodeId: number, stackName: string, redeploy: boolean) => {
|
||||
const handleRestore = async (nodeId: number, stackName: string, redeploy: boolean, restoreNotes: boolean) => {
|
||||
if (!selectedSnapshot) return;
|
||||
const key = `${nodeId}:${stackName}`;
|
||||
setRestoringStack(key);
|
||||
@@ -256,11 +289,17 @@ export default function FleetSnapshots() {
|
||||
const res = await apiFetch(`/fleet/snapshots/${selectedSnapshot.id}/restore`, {
|
||||
method: 'POST',
|
||||
localOnly: true,
|
||||
body: JSON.stringify({ nodeId, stackName, redeploy }),
|
||||
body: JSON.stringify({ nodeId, stackName, redeploy, restoreNotes }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data: { message: string; redeployed: boolean } = await res.json();
|
||||
toast.success(data.redeployed ? 'Stack restored and redeployed.' : 'Stack restored successfully.');
|
||||
const data: { message: string; redeployed: boolean; notesRestored: boolean; notesError?: string } = await res.json();
|
||||
const base = data.redeployed ? 'Stack restored and redeployed.' : 'Stack restored successfully.';
|
||||
if (data.notesError) {
|
||||
// Files restored; only the optional notes write failed.
|
||||
toast.warning(`${base} Documentation notes could not be restored.`);
|
||||
} else {
|
||||
toast.success(base + (data.notesRestored ? ' Documentation notes restored.' : ''));
|
||||
}
|
||||
} else {
|
||||
const err = await res.json().catch(() => null);
|
||||
toast.error(err?.message || err?.error || err?.data?.error || 'Failed to restore stack.');
|
||||
@@ -290,35 +329,40 @@ export default function FleetSnapshots() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestoreAll = async (redeploy: boolean) => {
|
||||
const handleRestoreAll = async (redeploy: boolean, restoreNotes: boolean) => {
|
||||
if (!selectedSnapshot) return;
|
||||
setRestoringAll(true);
|
||||
try {
|
||||
const res = await apiFetch(`/fleet/snapshots/${selectedSnapshot.id}/restore-all`, {
|
||||
method: 'POST',
|
||||
localOnly: true,
|
||||
body: JSON.stringify({ redeploy }),
|
||||
body: JSON.stringify({ redeploy, restoreNotes }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data: {
|
||||
restored: number;
|
||||
failed: number;
|
||||
redeploy: boolean;
|
||||
results: Array<{ stackName: string; success: boolean; error?: string }>;
|
||||
results: Array<{ stackName: string; success: boolean; error?: string; notesError?: string }>;
|
||||
} = await res.json();
|
||||
const noun = (n: number) => `${n} stack${n === 1 ? '' : 's'}`;
|
||||
const firstFailed = data.results?.find(r => !r.success);
|
||||
const failDetail = firstFailed
|
||||
? ` First failure: ${firstFailed.stackName} · ${firstFailed.error || 'unknown error'}`
|
||||
: '';
|
||||
if (data.failed === 0) {
|
||||
// Files restored but the optional notes write failed on some stacks.
|
||||
const notesFailed = data.results?.filter(r => r.notesError).length ?? 0;
|
||||
const notesSuffix = notesFailed > 0 ? ` Documentation notes could not be restored for ${noun(notesFailed)}.` : '';
|
||||
if (data.failed === 0 && notesFailed === 0) {
|
||||
toast.success(data.redeploy
|
||||
? `Restored and redeployed ${noun(data.restored)}.`
|
||||
: `Restored ${noun(data.restored)}.`);
|
||||
} else if (data.restored === 0) {
|
||||
toast.error(`Restore failed for ${noun(data.failed)}.${failDetail}`);
|
||||
} else if (data.failed === 0) {
|
||||
toast.warning(`Restored ${noun(data.restored)}.${notesSuffix}`);
|
||||
} else {
|
||||
toast.warning(`${data.restored} restored, ${data.failed} failed.${failDetail}`);
|
||||
toast.warning(`${data.restored} restored, ${data.failed} failed.${failDetail}${notesSuffix}`);
|
||||
}
|
||||
} else {
|
||||
const err = await res.json().catch(() => null);
|
||||
@@ -412,7 +456,11 @@ export default function FleetSnapshots() {
|
||||
</p>
|
||||
</div>
|
||||
{isAdmin && selectedSnapshot.nodes.length > 0 && (
|
||||
<RestoreAllButton restoring={restoringAll} onRestoreAll={handleRestoreAll} />
|
||||
<RestoreAllButton
|
||||
restoring={restoringAll}
|
||||
hasDocumentation={(selectedSnapshot.documentation?.stacks.length ?? 0) > 0}
|
||||
onRestoreAll={handleRestoreAll}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -422,6 +470,12 @@ export default function FleetSnapshots() {
|
||||
<Badge variant="secondary" className="font-mono tabular-nums">
|
||||
{selectedSnapshot.stack_count} stack{selectedSnapshot.stack_count !== 1 ? 's' : ''}
|
||||
</Badge>
|
||||
{(selectedSnapshot.documentation?.stacks.length ?? 0) > 0 && (
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<BookText className="w-3 h-3" strokeWidth={1.5} />
|
||||
Documentation captured
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -477,6 +531,33 @@ export default function FleetSnapshots() {
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Documentation capture warnings (notes that could not be fetched) */}
|
||||
{(() => {
|
||||
const warnings = selectedSnapshot.documentation?.warnings ?? [];
|
||||
if (warnings.length === 0) return null;
|
||||
return (
|
||||
<div className="rounded-xl border border-warning/30 bg-warning/5 p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<AlertTriangle className="w-4 h-4 text-warning shrink-0" />
|
||||
<span className="text-sm font-medium text-warning">
|
||||
Some stack documentation could not be captured:
|
||||
</span>
|
||||
</div>
|
||||
<ul className="ml-6 space-y-1">
|
||||
{warnings.map((w, i) => (
|
||||
<li key={`${w.nodeId}:${w.stackName}:${i}`} className="text-sm text-muted-foreground">
|
||||
<span className="font-medium">{w.nodeName}</span>
|
||||
{' / '}
|
||||
<span className="font-mono">{w.stackName}</span>
|
||||
{' - '}
|
||||
{w.reason}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Node / Stack / File tree */}
|
||||
<div className="space-y-2">
|
||||
{selectedSnapshot.nodes.map(node => {
|
||||
@@ -505,6 +586,8 @@ export default function FleetSnapshots() {
|
||||
{node.stacks.map(stack => {
|
||||
const stackKey = `${node.nodeId}:${stack.stackName}`;
|
||||
const stackExpanded = expandedStacks.has(stackKey);
|
||||
const dossier = selectedSnapshot.documentation?.stacks
|
||||
.find(s => s.nodeId === node.nodeId && s.stackName === stack.stackName)?.dossier;
|
||||
return (
|
||||
<div key={stackKey}>
|
||||
<button
|
||||
@@ -565,12 +648,16 @@ export default function FleetSnapshots() {
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Preserved dossier notes (read-only) */}
|
||||
{dossier && <DossierBlock dossier={dossier} />}
|
||||
|
||||
{/* Restore button (admin only) */}
|
||||
{isAdmin && (
|
||||
<RestoreButton
|
||||
nodeId={node.nodeId}
|
||||
nodeName={node.nodeName}
|
||||
stackName={stack.stackName}
|
||||
hasDossier={!!dossier}
|
||||
restoring={restoringStack === `${node.nodeId}:${stack.stackName}`}
|
||||
onRestore={handleRestore}
|
||||
/>
|
||||
@@ -817,14 +904,16 @@ export default function FleetSnapshots() {
|
||||
|
||||
// --- Restore Button Sub-Component ---
|
||||
|
||||
function RestoreButton({ nodeId, nodeName, stackName, restoring, onRestore }: {
|
||||
function RestoreButton({ nodeId, nodeName, stackName, hasDossier, restoring, onRestore }: {
|
||||
nodeId: number;
|
||||
nodeName: string;
|
||||
stackName: string;
|
||||
hasDossier: boolean;
|
||||
restoring: boolean;
|
||||
onRestore: (nodeId: number, stackName: string, redeploy: boolean) => Promise<void>;
|
||||
onRestore: (nodeId: number, stackName: string, redeploy: boolean, restoreNotes: boolean) => Promise<void>;
|
||||
}) {
|
||||
const [redeploy, setRedeploy] = useState(false);
|
||||
const [restoreNotes, setRestoreNotes] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
@@ -852,7 +941,7 @@ function RestoreButton({ nodeId, nodeName, stackName, restoring, onRestore }: {
|
||||
confirming={restoring}
|
||||
onConfirm={async () => {
|
||||
try {
|
||||
await onRestore(nodeId, stackName, redeploy);
|
||||
await onRestore(nodeId, stackName, redeploy, restoreNotes);
|
||||
} finally {
|
||||
setOpen(false);
|
||||
}
|
||||
@@ -874,18 +963,58 @@ function RestoreButton({ nodeId, nodeName, stackName, restoring, onRestore }: {
|
||||
Redeploy stack after restore
|
||||
</Label>
|
||||
</div>
|
||||
{hasDossier && (
|
||||
<div className="flex items-center space-x-2 pt-1">
|
||||
<Checkbox
|
||||
id={`notes-${nodeId}-${stackName}`}
|
||||
checked={restoreNotes}
|
||||
onCheckedChange={(checked) => setRestoreNotes(checked === true)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`notes-${nodeId}-${stackName}`}
|
||||
className="text-sm cursor-pointer"
|
||||
>
|
||||
Restore documentation notes (overwrites current notes)
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
</ConfirmModal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Preserved Dossier Sub-Component ---
|
||||
|
||||
function DossierBlock({ dossier }: { dossier: SnapshotDossierFields }) {
|
||||
const entries = DOSSIER_FIELD_LABELS.filter(([key]) => (dossier[key] ?? '').trim() !== '');
|
||||
if (entries.length === 0) return null;
|
||||
return (
|
||||
<div className="ml-3 mt-1 rounded-lg border border-card-border bg-muted/30 px-3 py-2">
|
||||
<div className="flex items-center gap-1.5 mb-1.5">
|
||||
<BookText className="w-3 h-3 text-muted-foreground shrink-0" strokeWidth={1.5} />
|
||||
<span className="text-[10px] font-mono uppercase tracking-wide text-muted-foreground">Dossier notes</span>
|
||||
</div>
|
||||
<dl className="space-y-1">
|
||||
{entries.map(([key, label]) => (
|
||||
<div key={key} className="grid grid-cols-[7rem_1fr] gap-2">
|
||||
<dt className="text-xs text-muted-foreground">{label}</dt>
|
||||
<dd className="text-xs whitespace-pre-wrap break-words">{dossier[key]}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Restore All Button Sub-Component ---
|
||||
|
||||
function RestoreAllButton({ restoring, onRestoreAll }: {
|
||||
function RestoreAllButton({ restoring, hasDocumentation, onRestoreAll }: {
|
||||
restoring: boolean;
|
||||
onRestoreAll: (redeploy: boolean) => Promise<void>;
|
||||
hasDocumentation: boolean;
|
||||
onRestoreAll: (redeploy: boolean, restoreNotes: boolean) => Promise<void>;
|
||||
}) {
|
||||
const [redeploy, setRedeploy] = useState(false);
|
||||
const [restoreNotes, setRestoreNotes] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
@@ -914,7 +1043,7 @@ function RestoreAllButton({ restoring, onRestoreAll }: {
|
||||
confirming={restoring}
|
||||
onConfirm={async () => {
|
||||
try {
|
||||
await onRestoreAll(redeploy);
|
||||
await onRestoreAll(redeploy, restoreNotes);
|
||||
} finally {
|
||||
setOpen(false);
|
||||
}
|
||||
@@ -933,6 +1062,18 @@ function RestoreAllButton({ restoring, onRestoreAll }: {
|
||||
Redeploy all stacks after restore
|
||||
</Label>
|
||||
</div>
|
||||
{hasDocumentation && (
|
||||
<div className="flex items-center space-x-2 pt-1">
|
||||
<Checkbox
|
||||
id="notes-all"
|
||||
checked={restoreNotes}
|
||||
onCheckedChange={(checked) => setRestoreNotes(checked === true)}
|
||||
/>
|
||||
<Label htmlFor="notes-all" className="text-sm cursor-pointer">
|
||||
Restore documentation notes (overwrites current notes)
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
</ConfirmModal>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -25,10 +25,11 @@ function SectionSkeleton() {
|
||||
);
|
||||
}
|
||||
|
||||
type FleetMeshFields = Pick<PatchableSettings, 'mesh_auto_recreate'>;
|
||||
type FleetMeshFields = Pick<PatchableSettings, 'mesh_auto_recreate' | 'snapshot_documentation'>;
|
||||
|
||||
const DEFAULT_FLEET_MESH: FleetMeshFields = {
|
||||
mesh_auto_recreate: DEFAULT_SETTINGS.mesh_auto_recreate,
|
||||
snapshot_documentation: DEFAULT_SETTINGS.snapshot_documentation,
|
||||
};
|
||||
|
||||
export function FleetMeshSection({ onDirtyChange }: FleetMeshSectionProps) {
|
||||
@@ -44,6 +45,7 @@ export function FleetMeshSection({ onDirtyChange }: FleetMeshSectionProps) {
|
||||
const baseline = serverSettingsRef.current;
|
||||
let n = 0;
|
||||
if (settings.mesh_auto_recreate !== baseline.mesh_auto_recreate) n++;
|
||||
if (settings.snapshot_documentation !== baseline.snapshot_documentation) n++;
|
||||
return n;
|
||||
}, [settings]);
|
||||
|
||||
@@ -73,6 +75,7 @@ export function FleetMeshSection({ onDirtyChange }: FleetMeshSectionProps) {
|
||||
const nodeData: Record<string, string> = nodeRes.ok ? await nodeRes.json() : {};
|
||||
const safe: FleetMeshFields = {
|
||||
mesh_auto_recreate: (nodeData.mesh_auto_recreate as '0' | '1') ?? DEFAULT_SETTINGS.mesh_auto_recreate,
|
||||
snapshot_documentation: (nodeData.snapshot_documentation as '0' | '1') ?? DEFAULT_SETTINGS.snapshot_documentation,
|
||||
};
|
||||
setSettings(safe);
|
||||
serverSettingsRef.current = { ...safe };
|
||||
@@ -103,7 +106,7 @@ export function FleetMeshSection({ onDirtyChange }: FleetMeshSectionProps) {
|
||||
return;
|
||||
}
|
||||
serverSettingsRef.current = { ...settings };
|
||||
toast.success('Mesh settings saved.');
|
||||
toast.success('Fleet settings saved.');
|
||||
} catch (e: unknown) {
|
||||
toast.error((e as Error)?.message || 'Something went wrong.');
|
||||
} finally {
|
||||
@@ -127,6 +130,18 @@ export function FleetMeshSection({ onDirtyChange }: FleetMeshSectionProps) {
|
||||
</SettingsField>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Documentation snapshots">
|
||||
<SettingsField
|
||||
label="Capture stack documentation in snapshots"
|
||||
helper="Preserve each stack's Dossier notes alongside its captured files. Restoring a stack never overwrites current notes unless you explicitly choose to. Off by default."
|
||||
>
|
||||
<TogglePill
|
||||
checked={settings.snapshot_documentation === '1'}
|
||||
onChange={(next) => onSettingChange('snapshot_documentation', next ? '1' : '0')}
|
||||
/>
|
||||
</SettingsField>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsActions hint={readOnly ? 'Read-only · admin access required to edit' : (hasChanges ? `${dirtyCount} unsaved` : undefined)}>
|
||||
{!readOnly && (
|
||||
<SettingsPrimaryButton onClick={saveSettings} disabled={isSaving || !hasChanges}>
|
||||
|
||||
@@ -84,13 +84,13 @@ describe('split section save payloads', () => {
|
||||
expect(patchedKeys()).toEqual(['docker_janitor_gb', 'prune_on_update', 'reclaim_hero']);
|
||||
});
|
||||
|
||||
it('FleetMeshSection patches only the mesh key', async () => {
|
||||
it('FleetMeshSection patches only the fleet keys', async () => {
|
||||
render(<FleetMeshSection />);
|
||||
const save = await screen.findByRole('button', { name: /save settings/i });
|
||||
fireEvent.click(screen.getByRole('switch')); // mesh_auto_recreate
|
||||
fireEvent.click(screen.getAllByRole('switch')[0]); // mesh_auto_recreate
|
||||
fireEvent.click(save);
|
||||
await waitFor(() => expect(mockedFetch.mock.calls.some(c => c[1]?.method === 'PATCH')).toBe(true));
|
||||
expect(patchedKeys()).toEqual(['mesh_auto_recreate']);
|
||||
expect(patchedKeys()).toEqual(['mesh_auto_recreate', 'snapshot_documentation']);
|
||||
});
|
||||
|
||||
it('DataRetentionSection patches only retention keys, never developer_mode', async () => {
|
||||
|
||||
@@ -126,9 +126,9 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
|
||||
{
|
||||
id: 'fleet-mesh',
|
||||
group: 'infrastructure',
|
||||
label: 'Fleet Mesh',
|
||||
description: 'Data-plane network behavior for the cross-node service mesh.',
|
||||
keywords: ['mesh', 'network', 'recreate', 'fleet', 'routing', 'data plane', 'sencho_mesh'],
|
||||
label: 'Fleet',
|
||||
description: 'Cross-node service-mesh data plane and fleet-snapshot documentation capture.',
|
||||
keywords: ['mesh', 'network', 'recreate', 'fleet', 'routing', 'data plane', 'sencho_mesh', 'snapshot', 'documentation', 'dossier'],
|
||||
tier: null,
|
||||
scope: 'node',
|
||||
adminOnly: true,
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface PatchableSettings {
|
||||
scan_history_per_image_limit?: string;
|
||||
prune_on_update?: '0' | '1';
|
||||
reclaim_hero?: '0' | '1';
|
||||
snapshot_documentation?: '0' | '1';
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: PatchableSettings = {
|
||||
@@ -32,6 +33,7 @@ export const DEFAULT_SETTINGS: PatchableSettings = {
|
||||
scan_history_per_image_limit: '50',
|
||||
prune_on_update: '1',
|
||||
reclaim_hero: '1',
|
||||
snapshot_documentation: '0',
|
||||
};
|
||||
|
||||
export type SectionId =
|
||||
|
||||
Reference in New Issue
Block a user