/** * AI Software Factory — the PURE decision logic for the backup/DR health gate * (`scripts/backup-health.ts`). Extracted so the release-pipeline VERDICT can * be unit-tested without booting S3/OpenSearch (NFR-B1…B7: RPO ≤ 5 min, * RTO ≤ 1 h — the gate that fails the release when a backup stopped working). * * No I/O, no env reads, no top-level execution → safe to import from a test * (the live script keeps its top-level `main()`, which is exactly why the * logic lives here rather than in the script). `backup-health.ts` owns the * live S3/OpenSearch checks + env; it delegates EVERY verdict decision to * these functions so there is a single source of truth. * * The contract under test (documented in the script header + §8 runbook): * - REACHABLE-but-STALE marker → FAIL in EVERY mode (exit 1) * - AUTH error (403 / bad creds) → FAIL in EVERY mode (never silent) * - UNREACHABLE (dev laptop, no stack)→ SKIP, report-only, exit 0 (NOT a pass) * - UNREACHABLE (strict/staging) → FAIL, exit 1 (the stack is expected up) */ export interface CheckResult { label: string; status: "PASS" | "FAIL" | "SKIP"; detail: string; ageMs: number | null; } /** Milliseconds between an ISO timestamp and `now` (negative if in the future). */ export function ageMsSince(iso: string, now: Date): number { return now.getTime() - new Date(iso).getTime(); } /** Human age: whole minutes under an hour, one-decimal hours above it. */ export function fmtAge(ms: number): string { const min = Math.round(ms / 60000); if (min < 60) return `${min}m`; const h = ms / 3600000; return `${h.toFixed(1)}h`; } /** * A mis-credentialed backup identity is a real bug, never silent — in ANY * mode (a dev who mis-set the key should see a loud FAIL, not a quiet skip). * Matches the S3/OS error strings the live checks surface. */ export function isAuthError(msg: string): boolean { return /HTTP 403|SignatureDoesNotMatch|AccessDenied|InvalidAccessKey|InvalidClientTokenId|Credentials|unauthorized/i.test( msg, ); } /** * Classify a check error and record its verdict: * - AUTH → FAIL (every mode) * - unreachable → FAIL (strict/staging) | SKIP (dev, report-only) * The `strict` flag is the mode, threaded from the caller (the script's * module-level `STRICT`) so the same function is testable in both. */ export function recordUnavailable( results: CheckResult[], label: string, err: unknown, limit: string, strict: boolean, ): void { const msg = err instanceof Error ? err.message : String(err); if (isAuthError(msg)) { results.push({ label, status: "FAIL", ageMs: null, detail: `AUTH ERROR: ${msg} (limit ${limit})` }); return; } if (strict) { results.push({ label, status: "FAIL", ageMs: null, detail: `UNAVAILABLE (strict): ${msg} (limit ${limit})` }); return; } results.push({ label, status: "SKIP", ageMs: null, detail: `UNAVAILABLE — report-only skip (dev, NOT a pass): ${msg} (limit ${limit})`, }); } export interface Verdict { exitCode: number; message: string; failures: CheckResult[]; skips: CheckResult[]; } /** * The exit-code mapping (the gate's contract): * - any FAIL → exit 1 (a stopped backup is a hard release failure) * - SKIPs only→ exit 0 (dev, report-only; loud warning, never a pass) * - all PASS → exit 0 * `message` is the `backup-health: ` tail the script prints. */ export function verdictFor(results: CheckResult[]): Verdict { const failures = results.filter((r) => r.status === "FAIL"); const skips = results.filter((r) => r.status === "SKIP"); if (failures.length > 0) { return { exitCode: 1, message: `FAIL — ${failures.length} check(s) not healthy`, failures, skips, }; } if (skips.length > 0) { return { exitCode: 0, message: `OK with ${skips.length} report-only skip(s) (unreachable — dev mode). No failures.`, failures, skips, }; } return { exitCode: 0, message: `OK — all ${results.length} checks healthy`, failures, skips, }; }