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