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.
This commit is contained in:
Anso
2026-06-11 00:26:26 -04:00
committed by GitHub
parent 739bbf990e
commit 38aabe7064
66 changed files with 5076 additions and 79 deletions
+27
View File
@@ -1754,6 +1754,33 @@ fleetRouter.get('/snapshots', authMiddleware, async (req: Request, res: Response
}
});
// Registered before /snapshots/:id so "coverage" is never parsed as an id.
// Hub-local by design: snapshot rows exist only in the hub database, so the
// readiness UI fetches this with localOnly and merges it client-side.
fleetRouter.get('/snapshots/coverage', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requireAdmin(req, res)) return;
try {
// Strict digits only: parseInt would accept '1abc' as 1.
const nodeIdRaw = typeof req.query.nodeId === 'string' ? req.query.nodeId : '';
const stackName = req.query.stackName as string;
if (!/^\d+$/.test(nodeIdRaw)) {
res.status(400).json({ error: 'nodeId must be a non-negative integer' });
return;
}
const nodeId = parseInt(nodeIdRaw, 10);
if (typeof stackName !== 'string' || !isValidStackName(stackName)) {
res.status(400).json({ error: 'Invalid stack name' });
return;
}
const latestAt = DatabaseService.getInstance().getLatestSnapshotTimestampFor(nodeId, stackName);
res.json({ latestAt });
} catch (error) {
console.error('[Fleet Snapshot] Coverage lookup error:', error);
res.status(500).json({ error: 'Failed to look up snapshot coverage' });
}
});
fleetRouter.get('/snapshots/:id', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requireAdmin(req, res)) return;
+2
View File
@@ -8,6 +8,7 @@ import { FileSystemService } from '../services/FileSystemService';
import { ComposeService } from '../services/ComposeService';
import { NotificationService } from '../services/NotificationService';
import { enforcePolicyPreDeploy } from '../services/PolicyEnforcement';
import { HealthGateService } from '../services/HealthGateService';
import { authMiddleware } from '../middleware/auth';
import { requireAdmin } from '../middleware/tierGates';
import { buildPolicyGateOptions } from '../helpers/policyGate';
@@ -297,6 +298,7 @@ autoUpdateRouter.post('/execute', authMiddleware, async (req: Request, res: Resp
await compose.updateStack(stackName, undefined, atomic);
db.clearStackUpdateStatus(req.nodeId, stackName);
HealthGateService.getInstance().begin(req.nodeId, stackName, 'update', `auto-update:${req.user?.username ?? 'scheduler'}`);
NotificationService.getInstance().broadcastEvent({
type: 'state-invalidate',
+4
View File
@@ -27,6 +27,8 @@ const ALLOWED_SETTING_KEYS = new Set([
'prune_on_update',
'reclaim_hero',
'snapshot_documentation',
'health_gate_enabled',
'health_gate_window_seconds',
]);
// Keys whose write requires a paid license, not just an admin role.
@@ -52,6 +54,8 @@ const SettingsPatchSchema = z.object({
prune_on_update: z.enum(['0', '1']),
reclaim_hero: z.enum(['0', '1']),
snapshot_documentation: z.enum(['0', '1']),
health_gate_enabled: z.enum(['0', '1']),
health_gate_window_seconds: z.coerce.number().int().min(15).max(600).transform(String),
}).partial();
export const settingsRouter = Router();
+69 -6
View File
@@ -16,6 +16,9 @@ import { enforcePolicyPreDeploy } from '../services/PolicyEnforcement';
import { buildStackDriftReport, type DriftFindingKind, type StackDriftReport } from '../services/DriftDetectionService';
import { DriftLedgerService, type DriftTemporal } from '../services/DriftLedgerService';
import { ComposeDoctorService } from '../services/ComposeDoctorService';
import { UpdateGuardService } from '../services/UpdateGuardService';
import { HealthGateService } from '../services/HealthGateService';
import { classifyFailure } from '../services/updateGuard/failureClassifier';
import { requirePermission, checkPermission } from '../middleware/permissions';
import { NotificationService, type NotificationCategory } from '../services/NotificationService';
import { StackOpLockService, type StackOpAction } from '../services/StackOpLockService';
@@ -351,6 +354,8 @@ interface BulkResultItem {
ok: boolean;
error?: string;
code?: string;
/** Health gate run started for a successful update, when gating is enabled. */
healthGateId?: string | null;
}
async function runStackBulkOp(
@@ -413,6 +418,8 @@ async function runStackBulkOp(
triggerPostDeployScan(stackName, req.nodeId).catch(err =>
console.error('[Security] Post-deploy scan failed for %s:', sanitizeForLog(stackName), err),
);
const healthGateId = HealthGateService.getInstance().begin(req.nodeId, stackName, 'update', req.user?.username ?? null);
return { stackName, ok: true, healthGateId };
} else {
const outcome = await containerActionForStack(req.nodeId, stackName, action);
if (outcome.kind === 'no-containers') {
@@ -1120,6 +1127,55 @@ stacksRouter.post('/:stackName/preflight/run', async (req: Request, res: Respons
}
});
// Update guard: readiness reports computed on demand from existing stores
// (preflight runs, drift findings, backup slot, update preview, live Docker
// state). Node-scoped like preflight: a remote stack is evaluated on the node
// that owns it. Read-only, so stack:read is the correct gate.
stacksRouter.get('/:stackName/update-readiness', async (req: Request, res: Response) => {
const stackName = req.params.stackName as string;
if (!requirePermission(req, res, 'stack:read', 'stack', stackName)) return;
if (!(await requireStackExists(req.nodeId, stackName, res))) return;
try {
const report = await UpdateGuardService.getInstance().computeUpdateReadiness(req.nodeId, stackName);
res.json(report);
} catch (error) {
console.error('[Stacks] Failed to compute update readiness for %s:', sanitizeForLog(stackName),
sanitizeForLog(getErrorMessage(error, 'unknown')));
res.status(500).json({ error: 'Failed to compute update readiness' });
}
});
stacksRouter.get('/:stackName/rollback-readiness', async (req: Request, res: Response) => {
const stackName = req.params.stackName as string;
if (!requirePermission(req, res, 'stack:read', 'stack', stackName)) return;
if (!(await requireStackExists(req.nodeId, stackName, res))) return;
try {
const report = await UpdateGuardService.getInstance().computeRollbackReadiness(req.nodeId, stackName);
res.json(report);
} catch (error) {
console.error('[Stacks] Failed to compute rollback readiness for %s:', sanitizeForLog(stackName),
sanitizeForLog(getErrorMessage(error, 'unknown')));
res.status(500).json({ error: 'Failed to compute rollback readiness' });
}
});
// Post-update health gate result. `gateId` returns that specific run so a
// superseded gate still resolves to its terminal state; without it, the
// latest run (or a never-run sentinel).
stacksRouter.get('/:stackName/health-gate', async (req: Request, res: Response) => {
const stackName = req.params.stackName as string;
if (!requirePermission(req, res, 'stack:read', 'stack', stackName)) return;
if (!(await requireStackExists(req.nodeId, stackName, res))) return;
try {
const gateId = typeof req.query.gateId === 'string' && req.query.gateId.trim() ? req.query.gateId : undefined;
res.json(HealthGateService.getInstance().getReport(req.nodeId, stackName, gateId));
} catch (error) {
console.error('[Stacks] Failed to load health gate for %s:', sanitizeForLog(stackName),
sanitizeForLog(getErrorMessage(error, 'unknown')));
res.status(500).json({ error: 'Failed to load health gate' });
}
});
stacksRouter.post('/:stackName/deploy', async (req: Request, res: Response) => {
const stackName = req.params.stackName as string;
if (!requirePermission(req, res, 'stack:deploy', 'stack', stackName)) return;
@@ -1139,7 +1195,8 @@ stacksRouter.post('/:stackName/deploy', async (req: Request, res: Response) => {
dlog(`[Stacks] Deploy completed: ${sanitizeForLog(stackName)}`);
if (debug) console.debug(`[Stacks:debug] Deploy finished in ${Date.now() - t0}ms`);
ok = true;
res.json({ message: 'Deployed successfully' });
const healthGateId = HealthGateService.getInstance().begin(req.nodeId, stackName, 'deploy', req.user?.username ?? null);
res.json({ message: 'Deployed successfully', healthGateId });
notifyActionSuccess('deploy_success', `${stackName} deployed`, stackName, req.user?.username ?? 'system');
if (!skipScan) {
triggerPostDeployScan(stackName, req.nodeId).catch(err =>
@@ -1156,12 +1213,14 @@ stacksRouter.post('/:stackName/deploy', async (req: Request, res: Response) => {
console.warn('[Stacks] Deploy failed, rollback did not complete: %s', sanitizeForLog(stackName));
}
const message = getErrorMessage(error, 'Failed to deploy stack');
// ComposeRollbackError already carries the cause's message; see classifyFailure.
const failure = classifyFailure(message, { dockerUnavailable: isDockerUnavailableError(error) });
notifyActionFailure('deploy', stackName, error, req.user?.username ?? 'system');
if (!res.headersSent) {
if (isDockerUnavailableError(error)) {
res.status(503).json({ error: message, code: 'docker_unavailable', rolledBack });
res.status(503).json({ error: message, code: 'docker_unavailable', rolledBack, failure });
} else {
res.status(500).json({ error: message, rolledBack });
res.status(500).json({ error: message, rolledBack, failure });
}
}
} finally {
@@ -1408,7 +1467,8 @@ stacksRouter.post('/:stackName/update', async (req: Request, res: Response) => {
dlog(`[Stacks] Update completed: ${sanitizeForLog(stackName)}`);
if (debug) console.debug(`[Stacks:debug] Update finished in ${Date.now() - t0}ms`);
ok = true;
res.json({ status: 'Update completed' });
const healthGateId = HealthGateService.getInstance().begin(req.nodeId, stackName, 'update', req.user?.username ?? null);
res.json({ status: 'Update completed', healthGateId });
notifyActionSuccess('image_update_applied', `${stackName} updated`, stackName, req.user?.username ?? 'system');
if (!skipScan) {
triggerPostDeployScan(stackName, req.nodeId).catch(err =>
@@ -1426,10 +1486,13 @@ stacksRouter.post('/:stackName/update', async (req: Request, res: Response) => {
}
notifyActionFailure('update', stackName, error, req.user?.username ?? 'system');
if (!res.headersSent) {
const message = getErrorMessage(error, 'Failed to update');
// ComposeRollbackError already carries the cause's message; see classifyFailure.
const failure = classifyFailure(message, { dockerUnavailable: isDockerUnavailableError(error) });
if (isDockerUnavailableError(error)) {
res.status(503).json({ error: getErrorMessage(error, 'Docker daemon is unreachable'), code: 'docker_unavailable', rolledBack });
res.status(503).json({ error: message, code: 'docker_unavailable', rolledBack, failure });
} else {
res.status(500).json({ error: getErrorMessage(error, 'Failed to update'), rolledBack });
res.status(500).json({ error: message, rolledBack, failure });
}
}
} finally {