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
+41 -25
View File
@@ -8,6 +8,7 @@ import { parseEffectiveModel } from './preflight/effectiveModel';
import { compareStackNetworks, fromDeclaredCompose } from './network/normalize';
import { sanitizeForLog, redactSensitiveText } from '../utils/safeLog';
import { getErrorMessage } from '../utils/errors';
import { isCleanOneShotCompletion } from '../utils/oneShotCompletion';
const MAX_RENDER_ERROR = 600;
@@ -178,16 +179,17 @@ function networkDriftFindings(
}
/**
* Pure diff step (no Docker / FS access) so it is directly unit-testable. Only
* running containers are compared, since a stopped container publishes no ports
* and is not "deployed": a declared service with no running container is
* service-missing, a running container with no matching service is
* service-undeclared, and image / port differences are checked only for services
* present on both sides so a missing/undeclared service is not double-reported.
* Pure diff step (no Docker / FS access) so it is directly unit-testable.
* Running/restarting containers drive image/port comparison. Clean one-shot
* completions (exit 0 + explicit declared restart "no", including normalized
* `deploy.restart_policy.condition: none`) satisfy service presence without
* counting as hasContainers. Omitting restart does not qualify. Network
* comparison still uses only running/restarting attachments.
*/
export function assembleStackDrift(input: AssembleStackDriftInput): StackDriftReport {
const { stack, declared, containers, parseError } = input;
const networks = input.networks ?? [];
// Public contract: at least one running/restarting container (not "satisfied").
const hasContainers = containers.some((c) => RUNNING_STATES.has(c.state));
// A parse failure means the declared model is untrustworthy: report drift
@@ -196,30 +198,44 @@ export function assembleStackDrift(input: AssembleStackDriftInput): StackDriftRe
return { stack, status: 'drifted', hasComposeFile: false, hasContainers, findings: [], parseError };
}
const runtimeByService = new Map<string, RuntimeService>();
for (const c of containers) {
if (!RUNNING_STATES.has(c.state)) continue;
const name = c.service ?? c.name;
const agg = runtimeByService.get(name) ?? { images: new Set<string>(), ports: new Set<string>() };
if (c.image) agg.images.add(normalizeImageRef(c.image));
for (const p of c.ports) agg.ports.add(portKey(p.publishedPort, p.protocol));
runtimeByService.set(name, agg);
}
// Nothing running: the stack is defined on disk but not deployed. One status
// conveys this; per-service findings would just be noise.
if (!hasContainers) {
return { stack, status: 'missing-runtime', hasComposeFile: true, hasContainers: false, findings: [] };
}
const declaredByName = new Map<string, DeclaredService>();
for (const svc of declared.services) declaredByName.set(svc.name, svc);
const runtimeByService = new Map<string, RuntimeService>();
const oneShotSatisfied = new Set<string>();
for (const c of containers) {
const name = c.service ?? c.name;
if (RUNNING_STATES.has(c.state)) {
const agg = runtimeByService.get(name) ?? { images: new Set<string>(), ports: new Set<string>() };
if (c.image) agg.images.add(normalizeImageRef(c.image));
for (const p of c.ports) agg.ports.add(portKey(p.publishedPort, p.protocol));
runtimeByService.set(name, agg);
continue;
}
const declaredRestart = declaredByName.get(name)?.restart;
if (isCleanOneShotCompletion({
state: c.state,
exitCode: c.exitCode,
restartPolicy: declaredRestart,
})) {
oneShotSatisfied.add(name);
}
}
const servicePresent = (serviceName: string): boolean =>
runtimeByService.has(serviceName) || oneShotSatisfied.has(serviceName);
// Nothing running and no declared service satisfied by a clean one-shot: the
// stack is defined on disk but not deployed. One status conveys this.
if (!hasContainers && !declared.services.some((svc) => servicePresent(svc.name))) {
return { stack, status: 'missing-runtime', hasComposeFile: true, hasContainers: false, findings: [] };
}
const findings: StackDriftFinding[] = [];
// Declared service with no running container.
// Declared service with no running container and no clean one-shot completion.
for (const svc of declared.services) {
if (!runtimeByService.has(svc.name)) {
if (!servicePresent(svc.name)) {
findings.push({
kind: 'service-missing',
service: svc.name,
@@ -239,7 +255,7 @@ export function assembleStackDrift(input: AssembleStackDriftInput): StackDriftRe
}
}
// Image / port divergence for services present on both sides.
// Image / port divergence for services present on both sides (running only).
for (const [name, svc] of declaredByName) {
const runtime = runtimeByService.get(name);
if (!runtime) continue;