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
+77
View File
@@ -0,0 +1,77 @@
/**
* Helpers for clean one-shot completion and Compose restart-intent normalization.
* Used by Health Gate and Drift service-presence only; does not change bulk
* status, Auto-Heal, or atomic-deploy helpers.
*/
/**
* True only for an explicit Compose `restart: "no"` (after normalization).
* Absent / null / empty do not qualify: Docker reports HostConfig restart
* "no" for both intentional one-shots and bare long-running services that
* omit `restart:`, so consumers must pass declared Compose intent, not inspect.
*/
export function isNoRestartPolicy(policy: string | null | undefined): boolean {
return policy === 'no';
}
/**
* Normalize Compose restart intent for Drift / declared-model consumers.
*
* Compose precedence: when `deploy.restart_policy` is set, its `condition`
* wins; otherwise the service-level `restart` field is used. Conditions map to
* Docker-like policy names so {@link isNoRestartPolicy} stays the single gate:
* `none` → `no`, `any` → `always`, `on-failure` → `on-failure`. Missing
* condition defaults to `any` (Compose default). Unknown shapes fail closed.
* Absent service restart (no deploy policy) stays null and is not one-shot eligible.
*/
export function normalizeComposeRestartIntent(
serviceRestart: string | null | undefined,
deploy?: Record<string, unknown> | null,
): string | null {
if (deploy && Object.prototype.hasOwnProperty.call(deploy, 'restart_policy')) {
const policy = deploy['restart_policy'];
if (policy === null || typeof policy !== 'object' || Array.isArray(policy)) {
return 'always';
}
const condition = (policy as Record<string, unknown>).condition;
// Missing, empty, or non-string → Compose default `any` → always.
if (typeof condition !== 'string' || condition === '') {
return 'always';
}
switch (condition.toLowerCase()) {
case 'none':
return 'no';
case 'on-failure':
return 'on-failure';
case 'any':
return 'always';
default:
return 'always';
}
}
if (serviceRestart == null || serviceRestart === '') return null;
return serviceRestart;
}
export interface OneShotCompletionInput {
state: string;
/** Exact Docker exit code; null means unknown (fail closed). */
exitCode: number | null;
/**
* Declared Compose restart intent after normalization (`"no"` for explicit
* one-shots / `deploy.restart_policy.condition: none`). Do not pass Docker
* inspect HostConfig values here.
*/
restartPolicy: string | null | undefined;
}
/**
* True only for an exited container with exit code exactly 0 and explicit
* declared restart `"no"`. Null/absent restart, null exit codes, and restarting
* policies never qualify.
*/
export function isCleanOneShotCompletion(input: OneShotCompletionInput): boolean {
return input.state === 'exited'
&& input.exitCode === 0
&& isNoRestartPolicy(input.restartPolicy);
}