mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-21 07:36:40 +00:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user