#!/usr/bin/env node // [impl->REQ-DEP-04] // tools/scripts/lint-deploy-stack.mjs // Source: 05-RESEARCH.md "lint-deploy-stack.mjs skeleton" (lines 757-784) // + 05-PATTERNS.md §"tools/scripts/lint-deploy-stack.mjs" // Drift guard for Phase 5 deploy-stack invariants. Refuses silent infra drift via PR. // Usage: node tools/scripts/lint-deploy-stack.mjs // Exit: 0 in-sync, 1 drift detected. import { readFileSync, existsSync } from 'node:fs'; const errors = []; const must = (cond, msg) => { if (!cond) errors.push(msg); }; // --- fly.{staging,prod}.toml --- const flyStaging = readFileSync('apps/server/fly.staging.toml', 'utf-8'); const flyProd = readFileSync('apps/server/fly.prod.toml', 'utf-8'); // Rule 1: fly.staging.toml MUST contain STAGING_MODE = "1" must(flyStaging.includes('STAGING_MODE = "1"'), 'fly.staging.toml: missing STAGING_MODE = "1"'); // Rule 2: fly.prod.toml MUST NOT contain STAGING_MODE must(!flyProd.includes('STAGING_MODE'), 'fly.prod.toml: MUST NOT contain STAGING_MODE'); // Rule 3: fly.staging.toml MUST contain auto_stop_machines = "off" must(flyStaging.includes('auto_stop_machines = "off"'), 'fly.staging.toml: auto_stop_machines must be "off"'); // Rule 4: fly.prod.toml MUST contain auto_stop_machines = "off" must(flyProd.includes('auto_stop_machines = "off"'), 'fly.prod.toml: auto_stop_machines must be "off"'); // Rule 5: Both fly.* MUST contain min_machines_running = 1 must(/min_machines_running\s*=\s*1/.test(flyStaging), 'fly.staging.toml: min_machines_running must be 1'); must(/min_machines_running\s*=\s*1/.test(flyProd), 'fly.prod.toml: min_machines_running must be 1'); // Rule 6: Both fly.* MUST contain path = "/health" must(flyStaging.includes('path = "/health"'), 'fly.staging.toml: missing path = "/health"'); must(flyProd.includes('path = "/health"'), 'fly.prod.toml: missing path = "/health"'); // Rule 7: Both fly.* MUST contain rebno-obs.flycast:5080/api/default (Pitfall 5 OTLP path) must(flyStaging.includes('rebno-obs.flycast:5080/api/default'), 'fly.staging.toml: OTEL endpoint must be flycast + /api/default'); must(flyProd.includes('rebno-obs.flycast:5080/api/default'), 'fly.prod.toml: OTEL endpoint must be flycast + /api/default'); // Rule 8: Both fly.* MUST NOT contain *.fly.dev as OTEL endpoint (negative — OTLP is flycast-private) must(!/OTEL_EXPORTER_OTLP_ENDPOINT.*\.fly\.dev/.test(flyStaging), 'fly.staging.toml: OTEL endpoint must NOT use *.fly.dev (use flycast)'); must(!/OTEL_EXPORTER_OTLP_ENDPOINT.*\.fly\.dev/.test(flyProd), 'fly.prod.toml: OTEL endpoint must NOT use *.fly.dev (use flycast)'); // --- Dockerfile --- const dockerfile = readFileSync('apps/server/Dockerfile', 'utf-8'); // Rule 9: Dockerfile MUST contain node:22-bookworm-slim (Pitfall 1) must(dockerfile.includes('node:22-bookworm-slim'), 'Dockerfile: must use node:22-bookworm-slim base (Pitfall 1)'); // Rule 10: Dockerfile MUST contain FROM litestream/litestream: must(/FROM litestream\/litestream:[\d.]+/.test(dockerfile), 'Dockerfile: must pin Litestream version (FROM litestream/litestream:X.Y.Z)'); // Rule 11: Dockerfile MUST contain dumb-init (Pitfall 2) must(dockerfile.includes('dumb-init'), 'Dockerfile: must include dumb-init (Pitfall 2)'); // Rule 12: Dockerfile MUST contain USER node must(dockerfile.includes('USER node'), 'Dockerfile: must drop privileges via USER node'); // --- .dockerignore --- const di = readFileSync('apps/server/.dockerignore', 'utf-8'); // Rule 13: .dockerignore MUST contain legacy AND localList.txt (T-DEP-01) must(di.split('\n').some(l => l.trim() === 'legacy' || l.trim() === 'legacy/'), '.dockerignore: must exclude legacy/'); must(di.includes('localList.txt'), '.dockerignore: must exclude localList.txt'); // --- litestream.yml --- const litestream = readFileSync('apps/server/litestream.yml', 'utf-8'); // Rule 14: litestream.yml MUST contain sync-interval: 1s (DEP-03 RPO) must(litestream.includes('sync-interval: 1s'), 'litestream.yml: sync-interval must be 1s for DEP-03 RPO'); // Rule 15: litestream.yml MUST contain type: s3 must(litestream.includes('type: s3'), 'litestream.yml: replica type must be s3'); // Rule 16: Defense-in-depth — no literal credentials must(!/AKIA[0-9A-Z]{16}/.test(litestream), 'litestream.yml: literal AWS access key detected — must use ${AWS_ACCESS_KEY_ID}'); must(!/secret_access_key\s*:\s*['"][A-Za-z0-9/+=]{20,}['"]/i.test(litestream), 'litestream.yml: literal secret detected — must use ${...}'); // --- docker-entrypoint.sh --- const entrypoint = readFileSync('apps/server/docker-entrypoint.sh', 'utf-8'); // Rule 19: docker-entrypoint.sh MUST contain exec "$@" (Pitfall 2 — SIGTERM delivery) must(entrypoint.includes('exec "$@"'), 'docker-entrypoint.sh: missing exec "$@" (Pitfall 2 — SIGTERM delivery)'); // Rule 20: docker-entrypoint.sh MUST run the Drizzle migrations script (D-09 BLOCKING). // Original wording demanded the built JS path; the Dockerfile (Plan 05-01) chose to // run the workspace via tsx end-to-end and never compiles a dist/ tree, so the // canonical invocation is `tsx ... scripts/run-migrations.ts`. Either form satisfies // the BLOCKING-migrate intent. must( entrypoint.includes('node dist/scripts/run-migrations.js') || /tsx[^\n]+scripts\/run-migrations\.ts/.test(entrypoint), 'docker-entrypoint.sh: missing run-migrations.{js,ts} call (D-09 BLOCKING)', ); // --- .github/workflows/deploy-{staging,prod}.yml (Rule 21+22: SHA-pin forcing function) --- // Skip cleanly if Plans 05/06 haven't landed yet. const flyctlPin = /superfly\/flyctl-actions\/setup-flyctl@[a-f0-9]{7,40}/; const flyctlBadRef = /superfly\/flyctl-actions\/setup-flyctl@(master|main|v\d)/; if (existsSync('.github/workflows/deploy-staging.yml')) { const wf = readFileSync('.github/workflows/deploy-staging.yml', 'utf-8'); // Rule 21 must(flyctlPin.test(wf), 'deploy-staging.yml: superfly/flyctl-actions/setup-flyctl must be SHA-pinned (7-40 hex chars)'); must(!flyctlBadRef.test(wf), 'deploy-staging.yml: superfly/flyctl-actions/setup-flyctl must NOT use @master/@main/@vN — pin to SHA'); } if (existsSync('.github/workflows/deploy-prod.yml')) { const wf = readFileSync('.github/workflows/deploy-prod.yml', 'utf-8'); // Rule 22 must(flyctlPin.test(wf), 'deploy-prod.yml: superfly/flyctl-actions/setup-flyctl must be SHA-pinned (7-40 hex chars)'); must(!flyctlBadRef.test(wf), 'deploy-prod.yml: superfly/flyctl-actions/setup-flyctl must NOT use @master/@main/@vN — pin to SHA'); } // --- apps/obs/* (Plan 09 outputs; skip when not yet created) --- if (existsSync('apps/obs/fly.toml')) { const obsFly = readFileSync('apps/obs/fly.toml', 'utf-8'); // Rule 17 must(obsFly.includes('app = "rebno-obs"'), 'apps/obs/fly.toml: app must be "rebno-obs"'); must(obsFly.includes('primary_region = "lax"'),'apps/obs/fly.toml: primary_region must be "lax"'); must(obsFly.includes('internal_port = 5080'), 'apps/obs/fly.toml: internal_port must be 5080'); } if (existsSync('apps/obs/Dockerfile')) { const obsDf = readFileSync('apps/obs/Dockerfile', 'utf-8'); // Rule 18 must(obsDf.includes('FROM public.ecr.aws/zinclabs/openobserve:'), 'apps/obs/Dockerfile: must FROM public.ecr.aws/zinclabs/openobserve:'); } if (errors.length) { for (const e of errors) console.error('lint-deploy-stack:', e); process.exit(1); } console.log('lint-deploy-stack: OK');