morphit/apps/ops-cli/scripts/ansible-env-var-consumer-smoke.ts
Morphit Team 7c1c3bb332
Some checks failed
morphit-ci / TypeScript typecheck (sweep all workspaces) (push) Has been cancelled
morphit-ci / apps/web svelte-check (svelte-kit sync + svelte-aware tsc) (push) Has been cancelled
morphit-ci / Integration tests (real Postgres 16) (push) Has been cancelled
morphit-ci / ansible-lint (playbook quality gate) (push) Has been cancelled
morphit-ci / Smoke suite (run-smokes.sh, triple-pulse) (push) Has been cancelled
morphit-release / Build + publish release tarball (push) Has been cancelled
v1.11.1 — registration self-heal + data-fs disk health + offline-install RPC softening + node-health unification (cp707-cp710)
2026-08-11 17:38:41 -07:00

321 lines
13 KiB
TypeScript

#!/usr/bin/env tsx
/**
* ansible-env-var-consumer-smoke — verify that every env var
* declared in an Ansible `*.env.j2` template has a real consumer
* somewhere in the code or sidecar scripts.
*
* Pre-Part-122-cp5 the relay.env.j2 template shipped a
* `MORPHIT_RELAY_PASSPHRASE={{ morphit_relay_keystore_passphrase }}`
* line that NO code path consumed. The placeholder value
* (`CHANGE-ME-PASSPHRASE`) invited operators to leak their real
* passphrase to a 0640 disk file out of misplaced template-
* completionism — the encrypted-envelope design (ADR-0010 §4)
* had been silently bypassed. F13 fixed that one instance;
* this smoke prevents the class from recurring.
*
* cp131 HIGH-002 fix: pre-cp131 the smoke gated on `MORPHIT_*`
* prefix on both the template-side AND consumer-side regex.
* That let backup.env.j2 ship 5 dead env vars (AGE_RECIPIENT,
* REMOTE_DESTINATION, SSH_KEY, DB_HOST, DB_PORT) — exactly the
* F13 bug-class the smoke was supposed to catch. cp131 drops
* the prefix gate and widens the consumer scan to include
* ops/backup/*.sh, so any uppercase env-var declared in a
* template MUST appear in some consumer file.
*
* Rule: for every LITERAL `[A-Z][A-Z0-9_]+=...` line in a template
* (i.e. one where the variable NAME isn't Jinja-templated), the
* exact var name MUST appear somewhere in:
*
* - apps/<workspace>/src/**\/*.ts (production code)
* - apps/<workspace>/scripts/**\/*.ts (smoke + tooling)
* - ops/scripts/*.sh (sidecar scripts)
* - ops/scripts/lib/*.sh (shared sidecar helpers)
* - ops/backup/*.sh (backup script)
* - ops/ipfs/*.sh (IPFS release-hosting pin + setup scripts, v1.9.0)
* - ops/ddns/*.sh (dynamic-DNS update + setup scripts, cp596)
*
* Template lines where the var NAME itself is Jinja-templated
* (e.g. `MORPHIT_FAIL2BAN_{{ var_jail }}_CRITICAL=...`) are
* SKIPPED — we can't resolve dynamic names statically. These
* are documented dynamic-dispatch patterns; the consumer reads
* them via pattern construction.
*
* Comment lines in templates (starting with `#`) are skipped.
*
* Scenarios:
* 1. For every literal template var: a consumer reference exists.
* 2. Sanity meta-check: at least one template was scanned.
* 3. Sanity meta-check: at least one consumer file was scanned.
*
* Usage:
* tsx apps/ops-cli/scripts/ansible-env-var-consumer-smoke.ts
*/
import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
import { join } from 'node:path';
const REPO_ROOT = join(import.meta.dirname, '..', '..', '..');
const ANSIBLE_ROLES_DIR = join(REPO_ROOT, 'ops', 'ansible', 'roles');
const APPS_DIR = join(REPO_ROOT, 'apps');
const PACKAGES_DIR = join(REPO_ROOT, 'packages');
const OPS_SCRIPTS_DIR = join(REPO_ROOT, 'ops', 'scripts');
interface ScenarioResult {
readonly name: string;
readonly ok: boolean;
readonly detail?: string;
}
const results: ScenarioResult[] = [];
/** Recursively walk a directory and yield files matching the
* predicate. Skips node_modules and any dotted dir at the root. */
function walkFiles(root: string, pred: (path: string) => boolean): string[] {
if (!existsSync(root)) return [];
const out: string[] = [];
const stack: string[] = [root];
while (stack.length > 0) {
const dir = stack.pop()!;
for (const ent of readdirSync(dir, { withFileTypes: true })) {
if (ent.isDirectory()) {
if (ent.name === 'node_modules') continue;
if (ent.name.startsWith('.')) continue;
stack.push(join(dir, ent.name));
} else if (ent.isFile()) {
const full = join(dir, ent.name);
if (pred(full)) out.push(full);
}
}
}
return out;
}
/** Templates whose env-vars are consumed by an EXTERNAL process
* (e.g. a third-party container, vendored upstream binary) rather
* than by Morphit's own code. Vars from these templates are
* exempted from the "must have a consumer in apps/ or ops/scripts/"
* rule because the consumer literally isn't in our codebase — it's
* in the upstream image / binary we deploy.
*
* This keeps the smoke honest about Morphit's own surface (catches
* AGE_RECIPIENT-class dead vars in OUR scripts) without
* false-positiving on legitimate upstream-vendored env vars.
*
* Each entry MUST cite the consumer it documents — adding to this
* list without naming the upstream is a smell.
*/
const EXTERNAL_CONSUMER_TEMPLATES: ReadonlyMap<string, string> = new Map([
// BunkerWeb (bunkerity/bunkerweb container) consumes its env
// vars directly via the container's nginx-builder runtime.
// See https://docs.bunkerweb.io/latest/settings/
[
'ops/ansible/roles/bunkerweb/templates/bunkerweb.env.j2',
'bunkerity/bunkerweb container (upstream nginx-builder reads env directly)'
]
]);
/** Find every literal `[A-Z][A-Z0-9_]+=...` line in *.env.j2 templates.
* Returns a list of (templatePath, varName) pairs. */
function collectTemplateVars(): Array<{ template: string; varName: string; externallyConsumed: boolean }> {
const out: Array<{ template: string; varName: string; externallyConsumed: boolean }> = [];
const templates = walkFiles(ANSIBLE_ROLES_DIR, (p) => p.endsWith('.env.j2'));
for (const t of templates) {
const relPath = t.replace(REPO_ROOT + '/', '');
const externallyConsumed = EXTERNAL_CONSUMER_TEMPLATES.has(relPath);
const src = readFileSync(t, 'utf-8');
for (const rawLine of src.split('\n')) {
const line = rawLine.trim();
// Skip comments + blank lines.
if (line.length === 0 || line.startsWith('#')) continue;
// Match `X_Y_Z=...` where the LHS is purely literal
// (no Jinja). If the LHS contains `{{`, the var name
// is dynamic — skip.
//
// cp131 HIGH-002 fix: previously hard-gated on
// MORPHIT_* prefix, which silently let non-prefixed
// vars (AGE_RECIPIENT, REMOTE_DESTINATION, SSH_KEY,
// DB_HOST, DB_PORT, BACKUP_DIR, RETAIN_DAYS, DB_NAME,
// DB_USER, ...) through unchecked — exactly the F13
// bug-class this smoke was created to prevent.
const m = /^([A-Z][A-Z0-9_]*)\s*=/.exec(line);
if (!m) continue;
const varName = m[1]!;
// Sanity: did the LHS include Jinja? Our regex
// already rejected that because Jinja delimiters
// aren't in [A-Z0-9_]. Double-check defensively.
if (varName.includes('{{') || varName.includes('}}')) continue;
out.push({ template: relPath, varName, externallyConsumed });
}
}
return out;
}
/** Build the consumer-surface: union of every uppercase env-var
* token appearing in production code, scripts, or sidecars.
* Returns the set of var names found.
*
* cp131: widened from `MORPHIT_*` to all `[A-Z][A-Z0-9_]+` so
* non-prefixed vars (AGE_RECIPIENT, DB_HOST, RSYNC_ARGS, ...)
* are caught. */
function collectConsumerSurface(): { names: Set<string>; fileCount: number } {
const names = new Set<string>();
const tsPred = (p: string): boolean =>
(p.endsWith('.ts') || p.endsWith('.tsx') || p.endsWith('.js') || p.endsWith('.mjs')) &&
!p.includes('.d.ts');
const shPred = (p: string): boolean => p.endsWith('.sh');
const consumerFiles = [
...walkFiles(APPS_DIR, tsPred),
// packages/ — shared workspace libraries are real consumers too.
// cp708: @morphit/node-health reads MORPHIT_HEALTH_DISK_PATH (via
// resolveHealthDiskPath) on behalf of both the indexer and ops-cli,
// so the literal lives in packages/, not apps/.
...walkFiles(PACKAGES_DIR, tsPred),
// ops/scripts/ — sidecar shell scripts.
...walkFiles(OPS_SCRIPTS_DIR, shPred),
// ops/backup/ — backup script (cp131 added; previously
// skipped, which let AGE_RECIPIENT et al. slip past).
...walkFiles(join(REPO_ROOT, 'ops', 'backup'), shPred),
// ops/ipfs/ — IPFS release-hosting scripts (v1.9.0). The pin +
// setup scripts are real consumers of the release-hosting env vars
// that the ipfs Ansible role's env templates declare; before this
// directory was scanned those vars had no discoverable consumer.
...walkFiles(join(REPO_ROOT, 'ops', 'ipfs'), shPred),
// ops/ddns/ — dynamic-DNS scripts (cp596). morphit-ddns-update.sh
// consumes MORPHIT_DDNS_UPDATE_URL / _IP_URL / _STATE_FILE, which the
// ddns Ansible role's env template declares; scanned here so those
// vars have a discoverable consumer.
...walkFiles(join(REPO_ROOT, 'ops', 'ddns'), shPred)
];
// Match uppercase env-var tokens. Require at least 3 chars
// total so we don't false-positive on every accidental
// uppercase pair in source (e.g. `OK`, `IO`, `JS`).
const re = /[A-Z][A-Z0-9_]{2,}/g;
for (const f of consumerFiles) {
const src = readFileSync(f, 'utf-8');
for (const m of src.matchAll(re)) {
names.add(m[0]);
}
}
return { names, fileCount: consumerFiles.length };
}
// ─── Collect ──
const templateVars = collectTemplateVars();
const { names: consumerNames, fileCount: consumerFileCount } =
collectConsumerSurface();
// ─── Scenario 1: every literal template var has a consumer ──
// EXCEPT for templates listed in EXTERNAL_CONSUMER_TEMPLATES,
// which document an upstream consumer outside Morphit's
// codebase (e.g. BunkerWeb container).
const literalsByVar = new Map<string, { templates: string[]; externallyConsumed: boolean }>();
for (const { template, varName, externallyConsumed } of templateVars) {
const entry = literalsByVar.get(varName) ?? { templates: [], externallyConsumed: true };
entry.templates.push(template);
// If ANY occurrence is internally-consumed, we still expect
// a Morphit-side consumer. The flag is true only when EVERY
// declaring template is in the EXTERNAL_CONSUMER list.
entry.externallyConsumed = entry.externallyConsumed && externallyConsumed;
literalsByVar.set(varName, entry);
}
// Sort for deterministic output.
const sortedVars = [...literalsByVar.keys()].sort();
for (const varName of sortedVars) {
const { templates, externallyConsumed } = literalsByVar.get(varName)!;
if (externallyConsumed) {
// Scenario reframed: var lives only in an externally-
// consumed template, so we expect NO Morphit-side
// consumer. Still emit a passing scenario for
// visibility — operators see the var is intentionally
// upstream-consumed.
const upstream = EXTERNAL_CONSUMER_TEMPLATES.get(templates[0]!) ?? 'external process';
results.push({
name: `${varName} is upstream-consumed (template marked external): ${upstream}`,
ok: true
});
continue;
}
const hasConsumer = consumerNames.has(varName);
results.push({
name: `${varName} has a consumer in apps/, ops/scripts/, ops/backup/, ops/ipfs/, or ops/ddns/`,
ok: hasConsumer,
detail: hasConsumer
? undefined
: `${varName} is declared in template(s) ` +
`[${templates.join(', ')}] but no file under apps/, ` +
`ops/scripts/, ops/backup/, ops/ipfs/, or ops/ddns/ references it. Either ` +
`remove the dead template line, or add a consumer (e.g. ` +
`zod schema in apps/<workspace>/src/config/index.ts, ` +
`bash variable in ops/scripts/morphit-*.sh, ops/backup/*.sh, or ops/ipfs/*.sh). ` +
`Dead template lines are a security trap: operators ` +
`may fill in real secrets expecting them to be used ` +
`(cp5 F13, cp131 HIGH-001 / HIGH-002).`
});
}
// ─── Scenario 2: at least one template was scanned ──
results.push({
name: 'at least one *.env.j2 template scanned (sanity vs repo restructure)',
ok: templateVars.length > 0,
detail:
templateVars.length === 0
? `no *.env.j2 templates found under ${ANSIBLE_ROLES_DIR} (did the repo layout change?)`
: undefined
});
// ─── Scenario 3: at least some consumer files were scanned ──
results.push({
name: 'at least one consumer file scanned (sanity vs repo restructure)',
ok: consumerFileCount > 0,
detail:
consumerFileCount === 0
? `no consumer files found under apps/, ops/scripts/, ops/backup/, or ops/ipfs/ (did the repo layout change?)`
: undefined
});
// ─── Scenario 4: every entry in EXTERNAL_CONSUMER_TEMPLATES
// points at an actually-existing template file. Guards
// against drift: if a template gets renamed or deleted, the
// allowlist entry pointing at the old path silently lets
// new (real-consumer-required) drift slip through.
for (const [tplPath, upstream] of EXTERNAL_CONSUMER_TEMPLATES) {
const abs = join(REPO_ROOT, tplPath);
results.push({
name: `EXTERNAL_CONSUMER_TEMPLATES entry "${tplPath}" still exists (upstream: ${upstream})`,
ok: existsSync(abs),
detail: existsSync(abs)
? undefined
: `EXTERNAL_CONSUMER_TEMPLATES lists ${tplPath} but the file ` +
`does not exist. Either restore the template or remove the ` +
`stale entry from the allowlist. Stale allowlist entries ` +
`silently mask new drift.`
});
}
// ─── Report ──
console.log(
`ansible env-var consumer smoke: ${results.length} scenarios ` +
`(${sortedVars.length} unique template vars, ${consumerNames.size} consumer-surface vars, ` +
`${consumerFileCount} consumer files scanned)\n`
);
let failed = 0;
for (const r of results) {
if (r.ok) {
console.log(`${r.name}`);
} else {
console.log(`${r.name}`);
if (r.detail) {
for (const line of r.detail.split('\n')) {
console.log(` ${line}`);
}
}
failed++;
}
}
console.log('');
if (failed === 0) {
console.log(`✓ all ${results.length} env-var consumer checks hold`);
process.exit(0);
} else {
console.error(`${failed} failed, ${results.length - failed} passed`);
process.exit(1);
}