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
+7
View File
@@ -210,6 +210,12 @@ export interface DependencyContainer {
/** Resolved Sencho stack, or null when the container is not Sencho-managed. */
stack: string | null;
state: string;
/**
* Exit code from list Status when a parenthesized code is present
* (e.g. "Exited (0) …", "Restarting (1) …"); null when none (e.g. "Up …", bare "Exited").
* Required so Drift can fail closed on unknown codes.
*/
exitCode: number | null;
image: string;
networks: { name: string; id: string; ip: string }[];
/** Named-volume sources mounted by the container (bind mounts excluded). */
@@ -1833,6 +1839,7 @@ class DockerController {
composeProject: c.Labels?.['com.docker.compose.project'] ?? null,
stack: DockerController.resolveContainerStack(c.Labels, projectToStack, knownSet, absDirToStack, resolvedBase),
state: c.State ?? 'unknown',
exitCode: parseExitCode(typeof c.Status === 'string' ? c.Status : undefined),
image: c.Image ?? '',
networks,
volumes,
+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;
+112 -15
View File
@@ -5,8 +5,11 @@ import { AutoHealService } from './AutoHealService';
import { sanitizeForLog } from '../utils/safeLog';
import { getErrorMessage } from '../utils/errors';
import { withTimeout } from '../utils/withTimeout';
import { isCleanOneShotCompletion } from '../utils/oneShotCompletion';
import { declaredFromEffectiveModel } from '../helpers/effectiveToDeclaredCompose';
import { parseEffectiveModel } from './preflight/effectiveModel';
import type { HealthGateContainer, HealthGateReport } from './updateGuard/types';
import { getComposeCommandTimeoutMs } from './ComposeService';
import { ComposeService, getComposeCommandTimeoutMs } from './ComposeService';
const POLL_INTERVAL_MS = 5_000;
// A prepared-but-never-begun token (prepare called, mutation then failed before
@@ -47,6 +50,10 @@ interface ObservedContainer {
service: string | null;
/** Image id the container is running (service gates check convergence on it). */
imageId: string;
/** Inspect State.ExitCode; null when unavailable. */
exitCode: number | null;
/** HostConfig.RestartPolicy.Name (report/debug only; not used for one-shot intent). */
restartPolicy: string | null;
}
interface ActiveGate {
@@ -89,6 +96,13 @@ interface ActiveGate {
collateralBaselineByName: Map<string, ObservedContainer>;
/** Role of each expected container (service gates), for failure attribution. */
roleByName: Map<string, GateRole>;
/**
* Declared Compose restart intent by service name (normalized). Loaded once
* per gate; null until the first load attempt completes. Used for one-shot
* recognition instead of Docker inspect (which cannot distinguish omit vs
* explicit `restart: "no"`).
*/
declaredRestartByService: Map<string, string | null> | null;
}
/** A pre-mutation baseline container captured at prepare time. */
@@ -101,6 +115,8 @@ interface PreparedBaseline {
restartCount: number;
startedAt: string | null;
imageId: string;
exitCode: number | null;
restartPolicy: string | null;
}
function isRegressionEligibleSibling(baseline: PreparedBaseline): boolean {
@@ -118,9 +134,35 @@ function observedFromPreparedBaseline(baseline: PreparedBaseline): ObservedConta
health: baseline.health,
service: baseline.service,
imageId: baseline.imageId,
exitCode: baseline.exitCode,
restartPolicy: baseline.restartPolicy,
};
}
/** Running, or a clean one-shot exit (exit 0 + explicit declared restart "no"). */
function isObservedContainerSatisfied(
gate: ActiveGate,
current: ObservedContainer | undefined,
): boolean {
if (!current) return false;
if (current.state === 'running') return true;
return isDeclaredCleanOneShot(gate, current);
}
/**
* One-shot recognition from declared Compose intent only. Unlabeled containers
* and services missing from the effective model fail closed.
*/
function isDeclaredCleanOneShot(gate: ActiveGate, current: ObservedContainer): boolean {
const map = gate.declaredRestartByService;
if (!map || !current.service || !map.has(current.service)) return false;
return isCleanOneShotCompletion({
state: current.state,
exitCode: current.exitCode,
restartPolicy: map.get(current.service),
});
}
/** An in-memory prepare snapshot awaiting attachExpectedImage + beginPrepared. */
interface PreparedGate {
token: string;
@@ -329,6 +371,7 @@ export class HealthGateService {
collateralEligibleNames: new Set(),
collateralBaselineByName: new Map(),
roleByName: new Map(),
declaredRestartByService: null,
};
this.active.set(key, gate);
this.scheduleNextPoll(gate);
@@ -488,6 +531,7 @@ export class HealthGateService {
prep.collateralBaseline.filter(b => prep.collateralEligibleNames.has(b.name)),
),
roleByName: new Map(),
declaredRestartByService: null,
};
this.active.set(key, gate);
this.scheduleNextPoll(gate);
@@ -587,6 +631,9 @@ export class HealthGateService {
if (gate.finalized || this.active.get(key) !== gate) return;
gate.consecutivePollErrors = 0;
await this.ensureDeclaredRestartMap(gate);
if (gate.finalized || this.active.get(key) !== gate) return;
const elapsedMs = Date.now() - gate.startedAt;
if (gate.expected === null) {
@@ -629,12 +676,16 @@ export class HealthGateService {
}
gate.missingLastPoll.delete(name);
if (current.state === 'exited' && baseline.restarts === current.restarts) {
// An exit with no restart attempt is terminal for the window.
const cleanOneShot = isDeclaredCleanOneShot(gate, current);
// An exit with no restart attempt is terminal for the window, unless
// this is an expected one-shot (exit 0 + explicit declared restart "no").
if (current.state === 'exited' && baseline.restarts === current.restarts && !cleanOneShot) {
this.finalize(gate, 'failed', `container ${name} exited during observation`, summary);
return;
}
if (current.health === 'unhealthy') {
// Residual Docker health on a completed one-shot is not a gate failure;
// long-running containers still fail on unhealthy.
if (!cleanOneShot && current.health === 'unhealthy') {
this.finalize(gate, 'failed', `container ${name} reported unhealthy`, summary);
return;
}
@@ -657,14 +708,19 @@ export class HealthGateService {
if (elapsedMs < gate.windowSeconds * 1000) return;
// Window complete: pass requires everything running and healthy wherever a
// healthcheck exists. A health state still 'starting' is not a pass.
const stillStarting = observed.filter(c => c.health === 'starting');
// Window complete: pass requires everything running (or a clean one-shot
// completion) and healthy wherever a healthcheck exists. A health state
// still 'starting' is not a pass (except residual health on a clean one-shot).
const stillStarting = observed.filter(
c => c.health === 'starting' && !isDeclaredCleanOneShot(gate, c),
);
if (stillStarting.length > 0) {
this.finalize(gate, 'unknown', 'a healthcheck was still starting when the observation window ended', summary);
return;
}
const notRunning = [...gate.expected.keys()].filter(name => byName.get(name)?.state !== 'running');
const notRunning = [...gate.expected.keys()].filter(
name => !isObservedContainerSatisfied(gate, byName.get(name)),
);
if (notRunning.length > 0) {
this.finalize(gate, 'failed', `not running at the end of the window: ${notRunning.join(', ')}`, summary);
return;
@@ -700,6 +756,9 @@ export class HealthGateService {
if (gate.finalized || this.active.get(key) !== gate) return;
gate.consecutivePollErrors = 0;
await this.ensureDeclaredRestartMap(gate);
if (gate.finalized || this.active.get(key) !== gate) return;
const elapsedMs = Date.now() - gate.startedAt;
const serviceName = gate.serviceName ?? '';
@@ -781,11 +840,12 @@ export class HealthGateService {
}
gate.missingLastPoll.delete(name);
if (current.state === 'exited' && baseline.restarts === current.restarts) {
const cleanOneShot = isDeclaredCleanOneShot(gate, current);
if (current.state === 'exited' && baseline.restarts === current.restarts && !cleanOneShot) {
this.finalize(gate, 'failed', `${noun} ${name} exited during observation`, summary, role);
return;
}
if (current.health === 'unhealthy') {
if (!cleanOneShot && current.health === 'unhealthy') {
this.finalize(gate, 'failed', `${noun} ${name} reported unhealthy`, summary, role);
return;
}
@@ -808,24 +868,28 @@ export class HealthGateService {
if (elapsedMs < gate.windowSeconds * 1000) return;
const stillStarting = observed.filter(
c => c.health === 'starting' && (c.service === serviceName || gate.collateralEligibleNames.has(c.name)),
c => c.health === 'starting'
&& !isDeclaredCleanOneShot(gate, c)
&& (c.service === serviceName || gate.collateralEligibleNames.has(c.name)),
);
if (stillStarting.length > 0) {
this.finalize(gate, 'unknown', 'a healthcheck was still starting when the observation window ended', summary);
return;
}
const runningPrimary = observed.filter(c => c.service === serviceName && c.state === 'running');
if (runningPrimary.length !== gate.expectedReplicas) {
const satisfiedPrimary = observed.filter(
c => c.service === serviceName && isObservedContainerSatisfied(gate, c),
);
if (satisfiedPrimary.length !== gate.expectedReplicas) {
this.finalize(
gate, 'failed',
`service ${serviceName} has ${runningPrimary.length} running replica(s), expected ${gate.expectedReplicas}`,
`service ${serviceName} has ${satisfiedPrimary.length} satisfied replica(s), expected ${gate.expectedReplicas}`,
summary, 'primary',
);
return;
}
const collateralNotRunning = [...gate.expected.keys()]
.filter(name => gate.roleByName.get(name) === 'collateral')
.filter(name => byName.get(name)?.state !== 'running');
.filter(name => !isObservedContainerSatisfied(gate, byName.get(name)));
if (collateralNotRunning.length > 0) {
this.finalize(gate, 'failed', `sibling(s) not running at the end of the window: ${collateralNotRunning.join(', ')}`, summary, 'collateral');
return;
@@ -855,6 +919,35 @@ export class HealthGateService {
return this.listStackContainers(gate.nodeId, gate.stackName);
}
/**
* Load declared Compose restart intent once per gate. Fail closed to an empty
* map on render/parse errors so inspect "no" cannot false-qualify one-shots.
*/
private async ensureDeclaredRestartMap(gate: ActiveGate): Promise<void> {
if (gate.declaredRestartByService !== null) return;
gate.declaredRestartByService = new Map();
try {
const result = await ComposeService.getInstance(gate.nodeId).renderConfig(gate.stackName);
if (result.rendered === null) {
console.warn(
'[HealthGate] declared restart map unavailable for %s (compose render failed)',
sanitizeForLog(gate.stackName),
);
return;
}
const model = parseEffectiveModel(JSON.parse(result.rendered), gate.stackName);
const declared = declaredFromEffectiveModel(model);
gate.declaredRestartByService = new Map(
declared.services.map((s) => [s.name, s.restart ?? null]),
);
} catch (error) {
console.warn(
'[HealthGate] declared restart map load failed for %s:',
sanitizeForLog(gate.stackName), getErrorMessage(error, 'unknown'),
);
}
}
/** List and inspect a stack's containers into the gate's observation shape. */
private async listStackContainers(nodeId: number, stackName: string): Promise<ObservedContainer[]> {
const docker = DockerController.getInstance(nodeId).getDocker();
@@ -877,6 +970,8 @@ export class HealthGateService {
health: inspect.State?.Health?.Status ?? null,
service: labels['com.docker.compose.service'] ?? null,
imageId: inspect.Image ?? '',
exitCode: typeof inspect.State?.ExitCode === 'number' ? inspect.State.ExitCode : null,
restartPolicy: inspect.HostConfig?.RestartPolicy?.Name || null,
};
} catch (e: unknown) {
// Removed between list and inspect; the missing-container logic will
@@ -903,6 +998,8 @@ export class HealthGateService {
restartCount: c.restartCount,
startedAt: c.startedAt,
imageId: c.imageId,
exitCode: c.exitCode,
restartPolicy: c.restartPolicy,
}));
}
@@ -56,7 +56,7 @@ export interface EffService {
networkMode?: string;
restart?: string;
hasHealthcheck: boolean;
/** Raw deploy block (read for key presence only, never values; undefined = none). */
/** Raw deploy block (preflight uses key presence; Drift also reads restart_policy.condition). Undefined = none. */
deploy?: Record<string, unknown>;
containerName?: string;
user?: string;
+1 -1
View File
@@ -363,7 +363,7 @@ const noRestartPolicy: PreflightRule = {
message: `Service "${s.name}" has no restart policy, so it will not come back after a crash or host reboot.`,
sourcePath: s.name,
service: s.name,
remediation: 'Add restart: unless-stopped.',
remediation: 'For long-running services, add restart: unless-stopped. For one-shot or init jobs that should finish and stay stopped, restart: "no" is appropriate.',
}));
},
};