P0 fix: content/ not in image crashed API boot; harden garnish + preflight
ROOT CAUSE: the Dockerfile copied src/poller/scripts/supabase but NOT content/. mediaEngine.js read content/stark-lines.json with an unguarded module-load readFileSync; ENOENT in the image threw at require time, and via app.js → routes/desk → deskService → mediaEngine that crashed the ENTIRE API at boot. The Coolify healthcheck rolled back to the last healthy image (4d2b27d), so every deploy since219167esilently served a 14-hour-old build — S11 live tracking, S6 API code, the settlement boot line, and SNAPSHOT_EXPECTED_INTERVAL were all merged but NOT running. FIX (one train): 1. Dockerfile COPYs content/ into the runner image. 2. mediaEngine: stark-lines.json is OPTIONAL (garnish, never load-bearing) — loadStark() try/catch → {} → posts render without the Stark kicker, never a crash. Belt AND suspenders with #1. 3. src/preflight.js (§A4): boot prints '[preflight] OK' or 'DEGRADED' naming exactly what content/env is missing — before the healthcheck can fail silently. Run first in server.js. 4. Full fragility sweep: mediaEngine was the ONLY unguarded module-load file read; coachSignals (config/coaches.json) was already lazy + try/catch + copied. No others. Verified: requiring app.js + deskService + mediaEngine with stark-lines.json ABSENT now boots clean (reproduced the exact prod failure). 2757 -> 2763 tests (tests/unit/bootResilience.test.js). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* preflight — boot visibility (Session 65, Standard §A4).
|
||||
*
|
||||
* A healthcheck that only pings /api/health can't tell "booted degraded"
|
||||
* from "crashed and rolled back" — which is exactly how a missing garnish
|
||||
* file (content/stark-lines.json) served a 14-hour-old image silently. This
|
||||
* runs once at boot and prints ONE unambiguous line: `[preflight] OK` when
|
||||
* everything required is present, or `[preflight] DEGRADED` naming exactly
|
||||
* what is missing. It NEVER throws and NEVER blocks boot — its only job is
|
||||
* to make the container's state legible in the logs.
|
||||
*
|
||||
* Philosophy (matches the app's own doctrine): content garnish missing is
|
||||
* NOTED, not fatal (absent beats crashed). Missing critical env is named so
|
||||
* an operator sees it immediately instead of debugging a symptom later.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Optional content — the app runs fine without these; we just name them so a
|
||||
// missing one is visible (and never silently crashes a require chain again).
|
||||
const OPTIONAL_CONTENT = [
|
||||
{ label: 'stark-lines.json', file: path.join(__dirname, '..', 'content', 'stark-lines.json'), note: 'media posts drop the Stark kicker' },
|
||||
{ label: 'coaches.json', file: path.join(__dirname, 'config', 'coaches.json'), note: 'coach-impact feature uses no seed' },
|
||||
];
|
||||
|
||||
// Critical env — the app boots without these but key surfaces degrade; named
|
||||
// loudly so an operator fixes them before hunting a downstream symptom.
|
||||
const CRITICAL_ENV = ['SUPABASE_URL', 'REDIS_URL', 'VYNDR_INTERNAL_KEY'];
|
||||
// Either service-role name satisfies the Supabase write path.
|
||||
const ENV_EITHER = [['SUPABASE_SERVICE_ROLE_KEY', 'SUPABASE_SERVICE_KEY']];
|
||||
|
||||
function runPreflight(log = console) {
|
||||
try {
|
||||
const missingContent = [];
|
||||
for (const c of OPTIONAL_CONTENT) {
|
||||
let ok = false;
|
||||
try { fs.accessSync(c.file, fs.constants.R_OK); ok = true; } catch { ok = false; }
|
||||
if (!ok) missingContent.push(`${c.label} (${c.note})`);
|
||||
}
|
||||
|
||||
const missingEnv = CRITICAL_ENV.filter((k) => !process.env[k]);
|
||||
for (const group of ENV_EITHER) {
|
||||
if (!group.some((k) => process.env[k])) missingEnv.push(group.join('|'));
|
||||
}
|
||||
|
||||
// Operational visibility for the pipeline knobs (not failures — states).
|
||||
const cron = process.env.SNAPSHOT_CRON === '1' ? 'armed' : 'off';
|
||||
const interval = Number(process.env.SNAPSHOT_EXPECTED_INTERVAL) > 0
|
||||
? `${process.env.SNAPSHOT_EXPECTED_INTERVAL}s` : 'default(18000s)';
|
||||
|
||||
if (missingContent.length === 0 && missingEnv.length === 0) {
|
||||
log.log(`[preflight] OK — content present, critical env present, snapshot cron ${cron}, expected-interval ${interval}`);
|
||||
} else {
|
||||
log.warn(`[preflight] DEGRADED — snapshot cron ${cron}, expected-interval ${interval}`);
|
||||
if (missingEnv.length) log.warn(`[preflight] missing env: ${missingEnv.join(', ')}`);
|
||||
if (missingContent.length) log.warn(`[preflight] missing content (non-fatal): ${missingContent.join(', ')}`);
|
||||
}
|
||||
return { ok: missingContent.length === 0 && missingEnv.length === 0, missingContent, missingEnv };
|
||||
} catch (e) {
|
||||
// Preflight itself must never break boot.
|
||||
try { log.warn(`[preflight] check errored (non-fatal): ${e.message}`); } catch { /* ignore */ }
|
||||
return { ok: false, error: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { runPreflight, __internals: { OPTIONAL_CONTENT, CRITICAL_ENV, ENV_EITHER } };
|
||||
Reference in New Issue
Block a user