feat(snapshots): preserve stack dossiers with fleet snapshots (#1339)

* feat(snapshots): preserve stack dossiers with fleet snapshots

Fleet snapshots can now optionally capture each stack's Dossier notes
alongside its compose and .env files, so a recovery restores the
operational knowledge around a stack, not just its configuration.

- Opt-in global setting "snapshot_documentation" (default off), toggled
  from the renamed Fleet settings section.
- Capture reads local dossiers from the database and remote dossiers over
  the Distributed API proxy; only stacks with notes are recorded, and
  secret values are never included.
- Captured notes are stored encrypted at rest in a new fleet_snapshots
  column and surfaced in the snapshot detail view behind a badge.
- Cloud and downloaded archives gain a documentation.json (archive_version 2).
- Restore stays conservative: dossier notes are written back only when the
  operator explicitly opts in, on both single-stack and restore-all paths.
- Existing snapshots and archives remain valid; behavior is unchanged when
  the setting is off.

* fix(snapshots): harden dossier-notes restore against bad input and partial failures

Address review findings on the documentation-snapshots restore path:

- Parse `restoreNotes` strictly (=== true) on single-stack restore, matching
  restore-all, so a stray non-boolean can never opt in to overwriting notes.
- Guard findSnapshotDossier: require an array of stacks and real dossier
  content, so a malformed or all-blank entry can't clobber current notes.
- Make the dossier-notes write non-fatal relative to the file restore: a notes
  failure (e.g. a remote dossier PUT) is caught, reported via `notesError`, and
  no longer 500s the single restore or fails the stack in restore-all once the
  files are already written.
- Surface the partial outcome in the UI: a warning toast on single restore, a
  summary note on restore-all, and gate the "Documentation captured" badge and
  restore-all notes control on captured stacks while rendering capture warnings.

Adds tests for strict parsing, malformed/blank blobs, remote notes restore
(success + non-fatal failure, single and bulk), and scheduled capture-on.

* fix(snapshots): drop unused binding in restore-all remote notes test

The restore-all remote notes test destructured a node id it never uses
(restore-all is driven by snapshot id alone), tripping no-unused-vars and
failing the lint step. Bind only the snapshot id.
This commit is contained in:
Anso
2026-06-08 08:44:59 -04:00
committed by GitHub
parent 842ee7dd0c
commit 710647a44f
16 changed files with 1020 additions and 55 deletions
@@ -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 () => {
+3 -3
View File
@@ -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 =