Files
sencho/backend/src/__tests__/stack-update-orchestrator.test.ts
T
Anso 3f1f15a6f4 fix: keep running containers until stack pull/build succeeds (#1657)
* fix: keep running containers until stack pull/build succeeds

Acquire images before reconcile, capture a recovery generation for
compensation, and only remove classified orphans after handoff.

* fix: address recovery audit blockers for safe stack updates

Retire abandoned and expired recovery artifacts, probe compensated
runtimes before reporting rollback success, preserve local Docker when
deleting a node, validate the exact Compose invocation before capture,
and repair updateStack return-contract fixtures.

* fix: resolve ESLint errors blocking CI on this branch

Unused-import and unused-variable errors left over from the stack
deletion refactor: MeshService in stacks.ts (its opt-out cascade moved
into DeployedStackDeletionService), a redundant pruneVolumes
destructure in deleteDeployedStack (the real one is re-derived from
the same input object inside runDeletionBody), and an unused beforeAll
import in a Docker-integration test stub. Also scopes the webhook
pull-action case body in a block to satisfy no-case-declarations;
purely syntactic, no behavior change.

* fix: harden recovery probe, cleanup retry, and failed-pull Docker test

Reject absent or unhealthy expected replicas before reporting rollback
success, keep cleanup records until artifacts are actually removed, fail
closed when a mesh override cannot be generated, and assert a real
failed pull leaves the original container running.

* fix: verify recovery probe image identity and stack-scoped override paths

Reject recovered runtimes that use the wrong image or leave scale-zero
services running, and confine tombstone override deletion to the intent
stack directory so forged cross-stack paths cannot be swept.

* test: batch notification cap fixtures in a SQLite transaction

Unbatched 1200-row inserts were timing out at the default 30s under
CI load even though the same assertions pass in under 2s when green.
2026-07-21 12:18:01 -04:00

124 lines
5.1 KiB
TypeScript

/**
* 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({ recoveryId: null });
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', recoveryId: null });
expect(state.updateStack).toHaveBeenCalledWith('web', undefined, true);
// recoveryId is forwarded from ComposeService.updateStack
});
});
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' });
});
});