morphit/apps/web/scripts/non-zod-env-example-consumer-parity-smoke.ts

215 lines
8.2 KiB
TypeScript

#!/usr/bin/env tsx
/**
* non-zod-env-example-consumer-parity-smoke.
*
* Part 122 cp61 STRUCTURAL DEFENSE (LL #65 / O-15) — sibling of cp61-O14.
*
* Closes the cp57-O11 generalization gap: cp57-O11 covers env-
* example files backed by a Zod schema (indexer, relay, matrix-
* bot — all three with `loadConfig()` Zod parsers). Two
* remaining env-example files in the repo aren't Zod-backed:
*
* - ops/bunkerweb/bunkerweb.env.example — consumed by the
* BunkerWeb container via `env_file:` directive in
* docker-compose.yml. BunkerWeb itself parses the vars
* into its nginx + ModSecurity runtime.
*
* - ops/backup/backup.env.example — consumed by morphit-backup.sh
* via shell-script `. "$BACKUP_ENV"` sourcing. Vars referenced
* in the script with `$VAR` / `${VAR}` expansion.
*
* Parity model differs per mechanism:
*
* env_file_directive: verify the docker-compose.yml `env_file:`
* line points at the canonical example filename. All vars get
* consumed by the container automatically (BunkerWeb's runtime
* reads them); no per-var check is needed beyond the directive.
*
* shell_script_sourcing: verify every var in the env-example is
* referenced in at least one consumer script via `$VAR` or
* `${VAR}` expansion. Reverse-direction check is skipped
* because shell scripts have locals + shell builtins that
* wouldn't be in the env-example (e.g. BACKUP_ENV is sourced
* FROM as part of the script setup, not a configurable knob).
*
* Bug history at cp61: both files are CURRENTLY clean — this is
* a preventive smoke. Without the gate, the next checkpoint that
* adds a phantom var to bunkerweb.env.example (forgetting to
* delete the corresponding feature wiring) or that removes a
* consumer script reference (without cleaning up the example)
* accumulates drift silently.
*
* Recurring class scope progression (14 defenses across 14 checkpoints):
* cp48-O1 through cp60-O13 (as listed in REVISIT)
* cp61-O14: bunkerweb CIDR cross-reference
* cp61-O15: non-Zod env-example consumer parity (THIS)
*
* Mutation tests:
* M-128: remove `env_file: ./bunkerweb.env` line from docker-
* compose.yml. Smoke fires:
* "bunkerweb: env_file: ./bunkerweb.env directive missing
* from ops/bunkerweb/docker-compose.yml."
* M-129: add `PHANTOM_VAR=test` to backup.env.example. Smoke
* fires: "backup: example var PHANTOM_VAR is not
* referenced in any consumer script."
*/
import { readFileSync } from 'fs';
import { join, dirname, basename } 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── non-zod-env-example-consumer-parity smoke (cp61 LL #65 / O-15) ──\n');
type EnvFileDirective = {
mechanism: 'env_file_directive';
composeFile: string; // path to docker-compose.yml that should reference the example
expectedOccurrences: number; // every service that needs the env vars must have its own env_file: directive
};
type ShellScriptSourcing = {
mechanism: 'shell_script_sourcing';
scriptFiles: string[]; // paths to scripts that consume the env file
};
interface NonZodService {
name: string;
examplePath: string;
consumer: EnvFileDirective | ShellScriptSourcing;
}
const SERVICES: NonZodService[] = [
{
name: 'bunkerweb',
examplePath: 'ops/bunkerweb/bunkerweb.env.example',
consumer: {
mechanism: 'env_file_directive',
composeFile: 'ops/bunkerweb/docker-compose.yml',
// BunkerWeb's compose has 2 services that BOTH need the env vars:
// `bunkerweb` (the WAF runtime) and `bunkerweb-scheduler` (the
// config-management agent that applies BunkerWeb settings).
// Both must have their own `env_file:` directive — partial
// removal silently misconfigures one of them.
expectedOccurrences: 2
}
},
{
name: 'backup',
examplePath: 'ops/backup/backup.env.example',
consumer: {
mechanism: 'shell_script_sourcing',
scriptFiles: ['ops/backup/morphit-backup.sh']
}
}
];
function parseEnvVars(content: string): Set<string> {
// Same regex as cp57-O11: match `MORPHIT_X=` or `# MORPHIT_X=` or
// `# X=` for bunkerweb-style non-MORPHIT-prefixed vars. Allow
// trailing whitespace between the var name and `=`.
const vars = new Set<string>();
const re = /^\s*#?\s*([A-Z][A-Z_0-9]*)\s*=/gm;
let m: RegExpExecArray | null;
while ((m = re.exec(content)) !== null) {
vars.add(m[1]);
}
return vars;
}
for (const svc of SERVICES) {
const examplePath = join(REPO_ROOT, svc.examplePath);
let exampleContent: string;
try {
exampleContent = readFileSync(examplePath, 'utf-8');
} catch (e) {
fail(svc.name, `cannot read ${svc.examplePath}: ${(e as Error).message}`);
continue;
}
const exampleVars = parseEnvVars(exampleContent);
console.log(`${svc.name}: example=${exampleVars.size} vars · mechanism=${svc.consumer.mechanism}`);
if (svc.consumer.mechanism === 'env_file_directive') {
const composePath = join(REPO_ROOT, svc.consumer.composeFile);
let composeContent: string;
try {
composeContent = readFileSync(composePath, 'utf-8');
} catch (e) {
fail(`${svc.name}: compose file readable`, `cannot read ${svc.consumer.composeFile}: ${(e as Error).message}`);
continue;
}
const expectedFilename = basename(svc.examplePath).replace(/\.example$/, '');
// docker-compose loads env_file at runtime; the file name in
// the directive is the deploy-side filename without `.example`.
// Count occurrences — every service that needs the env vars
// must have its own directive. Pinning EXACT count catches
// partial-removal mutations (e.g. one service loses the
// directive while a sibling keeps it) that a presence-only
// check would miss.
const directiveRe = new RegExp(`env_file:[\\s\\S]{0,80}?\\b${expectedFilename}\\b`, 'gm');
const occurrences = (composeContent.match(directiveRe) || []).length;
if (occurrences === svc.consumer.expectedOccurrences) {
pass(
`${svc.name}: docker-compose.yml has exactly ${occurrences} env_file: directive(s) pointing at ${expectedFilename}`
);
} else {
fail(
`${svc.name}: docker-compose env_file: directive count`,
`expected ${svc.consumer.expectedOccurrences} occurrence(s) of "env_file: ... ${expectedFilename}", found ${occurrences} in ${svc.consumer.composeFile}. ` +
`If a service was intentionally removed, update expectedOccurrences in this smoke. ` +
`If a directive was accidentally deleted, restore it — without it the corresponding container silently uses defaults instead of the configured env vars.`
);
}
} else if (svc.consumer.mechanism === 'shell_script_sourcing') {
let combinedScript = '';
for (const sf of svc.consumer.scriptFiles) {
const sp = join(REPO_ROOT, sf);
try {
combinedScript += '\n' + readFileSync(sp, 'utf-8');
} catch (e) {
fail(`${svc.name}: script readable`, `cannot read ${sf}: ${(e as Error).message}`);
}
}
// Find all $VAR / ${VAR} references in the script.
const scriptRefs = new Set<string>();
const refRe = /\$\{?([A-Z][A-Z_0-9]*)\}?/g;
let m: RegExpExecArray | null;
while ((m = refRe.exec(combinedScript)) !== null) {
scriptRefs.add(m[1]);
}
const orphans: string[] = [];
for (const v of exampleVars) {
if (!scriptRefs.has(v)) orphans.push(v);
}
if (orphans.length === 0) {
pass(`${svc.name}: every example var (${exampleVars.size}) is referenced in at least one consumer script`);
} else {
fail(
`${svc.name}: every example var has at least one script consumer`,
`${orphans.length} phantom var(s): ${orphans.join(', ')}. ` +
`Either the var is no longer used (remove from example) or the script reference was deleted (restore it).`
);
}
}
}
const total = passed + failed;
console.log(`\n${passed} passed, ${failed} failed (${total} total)`);
if (failed > 0) {
console.error('\nnon-zod-env-example-consumer-parity smoke FAILED');
process.exit(1);
}
console.log(`✓ all ${total} non-Zod env-example consumer-parity checks pass`);