mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-29 11:47:01 +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();
|
||||
|
||||
Reference in New Issue
Block a user