Files
sencho/backend/src/services/HealthGateService.ts
T
Anso 38aabe7064 feat: health-gated updates and rollback readiness (#1354)
* feat: classify stack deploy and update failures with suggested next actions

Failed deploy and update responses now carry a failure classification
(cause category, headline, and suggested next step) derived from the
compose error output. The recovery panel and chip render the
classification and include it in copied diagnostics, and gateway-style
failures surface as a node-unreachable cause.

* feat: add update and rollback readiness reports for stacks

Before a manual update, Sencho now shows an advisory readiness verdict
computed from the stored preflight result, open drift findings, live
container health, the pending image change, the rollback backup slot,
and node disk headroom. The Stack Dossier gains a rollback readiness
section that states what a rollback can restore and explicitly
discloses that volume and bind-mounted data are not covered. Toolbar
and sidebar updates now share one update path, and admins can create a
fleet snapshot from the readiness dialog before updating. Nodes that do
not advertise the capability keep the direct update flow.

* feat: observe stack health after updates with a post-deploy health gate

After a deploy or update succeeds, Sencho now watches the stack for a
configurable observation window and records a passed, failed, or
unknown verdict: containers must stay running, healthchecks must report
healthy, and restart loops or disappearing containers fail the gate.
The deploy panel shows the observation live and holds off auto-closing
until the verdict lands, a failed gate surfaces the existing recovery
actions including rollback, and the stack timeline records update
started and gate verdict events. Scheduled, webhook, bulk, and
git-source updates are gated the same way; rollbacks and installs are
deliberately not. The gate is observational only and can be tuned or
disabled per node under host alert settings.

* docs: document health-gated updates and rollback readiness

New operator page covering the update readiness dialog, the post-update
health gate and its settings, the rollback readiness disclosure, and
classified failures, with cross-links from the atomic deployments and
deploy progress pages. The API reference gains the readiness and
health-gate endpoints, the healthGateId success field, and the failure
classification schema on deploy and update error responses.

* feat: withhold the success verdict while the health gate observes

An update used to show a green Succeeded that a failed health gate then
contradicted moments later. The deploy modal now reports Verifying
health while the gate observes, shows success only when the gate
passes, and makes a failed or unknown gate the headline result; success
toasts soften to a verifying message while a gate runs. The mobile
recovery card groups its actions behind one bottom-right Take action
menu so it stays compact on a phone, with the classified cause still
visible on the card. A successful image update now also counts as the
last known-good marker in rollback readiness, and the docs gain
screenshots of the readiness dialog, gate states, dossier section, and
settings.

* fix: harden log format strings and the env existence path check

Log calls that interpolated the stack name into the console format
string now use constant format strings with placeholder arguments, and
envExists validates path containment inline at its filesystem access,
matching the established patterns used elsewhere in the same files.

* test: adapt deploy modal success specs to the post-deploy health gate

The deploy feedback modal now withholds its success verdict while the
health gate observes the new containers, showing "Verifying health"
until the gate passes. The two success-path E2E tests waited for
"Succeeded" within the gate's 90s default window and timed out.

Shorten the observation window to the 15s minimum for these tests via
the settings API, assert the verify-then-succeed sequence the modal
actually renders, and restore the default window afterward so the test
value does not leak into later runs.

* fix: serialize health gate polling and harden gate observation

Address race conditions in the post-update health gate found in review.

Backend: the gate poller used setInterval, so a Docker observe slower
than the 5s tick could overlap the next poll and corrupt the restart and
missing-container accounting, and a wedged socket could leave a poll
pending forever. Polling is now single-flight: each cycle self-schedules
the next only after it settles, and the observe is bounded by an 8s
timeout so a hung probe counts as a poll error and resolves the gate
unknown after three in a row.

Frontend: the gate poller could overlap requests, letting a slow earlier
"observing" response overwrite an already-applied terminal verdict. It is
now single-flight with a terminal latch, so a late response can never
roll the UI back from passed or failed.

Also reject a non-digit nodeId on the snapshot coverage route instead of
letting parseInt coerce it, document that turning off the deploy progress
panel opts out of the live gate UI while the gate still runs server-side,
and add gate-coverage tests for the webhook, git source, and auto-update
apply paths plus the new single-flight, observe-timeout, and recovery
cases.
2026-06-11 00:26:26 -04:00

473 lines
18 KiB
TypeScript

import { randomUUID } from 'crypto';
import DockerController from './DockerController';
import { DatabaseService, type HealthGateRunRow } from './DatabaseService';
import { sanitizeForLog } from '../utils/safeLog';
import { getErrorMessage } from '../utils/errors';
import { withTimeout } from '../utils/withTimeout';
import type { HealthGateContainer, HealthGateReport } from './updateGuard/types';
const POLL_INTERVAL_MS = 5_000;
// Per-observe ceiling so a hung Docker socket cannot leave a poll pending
// forever. Above POLL_INTERVAL_MS so a slow-but-live probe is not cut short; a
// timeout counts as a poll error and three in a row resolve the gate unknown.
const OBSERVE_TIMEOUT_MS = 8_000;
// A stack whose containers never appear gives up after this long.
const EMPTY_GRACE_MS = 15_000;
const DEFAULT_WINDOW_SECONDS = 90;
const MIN_WINDOW_SECONDS = 15;
const MAX_WINDOW_SECONDS = 600;
// Backstop against runaway concurrency (a burst of webhook or scheduled
// updates). Gates past the cap finalize immediately as unknown.
const MAX_CONCURRENT_GATES = 25;
interface ObservedContainer {
id: string;
name: string;
startedAt: string | null;
/** Docker's raw RestartCount at observation time. */
restartCount: number;
/** Gate-maintained restart tally since the baseline; 0 on a fresh snapshot,
* set by poll()'s accounting pass, at most one increment per poll. */
restarts: number;
state: string;
health: string | null;
}
interface ActiveGate {
runId: string;
nodeId: number;
stackName: string;
windowSeconds: number;
startedAt: number;
/** Self-scheduling poll timer, armed only between settled poll cycles. */
timer: ReturnType<typeof setTimeout> | null;
/** Expected container set, keyed by name; null until the first non-empty poll. */
expected: Map<string, ObservedContainer> | null;
consecutivePollErrors: number;
/** Names missing on the previous poll (two consecutive misses fail the gate). */
missingLastPoll: Set<string>;
/** Names in `restarting` state on the previous poll. */
restartingLastPoll: Set<string>;
/**
* Set by finalize so a poll that was mid-await when this gate was superseded
* or stopped can never overwrite the terminal verdict with a stale one.
*/
finalized: boolean;
}
/**
* Post-update health gate: after a deploy/update succeeds, observe the
* stack's containers for a configurable window and record a passed / failed /
* unknown verdict plus an activity timeline event. Purely observational: it
* never restarts, heals, or rolls anything back. AutoHeal needs no special
* handling: the unhealthy or exited state that triggers it is seen by the
* gate's own polls and fails the gate, and repeated restarts trip the
* restart-loop check.
*
* begin() is the single shared post-success hook for every gated deploy and
* update path; excluded paths (rollback, installs, reconciler loops) simply
* never call it.
*/
export class HealthGateService {
private static instance: HealthGateService;
private readonly active = new Map<string, ActiveGate>();
private started = false;
public static getInstance(): HealthGateService {
if (!HealthGateService.instance) {
HealthGateService.instance = new HealthGateService();
}
return HealthGateService.instance;
}
/** Sweep runs left observing by a previous process, then accept begin() calls. */
public start(): void {
this.started = true;
try {
const swept = DatabaseService.getInstance().markInterruptedHealthGateRuns(
'Sencho restarted during observation', Date.now(),
);
if (swept > 0) {
console.log(`[HealthGate] Marked ${swept} interrupted observation(s) as unknown`);
}
} catch (error) {
console.error('[HealthGate] Startup sweep failed:', getErrorMessage(error, 'unknown'));
}
}
/** Clear every poll timer and finalize in-flight gates as unknown. */
public stop(): void {
this.started = false;
for (const gate of [...this.active.values()]) {
this.finalize(gate, 'unknown', 'shutdown during observation', []);
}
}
/**
* Begin observing a stack after a successful deploy/update. Returns the gate
* run id for response correlation, or null when gating is disabled, the
* service is not started, or recording fails internally. Inserts the row
* synchronously so the caller can include the id in its response;
* observation then runs on a timer. Never throws.
*
* Also records the `update_started` activity event for update triggers, so
* every gated update path gets the timeline marker even when the gate
* itself is disabled.
*/
public begin(
nodeId: number,
stackName: string,
trigger: 'update' | 'deploy',
actor: string | null,
): string | null {
// Refuses work outside the start()/stop() lifecycle so a late call during
// shutdown cannot leave a dangling poll timer.
if (!this.started) return null;
try {
const db = DatabaseService.getInstance();
const settings = this.readSettings();
if (trigger === 'update') {
this.recordActivity(nodeId, stackName, 'info', 'update_started', `${stackName} update started`, actor);
}
if (!settings.enabled) return null;
// A newer operation supersedes an in-flight gate for the same stack.
const key = `${nodeId}:${stackName}`;
const existing = this.active.get(key);
if (existing) {
this.finalize(existing, 'unknown', 'superseded by a newer update', []);
}
const runId = randomUUID();
const startedAt = Date.now();
const row: HealthGateRunRow = {
id: runId,
node_id: nodeId,
stack_name: stackName,
trigger_action: trigger,
status: 'observing',
reason: null,
window_seconds: settings.windowSeconds,
containers_json: '[]',
started_at: startedAt,
ended_at: null,
created_by: actor,
};
if (this.active.size >= MAX_CONCURRENT_GATES) {
db.insertHealthGateRun({ ...row, status: 'unknown', reason: 'too many concurrent observations', ended_at: startedAt });
return runId;
}
db.insertHealthGateRun(row);
const gate: ActiveGate = {
runId,
nodeId,
stackName,
windowSeconds: settings.windowSeconds,
startedAt,
timer: null,
expected: null,
consecutivePollErrors: 0,
missingLastPoll: new Set(),
restartingLastPoll: new Set(),
finalized: false,
};
this.active.set(key, gate);
this.scheduleNextPoll(gate);
return runId;
} catch (error) {
// The gate is an observer; its failure must never fail the operation.
console.error(
'[HealthGate] begin (%s) failed for %s on node %d:',
trigger, sanitizeForLog(stackName), nodeId, error,
);
return null;
}
}
/** A specific run by id, the latest run, or the never-run sentinel. */
public getReport(nodeId: number, stackName: string, gateId?: string): HealthGateReport {
const db = DatabaseService.getInstance();
const row = gateId
? db.getHealthGateRun(nodeId, stackName, gateId)
: db.getLatestHealthGateRun(nodeId, stackName);
if (!row) {
return {
stack: stackName, id: null, status: 'never-run', trigger: null, reason: null,
windowSeconds: null, startedAt: null, endedAt: null, containers: [],
};
}
let containers: HealthGateContainer[] = [];
try {
const parsed: unknown = JSON.parse(row.containers_json);
if (Array.isArray(parsed)) containers = parsed as HealthGateContainer[];
} catch {
// A corrupt blob only loses the per-container detail, never the verdict.
console.warn('[HealthGate] Unreadable containers_json for run %s', sanitizeForLog(row.id));
}
return {
stack: stackName,
id: row.id,
status: row.status,
trigger: row.trigger_action,
reason: row.reason,
windowSeconds: row.window_seconds,
startedAt: row.started_at,
endedAt: row.ended_at,
containers,
};
}
/**
* Orchestrate one poll cycle and arm the next. Polls are single-flight: the
* next timer is scheduled only after this cycle fully settles (the finally
* below), so a slow or timed-out observe can never overlap the following poll
* or corrupt the restart/missing accounting that assumes one poll at a time.
*/
private async poll(gate: ActiveGate): Promise<void> {
const key = `${gate.nodeId}:${gate.stackName}`;
// A late timer fire after supersede, stop, or finalize must do nothing.
if (gate.finalized || this.active.get(key) !== gate) return;
try {
await this.runPollCycle(gate, key);
} finally {
if (!gate.finalized && this.active.get(key) === gate) {
this.scheduleNextPoll(gate);
}
}
}
/** Arm the next poll. No-op once the gate is finalized. */
private scheduleNextPoll(gate: ActiveGate): void {
if (gate.finalized) return;
gate.timer = setTimeout(() => { void this.poll(gate); }, POLL_INTERVAL_MS);
}
private async runPollCycle(gate: ActiveGate, key: string): Promise<void> {
let observed: ObservedContainer[];
try {
observed = await withTimeout(
this.observeContainers(gate), OBSERVE_TIMEOUT_MS, 'health gate observe',
);
} catch (error) {
gate.consecutivePollErrors += 1;
console.warn(
'[HealthGate] poll error %d for %s:',
gate.consecutivePollErrors, sanitizeForLog(gate.stackName), getErrorMessage(error, 'unknown'),
);
if (gate.consecutivePollErrors >= 3) {
this.finalize(gate, 'unknown', 'Docker became unreachable during observation', []);
}
return;
}
// The await above can straddle a supersede or stop; never act on a gate
// that was finalized mid-flight.
if (gate.finalized || this.active.get(key) !== gate) return;
gate.consecutivePollErrors = 0;
const elapsedMs = Date.now() - gate.startedAt;
if (gate.expected === null) {
if (observed.length > 0) {
gate.expected = new Map(observed.map(c => [c.name, c]));
} else if (elapsedMs >= EMPTY_GRACE_MS) {
this.finalize(gate, 'unknown', 'no containers found to observe', []);
}
return;
}
const byName = new Map(observed.map(c => [c.name, c]));
// First pass: restart accounting for every expected container still
// present, so the summary below reflects the tallies the checks act on. A
// restart counts when the container was replaced (new id), relaunched
// (StartedAt moved), or Docker bumped its RestartCount; at most one
// restart is tallied per poll regardless of how many occurred in the gap.
for (const [name, baseline] of gate.expected) {
const current = byName.get(name);
if (!current) continue;
const restarted =
current.id !== baseline.id ||
current.restartCount > baseline.restartCount ||
(current.startedAt !== null && baseline.startedAt !== null && current.startedAt !== baseline.startedAt);
current.restarts = baseline.restarts + (restarted ? 1 : 0);
}
const summary = this.summarize(gate.expected, byName);
// Second pass: fail fast on a clearly bad state.
for (const [name, baseline] of gate.expected) {
const current = byName.get(name);
if (!current) {
if (gate.missingLastPoll.has(name)) {
this.finalize(gate, 'failed', `container ${name} disappeared during observation`, summary);
return;
}
gate.missingLastPoll.add(name);
continue;
}
gate.missingLastPoll.delete(name);
if (current.state === 'exited' && baseline.restarts === current.restarts) {
// An exit with no restart attempt is terminal for the window.
this.finalize(gate, 'failed', `container ${name} exited during observation`, summary);
return;
}
if (current.health === 'unhealthy') {
this.finalize(gate, 'failed', `container ${name} reported unhealthy`, summary);
return;
}
if (current.restarts >= 2) {
this.finalize(gate, 'failed', `container ${name} is restart looping`, summary);
return;
}
if (current.state === 'restarting') {
if (gate.restartingLastPoll.has(name)) {
this.finalize(gate, 'failed', `container ${name} is stuck restarting`, summary);
return;
}
gate.restartingLastPoll.add(name);
} else {
gate.restartingLastPoll.delete(name);
}
// Carry the running restart tally forward as the new baseline.
gate.expected.set(name, current);
}
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');
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');
if (notRunning.length > 0) {
this.finalize(gate, 'failed', `not running at the end of the window: ${notRunning.join(', ')}`, summary);
return;
}
this.finalize(gate, 'passed', null, summary);
}
private async observeContainers(gate: ActiveGate): Promise<ObservedContainer[]> {
const docker = DockerController.getInstance(gate.nodeId).getDocker();
const listed = await docker.listContainers({
all: true,
filters: { label: [`com.docker.compose.project=${gate.stackName}`] },
});
const observed = await Promise.all(
listed.map(async (info): Promise<ObservedContainer | null> => {
try {
const inspect = await docker.getContainer(info.Id).inspect();
return {
id: info.Id,
name: info.Names?.[0]?.replace(/^\//, '') ?? info.Id.slice(0, 12),
startedAt: inspect.State?.StartedAt ?? null,
restartCount: typeof inspect.RestartCount === 'number' ? inspect.RestartCount : 0,
restarts: 0,
state: inspect.State?.Status ?? info.State ?? 'unknown',
health: inspect.State?.Health?.Status ?? null,
};
} catch (e: unknown) {
// Removed between list and inspect; the missing-container logic will
// see its absence on this or the next poll.
if ((e as { statusCode?: number })?.statusCode === 404) return null;
throw e;
}
}),
);
return observed.filter((c): c is ObservedContainer => c !== null);
}
private summarize(
expected: Map<string, ObservedContainer>,
current: Map<string, ObservedContainer>,
): HealthGateContainer[] {
return [...expected.values()].map(baseline => {
const now = current.get(baseline.name);
return {
name: baseline.name,
state: now?.state ?? 'missing',
health: now?.health ?? null,
restarts: now?.restarts ?? baseline.restarts,
};
});
}
private finalize(
gate: ActiveGate,
status: 'passed' | 'failed' | 'unknown',
reason: string | null,
containers: HealthGateContainer[],
): void {
if (gate.finalized) return;
gate.finalized = true;
if (gate.timer) clearTimeout(gate.timer);
const key = `${gate.nodeId}:${gate.stackName}`;
if (this.active.get(key) === gate) this.active.delete(key);
try {
DatabaseService.getInstance().finalizeHealthGateRun(
gate.runId, status, reason, Date.now(), JSON.stringify(containers),
);
} catch (error) {
// The verdict is lost from the DB (the startup sweep will later rewrite
// the row as unknown), so log everything needed to reconstruct it.
console.error(
'[HealthGate] Failed to persist verdict %s (%s) for run %s, stack %s:',
status, sanitizeForLog(reason ?? 'no reason'), gate.runId, sanitizeForLog(gate.stackName),
getErrorMessage(error, 'unknown'),
);
}
if (status === 'passed') {
this.recordActivity(gate.nodeId, gate.stackName, 'info', 'health_gate_passed',
`${gate.stackName} health gate passed after ${gate.windowSeconds}s`, 'system');
} else if (status === 'failed') {
this.recordActivity(gate.nodeId, gate.stackName, 'warning', 'health_gate_failed',
`${gate.stackName} health gate failed: ${reason ?? 'unknown reason'}`, 'system');
}
}
private recordActivity(
nodeId: number,
stackName: string,
level: 'info' | 'warning',
category: 'update_started' | 'health_gate_passed' | 'health_gate_failed',
message: string,
actor: string | null,
): void {
try {
DatabaseService.getInstance().addNotificationHistory(nodeId, {
level,
category,
message,
timestamp: Date.now(),
stack_name: stackName,
actor_username: actor,
});
} catch (error) {
console.warn('[HealthGate] Failed to record activity for %s:', sanitizeForLog(stackName), getErrorMessage(error, 'unknown'));
}
}
private readSettings(): { enabled: boolean; windowSeconds: number } {
try {
const settings = DatabaseService.getInstance().getGlobalSettings();
const windowRaw = parseInt(settings['health_gate_window_seconds'] ?? '', 10);
const windowSeconds = Number.isFinite(windowRaw)
? Math.min(MAX_WINDOW_SECONDS, Math.max(MIN_WINDOW_SECONDS, windowRaw))
: DEFAULT_WINDOW_SECONDS;
return { enabled: settings['health_gate_enabled'] !== '0', windowSeconds };
} catch (error) {
// Safe default: observing is non-destructive, so a settings read
// failure keeps the gate on with the default window.
console.warn('[HealthGate] Settings read failed; using defaults:', getErrorMessage(error, 'unknown'));
return { enabled: true, windowSeconds: DEFAULT_WINDOW_SECONDS };
}
}
}