fix: recognize clean one-shot completions in health gate and drift (#1691)

* fix: recognize clean one-shot completions in health gate and drift

Treat exit 0 with restart policy no/absent as successful completion so
init and migration jobs no longer fail post-update observation or show as
service-missing, while long-running restart policies still fail closed.

* fix: ignore residual health on clean one-shots and honor deploy.restart_policy

Completed exit-0 jobs with no-restart intent no longer fail the health gate on leftover starting/unhealthy state, and Drift treats deploy.restart_policy with Compose precedence so any/on-failure services are not mistaken for one-shots.

* fix: require explicit Compose restart no for one-shot recognition

Docker inspect reports restart no for both intentional jobs and bare services that omit restart, so Health Gate and Drift now require declared restart:""no"" (or deploy.restart_policy condition none) and load Compose intent once per gate.
This commit is contained in:
Anso
2026-07-24 09:41:21 -04:00
committed by GitHub
parent 524cc56d2f
commit 79914fe750
22 changed files with 1015 additions and 69 deletions
+218 -1
View File
@@ -36,7 +36,7 @@ function declared(services: DeclaredService[], parseError?: string): DeclaredCom
function container(p: Partial<DependencyContainer> & { id: string }): DependencyContainer {
return {
name: p.id, service: null, composeProject: null, stack: 'app',
state: 'running', image: 'img:latest', networks: [], volumes: [], ports: [], ...p,
state: 'running', exitCode: null, image: 'img:latest', networks: [], volumes: [], ports: [], ...p,
};
}
@@ -108,6 +108,147 @@ describe('assembleStackDrift - status', () => {
expect(report.findings).toEqual([]);
});
it('does not emit service-missing for a clean one-shot beside a running service', () => {
const report = assembleStackDrift({
stack: 'app',
declared: declared([
service({ name: 'app', image: 'app:1', restart: 'unless-stopped' }),
service({ name: 'migrate', image: 'migrate:1', restart: 'no' }),
]),
containers: [
container({ id: 'c1', service: 'app', image: 'app:1', state: 'running' }),
container({ id: 'c2', service: 'migrate', image: 'migrate:1', state: 'exited', exitCode: 0 }),
],
});
expect(findingKinds(report)).not.toContain('service-missing');
expect(report.status).toBe('in-sync');
expect(report.hasContainers).toBe(true);
});
it('still emits service-missing when a one-shot exits non-zero', () => {
const report = assembleStackDrift({
stack: 'app',
declared: declared([
service({ name: 'app', image: 'app:1', restart: 'unless-stopped' }),
service({ name: 'migrate', image: 'migrate:1', restart: 'no' }),
]),
containers: [
container({ id: 'c1', service: 'app', image: 'app:1', state: 'running' }),
container({ id: 'c2', service: 'migrate', image: 'migrate:1', state: 'exited', exitCode: 1 }),
],
});
expect(findingKinds(report)).toContain('service-missing');
expect(report.findings.find(f => f.kind === 'service-missing')?.service).toBe('migrate');
});
it('still treats exited unless-stopped as missing even with exit 0', () => {
const report = assembleStackDrift({
stack: 'app',
declared: declared([service({ name: 'web', restart: 'unless-stopped' })]),
containers: [container({ id: 'c1', service: 'web', state: 'exited', exitCode: 0 })],
});
expect(report.status).toBe('missing-runtime');
expect(report.hasContainers).toBe(false);
});
it('fails closed when exitCode is null even with restart no', () => {
const report = assembleStackDrift({
stack: 'app',
declared: declared([
service({ name: 'app', image: 'app:1', restart: 'unless-stopped' }),
service({ name: 'migrate', image: 'migrate:1', restart: 'no' }),
]),
containers: [
container({ id: 'c1', service: 'app', image: 'app:1', state: 'running' }),
container({ id: 'c2', service: 'migrate', image: 'migrate:1', state: 'exited', exitCode: null }),
],
});
expect(findingKinds(report)).toContain('service-missing');
expect(report.findings.find(f => f.kind === 'service-missing')?.service).toBe('migrate');
});
it('does not treat absent declared restart as a clean one-shot', () => {
const report = assembleStackDrift({
stack: 'app',
declared: declared([
service({ name: 'app', image: 'app:1', restart: 'unless-stopped' }),
service({ name: 'daemon-default', image: 'daemon:1' }),
]),
containers: [
container({ id: 'c1', service: 'app', image: 'app:1' }),
container({ id: 'c2', service: 'daemon-default', image: 'daemon:1', state: 'exited', exitCode: 0 }),
],
});
expect(findingKinds(report)).toContain('service-missing');
expect(report.findings.find((f) => f.kind === 'service-missing')?.service).toBe('daemon-default');
});
it('all-one-shot stack with dedicated network is not missing-runtime but keeps network-missing and hasContainers false', () => {
const report = assembleStackDrift({
stack: 'app',
declared: {
services: [service({ name: 'migrate', restart: 'no', networks: ['jobs'] })],
networks: { jobs: { external: false } },
volumes: {},
projectName: 'app',
},
containers: [
container({
id: 'c1', service: 'migrate', state: 'exited', exitCode: 0,
networks: [{ name: 'app_jobs', id: 'j', ip: '' }],
}),
],
networks: [depNet('app_jobs')],
});
expect(report.status).not.toBe('missing-runtime');
expect(findingKinds(report)).not.toContain('service-missing');
expect(report.hasContainers).toBe(false);
expect(findingKinds(report)).toContain('network-missing');
expect(report.status).toBe('drifted');
});
it('all-one-shot stack without network findings is in-sync with hasContainers false', () => {
const report = assembleStackDrift({
stack: 'app',
declared: declared([service({ name: 'migrate', restart: 'no' })]),
containers: [container({ id: 'c1', service: 'migrate', state: 'exited', exitCode: 0 })],
});
expect(report.status).toBe('in-sync');
expect(report.hasContainers).toBe(false);
expect(report.findings).toEqual([]);
});
it('emits service-missing when normalized restart is always (deploy any)', () => {
const report = assembleStackDrift({
stack: 'app',
declared: declared([
service({ name: 'app', image: 'app:1', restart: 'unless-stopped' }),
service({ name: 'worker', image: 'worker:1', restart: 'always' }),
]),
containers: [
container({ id: 'c1', service: 'app', image: 'app:1' }),
container({ id: 'c2', service: 'worker', image: 'worker:1', state: 'exited', exitCode: 0 }),
],
});
expect(findingKinds(report)).toContain('service-missing');
expect(report.findings.find((f) => f.kind === 'service-missing')?.service).toBe('worker');
});
it('emits service-missing when normalized restart is on-failure', () => {
const report = assembleStackDrift({
stack: 'app',
declared: declared([
service({ name: 'app', image: 'app:1', restart: 'unless-stopped' }),
service({ name: 'worker', image: 'worker:1', restart: 'on-failure' }),
]),
containers: [
container({ id: 'c1', service: 'app', image: 'app:1' }),
container({ id: 'c2', service: 'worker', image: 'worker:1', state: 'exited', exitCode: 0 }),
],
});
expect(findingKinds(report)).toContain('service-missing');
});
it('counts a restarting container as deployed', () => {
const report = assembleStackDrift({
stack: 'app',
@@ -456,6 +597,82 @@ describe('declaredFromEffectiveModel', () => {
expect(converted.services[0].ports).toEqual([{ hostIp: '127.0.0.1', publishedPort: 8080, protocol: 'tcp' }]);
});
it('preserves restart policy including no, unless-stopped, and absent', () => {
const withNo = declaredFromEffectiveModel({
projectName: 'app',
services: [effSvc({ name: 'migrate', restart: 'no' })],
networks: {},
volumes: {},
});
expect(withNo.services[0].restart).toBe('no');
const withUnless = declaredFromEffectiveModel({
projectName: 'app',
services: [effSvc({ name: 'web', restart: 'unless-stopped' })],
networks: {},
volumes: {},
});
expect(withUnless.services[0].restart).toBe('unless-stopped');
const absent = declaredFromEffectiveModel({
projectName: 'app',
services: [effSvc({ name: 'job', restart: undefined })],
networks: {},
volumes: {},
});
expect(absent.services[0].restart).toBeNull();
});
it('normalizes deploy.restart_policy conditions with Compose precedence', () => {
const none = declaredFromEffectiveModel({
projectName: 'app',
services: [effSvc({
name: 'migrate',
restart: 'unless-stopped',
deploy: { restart_policy: { condition: 'none' } },
})],
networks: {},
volumes: {},
});
expect(none.services[0].restart).toBe('no');
const any = declaredFromEffectiveModel({
projectName: 'app',
services: [effSvc({
name: 'worker',
restart: 'no',
deploy: { restart_policy: { condition: 'any' } },
})],
networks: {},
volumes: {},
});
expect(any.services[0].restart).toBe('always');
const onFailure = declaredFromEffectiveModel({
projectName: 'app',
services: [effSvc({
name: 'worker',
restart: undefined,
deploy: { restart_policy: { condition: 'on-failure' } },
})],
networks: {},
volumes: {},
});
expect(onFailure.services[0].restart).toBe('on-failure');
const defaultAny = declaredFromEffectiveModel({
projectName: 'app',
services: [effSvc({
name: 'worker',
restart: 'no',
deploy: { restart_policy: {} },
})],
networks: {},
volumes: {},
});
expect(defaultAny.services[0].restart).toBe('always');
});
it('normalizes networks so drift matches the rendered model', () => {
const model: EffectiveModel = {
projectName: 'myapp',