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
@@ -0,0 +1,122 @@
/**
* Unit tests for StackUpdateOrchestrator dispatch: the stack branch runs
* ComposeService.updateStack (side effects stay with callers), the service
* branch hard-rejects any non-manual trigger, and a single-service stack is
* refused with the stable `service_update_single_service` code before any
* mutation.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { EffectiveServiceModelResult, EffectiveServiceSpec } from '../services/effectiveServiceModel';
const { state } = vi.hoisted(() => ({
state: {
updateStack: vi.fn(),
model: null as EffectiveServiceModelResult | null,
},
}));
vi.mock('../services/ComposeService', () => ({
ComposeService: {
getInstance: () => ({ updateStack: state.updateStack }),
},
}));
vi.mock('../services/effectiveServiceModel', () => ({
buildEffectiveServiceModel: vi.fn(async () => state.model),
}));
import { StackUpdateOrchestrator, evaluateServiceReplicaConvergence, shortImageId } from '../services/StackUpdateOrchestrator';
const orch = () => StackUpdateOrchestrator.getInstance();
function spec(name: string): EffectiveServiceSpec {
return { name, declaredImage: 'nginx:latest', hasBuild: false, expectedReplicas: 1, dependsOn: [], hasHealthcheck: false };
}
beforeEach(() => {
state.updateStack.mockReset();
state.updateStack.mockResolvedValue(undefined);
state.model = null;
});
describe('evaluateServiceReplicaConvergence', () => {
it('converges when the exact expected running replicas share one image', () => {
expect(evaluateServiceReplicaConvergence('api', 3, ['sha:a', 'sha:a', 'sha:a'], 0)).toEqual({
kind: 'converged', imageId: 'sha:a',
});
});
it('reports divergent when running replica count is below expected', () => {
const result = evaluateServiceReplicaConvergence('api', 3, ['sha:a'], 0);
expect(result).toMatchObject({ kind: 'divergent' });
expect((result as { error: string }).error).toMatch(/1 running.*expected 3/i);
});
it('reports divergent when no running replicas remain', () => {
const result = evaluateServiceReplicaConvergence('api', 2, [], 0);
expect(result).toMatchObject({ kind: 'divergent' });
expect((result as { error: string }).error).toMatch(/no running replicas/i);
});
it('reports divergent when replicas disagree on image', () => {
const result = evaluateServiceReplicaConvergence('api', 2, ['sha:a', 'sha:b'], 0);
expect(result).toMatchObject({ kind: 'divergent' });
expect((result as { error: string }).error).toMatch(/did not converge/i);
});
it('reports inspect_failed when every inspect failed', () => {
expect(evaluateServiceReplicaConvergence('api', 2, [], 2)).toMatchObject({ kind: 'inspect_failed' });
});
it('treats scale-zero as converged with a null image', () => {
expect(evaluateServiceReplicaConvergence('api', 0, [], 0)).toEqual({ kind: 'converged', imageId: null });
});
});
describe('shortImageId', () => {
it('strips the sha256 prefix and truncates', () => {
expect(shortImageId('sha256:abcdef0123456789')).toBe('abcdef012345');
});
});
describe('StackUpdateOrchestrator stack branch', () => {
it('runs ComposeService.updateStack and returns stack_compose_done', async () => {
const result = await orch().execute(
{ nodeId: 0, stackName: 'web', target: { scope: 'stack' }, trigger: 'manual', actor: 'tester' },
{ atomic: true, terminalWs: null },
);
expect(result).toEqual({ kind: 'stack_compose_done' });
expect(state.updateStack).toHaveBeenCalledWith('web', undefined, true);
});
});
describe('StackUpdateOrchestrator service branch guards', () => {
it('rejects a service-scoped update with a non-manual trigger', async () => {
await expect(
orch().execute(
{ nodeId: 0, stackName: 'web', target: { scope: 'service', serviceName: 'app' }, trigger: 'scheduled', actor: 'system' },
{ policyOptions: { bypass: false, actor: 'system' } },
),
).rejects.toThrow(/manual/i);
expect(state.updateStack).not.toHaveBeenCalled();
});
it('refuses a single-service stack with code service_update_single_service', async () => {
state.model = { renderable: true, services: [spec('only')] };
const result = await orch().execute(
{ nodeId: 0, stackName: 'web', target: { scope: 'service', serviceName: 'only' }, trigger: 'manual', actor: 'tester' },
{ policyOptions: { bypass: false, actor: 'tester' } },
);
expect(result).toMatchObject({ kind: 'service_failed', code: 'service_update_single_service' });
expect(state.updateStack).not.toHaveBeenCalled();
});
it('fails closed when the effective model cannot render', async () => {
state.model = { renderable: false, code: 'effective_model_render_failed', error: 'boom' };
const result = await orch().execute(
{ nodeId: 0, stackName: 'web', target: { scope: 'service', serviceName: 'app' }, trigger: 'manual', actor: 'tester' },
{ policyOptions: { bypass: false, actor: 'tester' } },
);
expect(result).toMatchObject({ kind: 'service_failed', code: 'effective_model_render_failed' });
});
});