morphit/apps/web/scripts/cross-document-value-invariants-smoke.ts

464 lines
19 KiB
TypeScript

#!/usr/bin/env tsx
/**
* cross-document-value-invariants-smoke.
*
* Part 122 cp66 STRUCTURAL DEFENSE (LL #67 / O-16).
*
* Generalizes cp61-O14's value-cross-reference parity class.
*
* cp61-O14 caught a specific real bug: the BunkerWeb Docker network
* CIDR was pinned at 172.20.0.0/16 in the canonical compose but
* 172.18.0.0/16 in the Ansible group_vars default for trusted_proxy_ips.
* Default Ansible deploy → broken trusted-proxy chain → all signups
* bucket into one rate-limit slot. The structural pattern: ONE value
* lives in N files; if one file drifts, deploy breaks silently.
*
* Other repo values exhibit the same pattern. cp66 generalizes:
* each invariant declares a SOURCE OF TRUTH (file + extraction regex)
* and a list of CONSUMER files (each with its own regex/group).
* Drift in any consumer fires the smoke.
*
* Registered invariants (cp69):
* 1. postgres_db_name (init.sql → env.examples → ansible/group_vars)
* 2. postgres_user_name (init.sql → env.examples → ansible/group_vars)
* 3. postgres_port (ansible/group_vars → env.examples DATABASE_URL)
* 4. treasury_fee_account (indexer config default → operator-facing docs
* that name the default by string)
* 5. indexer_bind_port (ansible group_vars → bunkerweb frontend nginx.conf /v1/)
* 6. relay_bind_port (ansible group_vars → bunkerweb frontend nginx.conf /relay/)
* 7. bunkerweb_net_name (canonical compose → ansible role template +
* ansible verification task)
* 8. relay_listen_port_default (relay config Zod default → env.example + nginx)
* 9. indexer_listen_port_default (indexer config Zod default → env.example + nginx)
* 10. matrix_bot_healthcheck_port (matrix-bot config Zod default → env.example +
* ansible role env template) [cp69]
* 11. bunkerweb_cidr (canonical compose subnet → ansible default
* trusted_proxy_ips) [cp69; slim cousin of
* cp61-O14, which also checks docs]
*
* NOTE: 5-6 are the BunkerWeb-fronted bind ports and 8-9 are the bare-metal
* nginx-fronted listen ports. As of cp224 both deploy modes are unified on
* the code defaults (relay 8080 / indexer 8081), but they are still checked
* independently — the ansible group_vars bind port and the frontend nginx.conf
* proxy port must agree (5-6), and the Zod default and the bare-metal nginx
* upstream must agree (8-9) — so a drift in either path gets a distinct
* default depending on whether they `docker compose up` from ops/bunkerweb/
* or `apt install nginx` from ops/nginx/. Each set MUST be internally
* consistent.
*
* NOTE on bunkerweb_cidr (#11): cp61-O14 (bunkerweb-cidr-cross-reference-smoke)
* has richer doc-aware behavior — it checks PRE-LAUNCH-CHECKLIST, OPERATIONS,
* RUN-A-MORPHIT-NODE, BRAG-LIST, and ansible role templates for the canonical
* CIDR with proximity-to-keyword scoping. We add a slim invariant here to
* give the registry pattern uniform coverage; if drift happens, BOTH defenses
* fire — cp61-O14 with the rich diagnostic, cp66-O16 with the registry-shaped
* one. Keeping both is intentional: cp61-O14 is doc-aware (catches drift in
* operator-facing prose); cp66-O16 is config-aware (catches drift in ansible
* default). Different failure modes, complementary signals.
*
* Bug class this catches at pre-launch: an operator running a fresh
* `ops-cli init` followed by importing values from operator docs OR
* the Ansible default OR the env example finds them in tension —
* the deploy fails in a confusing way because one document drifted
* away from the others. cp66's smoke catches that BEFORE first-launch.
*
* Adding new invariants: append to the INVARIANTS array. Each entry
* must point at a single source-of-truth file whose value is canonical,
* and any number of consumer files that must agree. The smoke is
* registry-driven; new invariants slot in without changing the runner
* logic.
*
* Mutation tests:
* M-130: change init.sql DATABASE name to `morphit_other` → smoke
* fires "ops/env/indexer.env.example mentions 'morphit_indexer'
* but canonical postgres_db_name is 'morphit_other'".
* M-131: change ansible postgres_port to 6432 → smoke fires
* "ops/env/indexer.env.example DATABASE_URL port '5432' ≠
* canonical '6432'".
* M-132: change indexer config default fee_recipient to 'morphit-pool'
* while leaving docs at 'morphit-fees' → smoke fires.
*/
import { readFileSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const REPO_ROOT = join(__dirname, '..', '..', '..');
let failed = 0;
let passed = 0;
function pass(name: string): void {
console.log(`${name}`);
passed++;
}
function fail(name: string, detail: string): void {
console.error(`${name}`);
console.error(` ${detail}`);
failed++;
}
console.log('\n── cross-document-value-invariants smoke (cp66 LL #67 / O-16) ──\n');
interface Extraction {
/** File path relative to repo root. */
file: string;
/** Regex with at least one capture group; the captured value is what
* this file declares for the invariant. */
regex: RegExp;
/** Capture group index (default 1). */
group?: number;
/** Optional context tag: e.g. "DATABASE_URL", "ansible default". Used
* in diagnostics so the operator knows which line drifted. */
context?: string;
}
interface Invariant {
name: string;
description: string;
source: Extraction;
consumers: Extraction[];
}
const INVARIANTS: Invariant[] = [
{
name: 'postgres_db_name',
description:
'Postgres database name — defined by init.sql; consumed by both env.example DATABASE_URLs and ansible group_vars',
source: {
file: 'ops/postgres/init.sql',
regex: /CREATE DATABASE\s+(\w+)/,
context: 'init.sql CREATE DATABASE',
},
consumers: [
{
file: 'ops/env/indexer.env.example',
regex: /MORPHIT_INDEXER_DATABASE_URL=postgresql:\/\/[^@]+@[^/]+\/(\w+)/,
context: 'indexer env DATABASE_URL path',
},
{
file: 'ops/env/relay.env.example',
regex: /MORPHIT_RELAY_DATABASE_URL=postgresql:\/\/[^@]+@[^/]+\/(\w+)/,
context: 'relay env DATABASE_URL path',
},
{
file: 'ops/ansible/group_vars/all.yml',
regex: /^postgres_indexer_db:\s*(\w+)/m,
context: 'ansible postgres_indexer_db',
},
],
},
{
name: 'postgres_user_name',
description:
'Postgres role/user name — defined by init.sql; consumed by env.example DATABASE_URLs and ansible group_vars',
source: {
file: 'ops/postgres/init.sql',
regex: /CREATE ROLE\s+(\w+)\s+LOGIN/,
context: 'init.sql CREATE ROLE',
},
consumers: [
{
file: 'ops/env/indexer.env.example',
regex: /MORPHIT_INDEXER_DATABASE_URL=postgresql:\/\/(\w+):/,
context: 'indexer env DATABASE_URL user',
},
{
file: 'ops/env/relay.env.example',
regex: /MORPHIT_RELAY_DATABASE_URL=postgresql:\/\/(\w+):/,
context: 'relay env DATABASE_URL user',
},
{
file: 'ops/ansible/group_vars/all.yml',
regex: /^postgres_indexer_user:\s*(\w+)/m,
context: 'ansible postgres_indexer_user',
},
],
},
{
name: 'postgres_port',
description:
'Postgres listener port — defined by ansible/group_vars (the canonical operator default); consumed by env.example DATABASE_URLs',
source: {
file: 'ops/ansible/group_vars/all.yml',
regex: /^postgres_port:\s*(\d+)/m,
context: 'ansible postgres_port',
},
consumers: [
{
file: 'ops/env/indexer.env.example',
regex: /MORPHIT_INDEXER_DATABASE_URL=postgresql:\/\/[^@]+@[^:]+:(\d+)\//,
context: 'indexer env DATABASE_URL port',
},
{
file: 'ops/env/relay.env.example',
regex: /MORPHIT_RELAY_DATABASE_URL=postgresql:\/\/[^@]+@[^:]+:(\d+)\//,
context: 'relay env DATABASE_URL port',
},
],
},
{
name: 'treasury_fee_account',
description:
'Treasury fee-recipient account — defined by CANONICAL_TREASURY (apps/indexer/src/config/canonicalTreasury.ts), the cp315 single source of truth that the indexer config Zod default references; consumed by indexer code/docs that name the account by string',
source: {
file: 'apps/indexer/src/config/canonicalTreasury.ts',
regex: /blurt:\s*'([^']+)'/,
context: 'CANONICAL_TREASURY.blurt single source of truth',
},
// Docs that NAME the default account by string MUST match. Docs
// that talk about "the operator's fee account" abstractly (no
// quoted name) don't appear here — they're not making a claim
// about the canonical default.
//
// IMPORTANT: docs reference the account WITH the `@` prefix
// (`@morphit-fees`) because that's how Blurt accounts are
// written in prose, but the config default value is the raw
// account name (`morphit-fees`). The smoke uses a relaxed
// pattern that matches either form.
//
// Both files contain multiple operator-account references
// (e.g. `@morphit-relay` AND `@morphit-fees`). Each consumer
// regex picks out the TREASURY-specific mention by its semantic
// context — "accumulates" for the balance-scanner doc, "to="
// for the strangerFee transfer destination — so a rename of
// the relay account doesn't falsely fire this invariant.
consumers: [
{
file: 'apps/indexer/src/indexer/operatorAccountBalanceScanner.ts',
// Treasury account is the one that "accumulates" (the
// relay account "drains" as it funds welcome bonuses).
regex: /@(morphit-\w+)\s+typically accumulates/,
context: 'balance-scanner: treasury accumulates',
},
{
file: 'apps/indexer/src/indexer/handlers/strangerFee.ts',
// Stranger-fee transfers always have `to=@morphit-fees`
// as the destination (the treasury); never the relay
// account, which has no role in fee receipt.
regex: /to=@(morphit-\w+),/,
context: 'strangerFee transfer destination',
},
],
},
{
name: 'indexer_bind_port',
description:
"Indexer HTTP bind port — defined by ansible/group_vars (unified to the code default 8081); consumed by ops/bunkerweb/frontend/nginx.conf's /v1/ proxy_pass (the frontend nginx reverse-proxies the indexer at this port behind BunkerWeb). If they drift, the frontend 502s the indexer service.",
source: {
file: 'ops/ansible/group_vars/all.yml',
regex: /^morphit_indexer_bind_port:\s*(\d+)/m,
context: 'ansible indexer bind_port default',
},
consumers: [
{
file: 'ops/bunkerweb/frontend/nginx.conf',
regex: /location \/v1\/ \{[\s\S]*?proxy_pass http:\/\/host\.docker\.internal:(\d+)/,
context: 'bunkerweb frontend nginx.conf /v1/ proxy_pass (indexer)',
},
],
},
{
name: 'relay_bind_port',
description:
"Relay HTTP bind port — defined by ansible/group_vars (unified to the code default 8080); consumed by ops/bunkerweb/frontend/nginx.conf's /relay/ proxy_pass (the frontend nginx reverse-proxies the relay at this port behind BunkerWeb). If they drift, the frontend 502s the relay service.",
source: {
file: 'ops/ansible/group_vars/all.yml',
regex: /^morphit_relay_bind_port:\s*(\d+)/m,
context: 'ansible relay bind_port default',
},
consumers: [
{
file: 'ops/bunkerweb/frontend/nginx.conf',
regex: /location \/relay\/ \{[\s\S]*?proxy_pass http:\/\/host\.docker\.internal:(\d+)/,
context: 'bunkerweb frontend nginx.conf /relay/ proxy_pass (relay)',
},
],
},
{
name: 'bunkerweb_net_name',
description:
"BunkerWeb Docker network name — defined by ops/bunkerweb/docker-compose.yml; consumed by ansible bunkerweb role's docker-compose template (same network must coexist) AND by ansible task that inspects the network by name (`docker network inspect <name>`). Drift class: rename the network in the canonical compose, forget the inspect task → ansible verification step fails with 'no such network'.",
source: {
file: 'ops/bunkerweb/docker-compose.yml',
// The canonical compose pins the network with both an entry
// key under `networks:` AND a `name:` line. We use the
// explicit `name:` so renames detected unambiguously.
regex: /^networks:[\s\S]*?^\s+(\w+):\s*\n\s+name:\s+(\w+)/m,
group: 2,
context: 'canonical compose networks.<key>.name',
},
consumers: [
{
file: 'ops/ansible/roles/bunkerweb/templates/docker-compose.yml.j2',
regex: /^networks:[\s\S]*?^\s+(\w+):\s*\n\s+name:\s+(\w+)/m,
group: 2,
context: 'ansible bunkerweb role compose template',
},
{
file: 'ops/ansible/roles/bunkerweb/tasks/main.yml',
regex: /docker network inspect (\w+)/,
context: 'ansible bunkerweb verification task',
},
],
},
{
name: 'relay_listen_port_default',
description:
"Relay HTTP listen port (bare-metal deploy default) — defined by apps/relay/src/config/index.ts Zod default; consumed by env.example default + nginx reverse-proxy upstream. If they drift, the bare-metal nginx deploy 502s. As of cp224 this equals relay_bind_port (the BunkerWeb-fronted path is unified on the same 8080 default), but the two are still checked independently against their own consumers.",
source: {
file: 'apps/relay/src/config/index.ts',
regex: /MORPHIT_RELAY_LISTEN_PORT:[^,;\n]*\.default\((\d+)\)/,
context: 'relay config Zod default',
},
consumers: [
{
file: 'ops/env/relay.env.example',
regex: /^MORPHIT_RELAY_LISTEN_PORT=(\d+)/m,
context: 'relay env.example default value',
},
{
file: 'ops/nginx/relay.conf',
regex: /proxy_pass\s+http:\/\/127\.0\.0\.1:(\d+);/,
context: 'nginx relay.conf proxy_pass port',
},
],
},
{
name: 'indexer_listen_port_default',
description:
"Indexer HTTP listen port (bare-metal deploy default) — defined by apps/indexer/src/config/index.ts Zod default; consumed by env.example default + nginx upstream server. If they drift, the bare-metal nginx deploy 502s. As of cp224 this equals indexer_bind_port (the BunkerWeb-fronted path is unified on the same 8081 default), but the two are still checked independently against their own consumers.",
source: {
file: 'apps/indexer/src/config/index.ts',
regex: /MORPHIT_INDEXER_LISTEN_PORT:[^,;\n]*\.default\((\d+)\)/,
context: 'indexer config Zod default',
},
consumers: [
{
file: 'ops/env/indexer.env.example',
regex: /^MORPHIT_INDEXER_LISTEN_PORT=(\d+)/m,
context: 'indexer env.example default value',
},
{
file: 'ops/nginx/indexer.conf',
regex: /server\s+127\.0\.0\.1:(\d+);/,
context: 'nginx indexer.conf upstream server port',
},
],
},
{
name: 'matrix_bot_healthcheck_port',
description:
"matrix-bot systemd healthcheck loopback port — defined by apps/matrix-bot/src/config.ts Zod default; consumed by env.example default-value comments + ansible role env template. If they drift, the operator's healthcheck wrapper hits the wrong port → false-down alarms even though the bot is fine. Loopback-only; no security surface, but operator trust depends on the healthcheck being accurate.",
source: {
file: 'apps/matrix-bot/src/config.ts',
regex: /MORPHIT_MATRIX_BOT_HEALTHCHECK_PORT:\s*z\.coerce[\s\S]*?\.default\((\d+)\)/,
context: 'matrix-bot config Zod default',
},
consumers: [
{
// Env example references the default in TWO places: the inline
// "Default: 9876." prose comment AND the commented-out
// `# MORPHIT_MATRIX_BOT_HEALTHCHECK_PORT=9876` line. The
// commented line is the canonical operator-facing value, so
// that's what we match.
file: 'ops/env/matrix-bot.env.example',
regex: /^#\s*MORPHIT_MATRIX_BOT_HEALTHCHECK_PORT=(\d+)/m,
context: 'matrix-bot env.example default value',
},
{
// Ansible role template references the variable but its value
// flows through `matrix_bot_healthcheck_port` in group_vars
// (which is unset → defaults to whatever the Zod schema picks).
// We assert the inline comment matches, since that's what an
// operator reading the template sees as the "default the bot
// uses today".
file: 'ops/ansible/roles/matrix_bot/templates/matrix-bot.env.j2',
regex: /Override the default healthcheck loopback port \((\d+)\)/,
context: 'ansible matrix-bot role env template comment',
},
],
},
{
name: 'bunkerweb_cidr',
description:
"BunkerWeb Docker network CIDR — defined by canonical compose's `subnet:` line; consumed by ansible group_vars's `morphit_relay_trusted_proxy_ips` (operator's default trusted-proxy chain). This is the SLIM cousin of cp61-O14 (bunkerweb-cidr-cross-reference-smoke), which ALSO checks docs + READMEs + PRE-LAUNCH-CHECKLIST. We keep both: cp61-O14 has doc-aware behavior with proximity-to-keyword scoping; cp66-O16 catches the same canonical→ansible-default mismatch with a registry-shaped diagnostic. If both fire on the same drift, the operator gets two helpful signals; if only the slim version fires (because doc references were sparse), cp61-O14's doc check still surfaces in cp66-O16.",
source: {
file: 'ops/bunkerweb/docker-compose.yml',
regex: /subnet:\s*([\d./]+)/,
context: 'canonical compose subnet',
},
consumers: [
{
file: 'ops/ansible/group_vars/all.yml',
regex: /^morphit_relay_trusted_proxy_ips:\s*"([^"]+)"/m,
context: 'ansible default trusted_proxy_ips',
},
],
},
];
function extractValue(ex: Extraction): string | null {
const path = join(REPO_ROOT, ex.file);
let src: string;
try {
src = readFileSync(path, 'utf-8');
} catch (e) {
fail(
`Read source file: ${ex.file}`,
`Could not read: ${(e as Error).message}`
);
return null;
}
const m = src.match(ex.regex);
if (!m) {
fail(
`Extract value from ${ex.file}`,
`Regex ${ex.regex} did not match (context: ${ex.context ?? 'n/a'})`
);
return null;
}
const groupIdx = ex.group ?? 1;
if (m[groupIdx] === undefined) {
fail(
`Extract value from ${ex.file}`,
`Regex matched but group ${groupIdx} undefined (context: ${ex.context ?? 'n/a'})`
);
return null;
}
return m[groupIdx];
}
for (const inv of INVARIANTS) {
console.log(`${inv.name}`);
console.log(` ${inv.description}`);
const canonical = extractValue(inv.source);
if (canonical === null) {
// Source extraction failed — fail() was already called inside
// extractValue, skip the consumers (we have no canonical to compare).
continue;
}
console.log(` SOURCE OF TRUTH: ${inv.source.file} → '${canonical}'`);
for (const c of inv.consumers) {
const consumed = extractValue(c);
if (consumed === null) continue;
if (consumed !== canonical) {
fail(
`${inv.name} consumer ${c.file} matches canonical`,
`${c.context ?? c.file} declares '${consumed}' but canonical (${inv.source.file}) is '${canonical}'. Both must agree or operators get a silently-broken deploy.`
);
} else {
pass(`${inv.name}: ${c.context ?? c.file} = '${consumed}' ✓`);
}
}
console.log('');
}
const total = passed + failed;
console.log(`${passed} passed, ${failed} failed (${total} total)`);
if (failed > 0) {
console.error('\ncross-document-value-invariants smoke FAILED');
process.exit(1);
}
console.log(`✓ all ${total} cross-document-value-invariants scenarios passed`);