/** * Health Check Runner * * Invokes health_check hooks on modules and parses structured results. * Updates module state to VERIFIED on successful health checks. */ import { eq } from 'drizzle-orm'; import { FuelGauge } from '../cli/fuel-gauge'; import type { DbClient } from '../db/client'; import { type ModuleState, modules } from '../db/schema'; import { loadCapabilityFunctions } from '../hooks/capability-loader'; import { invokeHook } from '../hooks/executor'; import { createHookStores } from '../hooks/hook-store'; import { loadHookConfigMap } from '../hooks/load-hook-config'; import { createCapturingLogger, createConsoleLogger, createGaugeLogger } from '../hooks/logger'; import type { HookLogger, HookResult } from '../hooks/types'; import type { HealthWaiver, ModuleManifest } from '../manifest/schema'; import { decryptSecret } from '../secrets/encryption'; import { getOrCreateMasterKey } from '../secrets/master-key'; import { controlPlaneHealthChecks } from './control-plane-health'; import { CONTROL_PLANE_MODULE_ID, getModuleSystems } from './deployed-systems'; import { remoteAccessPolicy } from './remote-access'; export interface HealthCheckItem { name: string; /** * `skip` — the check could not perform its real probe (a credential is * not provisioned, a peer is unreachable from the control plane) and * refuses to guess. It is not a pass: a module whose checks all report * skip is unmeasured, and `system audit` renders the fleet UNKNOWN, not * READY. Never claim `pass` for a probe you did not run (celilo#1326). */ status: 'pass' | 'warn' | 'fail' | 'skip'; message: string; details?: string; } export interface HealthCheckResult { moduleId: string; status: 'healthy' | 'degraded' | 'unhealthy' | 'no-checks' | 'error'; checks: HealthCheckItem[]; error?: string; /** * Files the hook wrote to its per-run artifact directory. * * This path is the one that runs UNATTENDED every fifteen minutes, and * it used to drop `HookResult`'s artifacts on the floor entirely — the * on-demand paths (`module run-hook`, deploy) surfaced them and this one * did not, so the artifacts were discarded exactly when nobody was * watching to re-run the check by hand. */ artifactPaths?: string[]; /** * The module's recorded health waiver, if its manifest declares one * (openspec/changes/health-waiver-mechanism). It does not change this * result's severity — an unmeasured module stays unmeasured — it * annotates the finding with who decided and why. */ waiver?: HealthWaiver; } export interface HealthCheckOptions { debug?: boolean; noInteractive?: boolean; onProgress?: (msg: string) => void; /** * Presentation-only silence: no gauge and no plain stdout lines, hook * output captured instead. Unlike `unattended` this does NOT touch the * state-transition guard — a `--json` command still runs real checks * and still moves lifecycle state (celilo#1362). */ quiet?: boolean; /** * Scheduled/monitor-driven run: no FuelGauge, no stdout, and — the part * that matters beyond cosmetics — the module's lifecycle state is NOT * touched. See `createHealthCheckPresentation` and the state-transition * guard below. */ unattended?: boolean; } /** * How a health-check run reports itself while it runs. * * The four modes differ ONLY in which logger they build and whether a * gauge needs stopping afterwards, so they are resolved here rather than * branching around four copies of the `invokeHook` call (Rule 10.1 — * presentation is not execution). */ interface HealthCheckPresentation { logger: HookLogger; /** Called once the hook returns; stops the gauge when there is one. */ finish(success: boolean): void; } const NO_OP_FINISH = (): void => {}; function createHealthCheckPresentation( moduleId: string, options: HealthCheckOptions, ): HealthCheckPresentation { if (options.debug) { return { logger: createConsoleLogger(moduleId, 'health_check'), finish: NO_OP_FINISH }; } // Scheduled run: capture hook output rather than letting it reach a // terminal nobody is watching. if (options.unattended) { return { logger: createCapturingLogger().logger, finish: NO_OP_FINISH }; } // TUI mode: emit a progress message instead of drawing FuelGauge // (which writes to stdout and would corrupt the alt-screen render). // Use a capturing logger to absorb hook-level log lines that would // otherwise leak to stdout via createConsoleLogger. if (options.onProgress) { options.onProgress(`Checking ${moduleId}`); return { logger: createCapturingLogger().logger, finish: NO_OP_FINISH }; } // Non-interactive stdout (redirected, piped, or an explicit --json // caller): stdout is a data channel here (celilo#699), so neither the // gauge's frames nor the gauge logger's plain lines may reach it. // Capture everything instead (celilo#1362). State transitions still // happen — only the presentation goes quiet. if (options.quiet || !process.stdout.isTTY) { return { logger: createCapturingLogger().logger, finish: NO_OP_FINISH }; } const gauge = new FuelGauge('Testing app', { skipAnimation: options.noInteractive }); gauge.start(); return { logger: createGaugeLogger(gauge, moduleId, 'health_check'), finish: (success) => gauge.stop(success), }; } /** * Decide whether a health-check result should move a module's lifecycle * state, returning the new state or `null` to leave it alone. * * A SCHEDULED run observes only. `VERIFIED` asserts that a check was * deliberately run and passed; letting a recurring unattended check drive it * would silently change what the state means — from "someone verified this" * to "it was up N minutes ago" — and would churn it on every transient * failure, damping that the alert lifecycle already provides and module state * does not. Continuously observed health is reported through alerts and the * `celilo module list` health column instead. * * Pure on purpose: this rule is the one thing in the scheduling work that * regresses invisibly (a wrong boolean here just makes state flicker), so it * is testable without a database or a hook subprocess. * * See openspec/changes/add-alerting/design.md D15. */ export function nextModuleState( current: ModuleState, status: 'healthy' | 'degraded' | 'unhealthy', unattended: boolean, ): ModuleState | null { if (unattended) return null; if (status === 'healthy' || status === 'degraded') return 'VERIFIED'; return current === 'VERIFIED' ? 'INSTALLED' : null; } /** * Run health checks for a single module * * `onProgress` (when supplied) replaces the FuelGauge animation. Used * by the audit TUI to stream progress messages into its own UI rather * than letting them leak to stdout before the alt-screen takes over. * * `unattended` marks a monitor-driven run: silent, and non-mutating with * respect to module state. */ export async function runModuleHealthCheck( moduleId: string, db: DbClient, options: HealthCheckOptions = {}, ): Promise { const module = db.select().from(modules).where(eq(modules.id, moduleId)).get(); if (!module) { return { moduleId, status: 'error', checks: [], error: 'Module not found' }; } // The control plane checks itself, in celilo's own process (celilo#1225). // // This was a health_check hook and it failed under the jail in the worst // possible way: it asked `existsSync(db_path)`, the jail deliberately does // not bind celilo's data directory, and so it reported a healthy fleet's // database MISSING and failed the deploy over a file that was right there. // `runFleetChecks` is what `celilo system doctor` already runs and asks // eight questions where the hook asked two. if (moduleId === CONTROL_PLANE_MODULE_ID) { const checks = await controlPlaneHealthChecks(db); return finishHealthCheck(moduleId, module.state, checks, db, options); } const manifest = module.manifestData as ModuleManifest; const hookDef = manifest.hooks?.health_check; if (!hookDef) { return { moduleId, status: 'no-checks', checks: [], waiver: manifest.health_waiver }; } // Build config + secrets for hook context. The config map shape // (including the machine-IP fallback for machine deploys) lives in // a single helper so health_check, on_install/on_uninstall via // run-named-hook, and capability-loader all see the same thing. const configMap = await loadHookConfigMap(moduleId, db); const { secrets } = await import('../db/schema'); const secretRecords = db.select().from(secrets).where(eq(secrets.moduleId, moduleId)).all(); const masterKey = await getOrCreateMasterKey(); const secretMap: Record = {}; for (const s of secretRecords) { secretMap[s.name] = decryptSecret( { encryptedValue: s.encryptedValue, iv: s.iv, authTag: s.authTag }, masterKey, ); } const requiredCapabilities = manifest.requires.capabilities.map((c) => c.name); // Run the hook. Logger is constructed BEFORE loadCapabilityFunctions // so the auto-logging wrapper (HOOK_API_V2 D6) can capture it for // every capability call. const presentation = createHealthCheckPresentation(moduleId, options); const capabilityFunctions = await loadCapabilityFunctions(moduleId, db, presentation.logger); const hookResult: HookResult = await invokeHook( module.sourcePath, 'health_check', manifest.celilo_contract, hookDef, {}, configMap, secretMap, presentation.logger, { debug: options.debug ?? false, capabilities: capabilityFunctions, requiredCapabilities, systems: getModuleSystems(moduleId, db), remoteAccess: remoteAccessPolicy(moduleId, db), hookStores: () => createHookStores(db, moduleId), }, ); presentation.finish(hookResult.success); if (!hookResult.success) { // Artifacts matter MOST here: the hook died, so there are no named // checks to explain why, and whatever it managed to write is all the // operator gets. return { moduleId, status: 'error', checks: [], error: hookResult.error, artifactPaths: hookResult.artifactPaths, }; } // Parse structured output const outputs = hookResult.outputs as { status?: string; checks?: HealthCheckItem[]; }; const checks = outputs.checks || []; return finishHealthCheck( moduleId, module.state, checks, db, options, hookResult.artifactPaths, manifest.health_waiver, ); } /** * Derive the module-level verdict from the named checks. * * Pure so the folding rule is testable without a database or a hook * subprocess. `skip` checks are folded toward nothing measured: they are * neither pass nor fail, and a module whose checks all skipped is * `no-checks` — nothing ran that deserves the word healthy. */ export function deriveHealthStatus(checks: HealthCheckItem[]): HealthCheckResult['status'] { if (checks.some((c) => c.status === 'fail')) return 'unhealthy'; if (checks.some((c) => c.status === 'warn')) return 'degraded'; const measured = checks.filter((c) => c.status !== 'skip'); if (checks.length > 0 && measured.length === 0) return 'no-checks'; return 'healthy'; } /** * Derive the verdict from the named checks and move the module's state. * * Shared by the hook path and the control plane's own path so the two cannot * drift on what `degraded` means or on when a state transition is suppressed. */ function finishHealthCheck( moduleId: string, moduleState: ModuleState, checks: HealthCheckItem[], db: DbClient, options: HealthCheckOptions, artifactPaths?: string[], waiver?: HealthWaiver, ): HealthCheckResult { const status = deriveHealthStatus(checks); // Only a run that measured something may move lifecycle state. A module // whose checks all skipped (or that declared no hook) stays as it was — // `VERIFIED` asserts that a check was deliberately run and passed, and // nothing was measured here. const nextState = status === 'healthy' || status === 'degraded' || status === 'unhealthy' ? nextModuleState(moduleState, status, options.unattended ?? false) : null; if (nextState) { // Moving to VERIFIED/INSTALLED means the module just measured healthy (or // was freshly deployed). Clear a recorded failure with the state, so a // healthy row doesn't keep printing its old `Error:` line (celilo#1363). db.update(modules) .set({ state: nextState, errorMessage: null }) .where(eq(modules.id, moduleId)) .run(); } return { moduleId, status, checks, ...(artifactPaths ? { artifactPaths } : {}), ...(waiver ? { waiver } : {}), }; } /** * Cap on concurrent health checks. Picked small on purpose — the * waits are SSH-bound, but each invocation also spins up a hook * subprocess locally, and an unbounded fan-out can swamp a low-spec * machine or a slow link. Four lets us overlap most network waits * without thrashing. */ const HEALTH_CHECK_CONCURRENCY = 4; /** * Run `worker` over `items`, with at most `cap` in flight at a time. * Results land in input order. `worker` errors are wrapped by the * caller — this helper does not catch them itself. */ async function mapConcurrent( items: T[], cap: number, worker: (item: T) => Promise, ): Promise { const results = new Array(items.length); let cursor = 0; async function pump(): Promise { while (cursor < items.length) { const idx = cursor++; results[idx] = await worker(items[idx]); } } const workers = Array.from({ length: Math.min(cap, items.length) }, pump); await Promise.all(workers); return results; } /** * Run health checks for all deployed modules. * * Parallelizes per-module checks with a small concurrency cap so SSH * waits overlap. Per-module failures are converted into `error` * results so one bad module doesn't prevent siblings from being * audited. */ export async function runAllHealthChecks( db: DbClient, options: { debug?: boolean; onProgress?: (msg: string) => void; quiet?: boolean } = {}, ): Promise { const eligible = db .select() .from(modules) .all() .filter((m) => ['INSTALLED', 'VERIFIED'].includes(m.state)); // Wrap onProgress with a "done N/M" counter so the TUI surfaces // overall progress instead of flickering between the latest of // four concurrent in-flight checks. const total = eligible.length; let done = 0; const wrappedOnProgress = options.onProgress ? (msg: string) => { options.onProgress?.(`${msg} (${done}/${total} done)`); } : undefined; const childOptions = { ...options, onProgress: wrappedOnProgress }; return mapConcurrent(eligible, HEALTH_CHECK_CONCURRENCY, async (module) => { try { const result = await runModuleHealthCheck(module.id, db, childOptions); done++; // Emit one final "N/M done" tick after each module completes, // so progress moves even when no new check is starting. wrappedOnProgress?.(`Checked ${module.id}`); return result; } catch (err) { done++; return { moduleId: module.id, status: 'error', checks: [], error: err instanceof Error ? err.message : String(err), }; } }); }