feat: add service-scoped Compose update and restore (#1648)

* feat: add service-scoped Compose update and restore

Allow updating or rebuilding one declared Compose service on multi-service
stacks without recreating siblings, with recovery snapshots, health-gate
observation, and prune holds for rollback images. Full-stack update paths
and single-service UX stay unchanged.

* fix: sanitize service-scoped update log messages for CodeQL

* fix: address service-scoped update audit findings B-01 through B-07

* fix: complete service-scoped update audit metadata and surfaces

* test: wrap Updates readiness tests for deploy-feedback context

* fix: keep service recovery reachable without Deploy Progress

Make failed service-gate recovery discoverable when Deploy Progress is
disabled or dismissed, suppress stale image-scan notification side
effects, normalize ComposeService line endings, and add focused
regression coverage.

* fix: resurface ContainersHealth density and expand on multi-service stacks

Service grouping hid the summary strip and Compact/Detailed/Expand controls that still applied to multi-container stacks.
This commit is contained in:
Anso
2026-07-19 02:42:29 -04:00
committed by GitHub
parent 31d4e4669b
commit 63213c0960
89 changed files with 7608 additions and 331 deletions
@@ -78,3 +78,86 @@ describe('stack_update_status tri-state accessors', () => {
expect(db().getStackUpdateDetail(2).web.checkStatus).toBe('failed');
});
});
describe('stack_update_status services_json (per-service reduction)', () => {
const SERVICES = [
{ service: 'web', image: 'web:latest', hasUpdate: true, checkStatus: 'ok' as const, lastError: null },
{ service: 'worker', image: 'worker:latest', hasUpdate: false, checkStatus: 'ok' as const, lastError: null },
];
it('persists and returns a per-service breakdown via getStackUpdateDetail', () => {
db().upsertStackUpdateStatus(NODE, 'stackA', true, 1000, 'ok', null, SERVICES, 3);
const detail = db().getStackUpdateDetail(NODE).stackA;
expect(detail.services).toEqual(SERVICES);
});
it('omits the services field entirely for a stack with no persisted per-service data', () => {
db().upsertStackUpdateStatus(NODE, 'stackA', true, 1000, 'ok', null);
const detail = db().getStackUpdateDetail(NODE).stackA;
expect(detail.services).toBeUndefined();
expect('services' in detail).toBe(false);
});
it('leaves a prior services_json untouched when a later write omits services (COALESCE)', () => {
db().upsertStackUpdateStatus(NODE, 'stackA', true, 1000, 'ok', null, SERVICES, 1);
// A legacy-path caller (or the fallback tally) writes without a services array.
db().upsertStackUpdateStatus(NODE, 'stackA', true, 2000, 'ok', null);
expect(db().getStackUpdateDetail(NODE).stackA.services).toEqual(SERVICES);
});
it('getStackServicesJson returns [] for a stack with no persisted row', () => {
expect(db().getStackServicesJson(NODE, 'missing')).toEqual([]);
});
it('getStackServicesJson returns [] for a stack row with no services_json', () => {
db().upsertStackUpdateStatus(NODE, 'stackA', true, 1000, 'ok', null);
expect(db().getStackServicesJson(NODE, 'stackA')).toEqual([]);
});
it('getStackServicesJson round-trips a persisted per-service breakdown', () => {
db().upsertStackUpdateStatus(NODE, 'stackA', true, 1000, 'ok', null, SERVICES, 5);
expect(db().getStackServicesJson(NODE, 'stackA')).toEqual(SERVICES);
});
it('recordStackCheckFailure persists a per-service breakdown alongside the failure', () => {
db().recordStackCheckFailure(NODE, 'stackA', 'Registry unreachable', 4000, SERVICES, 2);
const detail = db().getStackUpdateDetail(NODE).stackA;
expect(detail.checkStatus).toBe('failed');
expect(detail.services).toEqual(SERVICES);
});
it('treats corrupt services_json as an empty list rather than throwing (empty model invariant)', () => {
db().upsertStackUpdateStatus(NODE, 'stackA', true, 1000, 'ok', null);
const raw = (db() as unknown as { db: { prepare: (s: string) => { run: (...args: unknown[]) => void } } }).db;
raw.prepare('UPDATE stack_update_status SET services_json = ? WHERE node_id = ? AND stack_name = ?')
.run('{not valid json', NODE, 'stackA');
expect(db().getStackServicesJson(NODE, 'stackA')).toEqual([]);
const detail = db().getStackUpdateDetail(NODE).stackA;
expect(detail.services).toBeUndefined();
expect(detail.hasUpdate).toBe(true); // aggregate columns are unaffected by corrupt services_json
});
it('treats a services_json shape-version mismatch as an empty list', () => {
db().upsertStackUpdateStatus(NODE, 'stackA', true, 1000, 'ok', null);
const raw = (db() as unknown as { db: { prepare: (s: string) => { run: (...args: unknown[]) => void } } }).db;
raw.prepare('UPDATE stack_update_status SET services_json = ? WHERE node_id = ? AND stack_name = ?')
.run(JSON.stringify({ version: 999, generation: 1, services: SERVICES }), NODE, 'stackA');
expect(db().getStackServicesJson(NODE, 'stackA')).toEqual([]);
});
it('filters out malformed entries within an otherwise valid services_json array', () => {
db().upsertStackUpdateStatus(NODE, 'stackA', true, 1000, 'ok', null);
const raw = (db() as unknown as { db: { prepare: (s: string) => { run: (...args: unknown[]) => void } } }).db;
const malformed = [...SERVICES, { service: 'broken' /* missing required fields */ }];
raw.prepare('UPDATE stack_update_status SET services_json = ? WHERE node_id = ? AND stack_name = ?')
.run(JSON.stringify({ version: 1, generation: 1, services: malformed }), NODE, 'stackA');
expect(db().getStackServicesJson(NODE, 'stackA')).toEqual(SERVICES);
});
});