90 lines
6.5 KiB
JavaScript
90 lines
6.5 KiB
JavaScript
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import crypto from 'node:crypto';
|
|
import { performance } from 'node:perf_hooks';
|
|
|
|
const sleep = milliseconds => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds);
|
|
|
|
// Synchronous so the lifecycle lock remains held through startup/readiness.
|
|
// Adapters also allow deterministic testing without real time or Docker.
|
|
export function waitForHealth({ inspect, probe, timeoutSeconds = 120, stableSeconds = 10, now = () => performance.now(), pause = sleep, startedAtMs }) {
|
|
if (!Number.isSafeInteger(timeoutSeconds) || timeoutSeconds < 1 || !Number.isSafeInteger(stableSeconds) || stableSeconds < 1 || stableSeconds >= timeoutSeconds) throw new Error('Health timeout must exceed positive stability window');
|
|
const started = startedAtMs ?? now(), deadline = started + timeoutSeconds * 1000;
|
|
let identity, restarts, startedAt, healthySince, lastState = null, lastProbe = null;
|
|
const result = (ok, reason) => ({ ok, reason, elapsed_ms: Math.round(now() - started), timeout_seconds: timeoutSeconds, stable_seconds: stableSeconds, state: lastState, probe: lastProbe });
|
|
const budget = () => Math.max(1, Math.ceil(deadline - now()));
|
|
try {
|
|
while (now() < deadline) {
|
|
const container = inspect(budget());
|
|
if (!container) return result(false, 'missing-container');
|
|
lastState = { id: container.Id, status: container.State?.Status, running: container.State?.Running, restarting: container.State?.Restarting, paused: container.State?.Paused, dead: container.State?.Dead, exit_code: container.State?.ExitCode, oom_killed: container.State?.OOMKilled, error: container.State?.Error, restart_count: container.RestartCount, started_at: container.State?.StartedAt, health: container.State?.Health?.Status };
|
|
if (identity === undefined) { identity = container.Id; restarts = container.RestartCount; startedAt = container.State?.StartedAt; }
|
|
if (!identity || !Number.isSafeInteger(restarts) || restarts < 0) return result(false, 'invalid-container-state');
|
|
if (identity !== container.Id) return result(false, 'container-replaced');
|
|
if (container.State?.Restarting || container.RestartCount !== restarts || container.State?.StartedAt !== startedAt) return result(false, 'restart-loop');
|
|
if (!container.State?.Running || container.State?.Dead || ['exited', 'dead', 'removing'].includes(container.State?.Status)) return result(false, 'crash');
|
|
if (container.State?.Paused) return result(false, 'paused');
|
|
if (!container.State?.Health) return result(false, 'missing-healthcheck');
|
|
if (now() >= deadline) break;
|
|
if (container.State.Health.Status === 'healthy') {
|
|
lastProbe = probe(container.Id, budget());
|
|
if (now() >= deadline) break;
|
|
if (lastProbe.status === 0) {
|
|
healthySince ??= now();
|
|
if (now() - healthySince >= stableSeconds * 1000) return result(true, 'healthy');
|
|
} else healthySince = undefined;
|
|
} else healthySince = undefined;
|
|
pause(Math.min(1000, Math.max(0, deadline - now())));
|
|
}
|
|
return result(false, 'timeout');
|
|
} catch (error) {
|
|
lastProbe = { ...lastProbe, error: error.message };
|
|
return result(false, now() >= deadline ? 'timeout' : 'inspection-error');
|
|
}
|
|
}
|
|
|
|
export function saveHealthReport({ root, project, releaseIdentity, result, logs }) {
|
|
const directory = path.join(root, 'diagnostics');
|
|
if (fs.existsSync(directory) && (!fs.lstatSync(directory).isDirectory() || fs.lstatSync(directory).isSymbolicLink())) throw new Error('Unsafe diagnostics directory');
|
|
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
const id = `${new Date().toISOString().replace(/[-:.]/g, '')}-${crypto.randomUUID()}`;
|
|
const reportPath = path.join(directory, `health-${id}.json`), logsPath = path.join(directory, `health-${id}.log`);
|
|
let backup = null;
|
|
try { backup = JSON.parse(fs.readFileSync(path.join(root, '.mcpack-backup-operation.json'), 'utf8')).snapshot ?? null; } catch { /* No prior backup. */ }
|
|
const report = { schema_version: 1, created_at: new Date().toISOString(), project, release_manifest_sha256: releaseIdentity, ...result, recovery: { runtime_preserved: true, release_binding_preserved: true, active_release_changed: false, automatic_rollback: false, backup_snapshot: backup }, logs: logsPath };
|
|
fs.writeFileSync(logsPath, logs, { mode: 0o600, flag: 'wx' });
|
|
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2) + '\n', { mode: 0o600, flag: 'wx' });
|
|
return { reportPath, logsPath };
|
|
}
|
|
|
|
export function healthOptions(args, defaultTimeout = 120) {
|
|
args = args.flatMap(item => {
|
|
const match = /^(--wait-timeout|--stable-seconds)=(.*)$/.exec(item);
|
|
if (item.startsWith('--wait=')) throw new Error('Use --wait without a value');
|
|
return match ? [match[1], match[2]] : [item];
|
|
});
|
|
const command = args[0], wait = command === 'health' || args.includes('--wait');
|
|
let timeoutSeconds = defaultTimeout, stableSeconds = 10;
|
|
const seen = new Set(), forwarded = [command === 'health' ? 'ps' : command];
|
|
for (let index = 1; index < args.length; index++) {
|
|
const item = args[index];
|
|
if (['--wait', '--wait-timeout', '--stable-seconds'].includes(item)) {
|
|
if (seen.has(item)) throw new Error(`Repeated health argument: ${item}`);
|
|
seen.add(item);
|
|
if (item === '--wait') continue;
|
|
const value = args[++index];
|
|
if (!/^[1-9]\d*$/.test(value ?? '') || !Number.isSafeInteger(Number(value))) throw new Error(`Invalid ${item}`);
|
|
if (item === '--wait-timeout') timeoutSeconds = Number(value); else stableSeconds = Number(value);
|
|
} else {
|
|
if (command === 'health') throw new Error(`Unexpected health argument: ${item}`);
|
|
forwarded.push(item);
|
|
}
|
|
}
|
|
if (seen.size && !wait) throw new Error('Health options require --wait');
|
|
if (wait && !['up', 'start', 'restart', 'health'].includes(command)) throw new Error('--wait supports up/start/restart/health only');
|
|
if (wait && stableSeconds >= timeoutSeconds) throw new Error('Health timeout must exceed stability window; use --stable-seconds');
|
|
if (wait && command === 'up' && (forwarded.includes('--abort-on-container-exit') || forwarded.includes('--abort-on-container-failure') || forwarded.includes('--attach') || forwarded.includes('--attach-dependencies') || forwarded.includes('--exit-code-from'))) throw new Error('Health startup requires detached Compose');
|
|
if (wait && command === 'up' && !forwarded.includes('-d') && !forwarded.includes('--detach')) forwarded.push('-d');
|
|
return { wait, timeoutSeconds, stableSeconds, forwarded };
|
|
}
|