morphit/apps/indexer/scripts/ansible-env-template-required-vars-smoke.ts

217 lines
8.1 KiB
TypeScript

#!/usr/bin/env tsx
/**
* ansible-env-template-required-vars-smoke.
*
* Part 122 cp52 STRUCTURAL DEFENSE (LL #56 / O-6).
*
* Closes the cp52-A3 class: every env var that the indexer/relay
* Zod schema marks REQUIRED (no `.default()`, no `.optional()`)
* MUST be present in the corresponding Ansible env template
* (`ops/ansible/roles/morphit/templates/indexer.env.j2` and
* `relay.env.j2`). Without this, running the playbook on a
* fresh host yields services that fail Zod validation at startup.
*
* Bug history:
* - Ansible templates last touched at cp36; canonical env
* examples updated through cp49.
* - Cp52 deep-deep on playbook readiness surfaced
* `MORPHIT_INDEXER_PUBLIC_ORIGIN` missing from the indexer
* template despite being a `z.string().url()` required field.
* Indexer would crash at startup on a fresh deploy.
* - Fix landed inline at cp52; this smoke pins the pattern.
*
* Recurring class scope progression (6 defenses across 5 checkpoints):
* cp48-O1: standalone smoke scripts
* cp49-O2: vitest unit tests
* cp50-O3: HTTP route handler regex
* cp51-O4: ops-cli per-ticker hardcoded tables
* cp51-O5: per-asset i18n FAQ key coverage
* cp52-O6: Ansible env template required-var parity (THIS)
*
* Mutation test verification: M-120 — deleting
* `MORPHIT_INDEXER_PUBLIC_ORIGIN` from indexer.env.j2 fires:
* "ansible-env-template-required-vars FAILED:
* indexer template missing required env var
* MORPHIT_INDEXER_PUBLIC_ORIGIN. Indexer would fail Zod
* validation at startup."
*
* Limitations: parses the Zod schema textually with a regex. If
* the schema layout changes (e.g. nested z.object), the parser
* may need updating. Schema location is asserted (`z.object({`
* inside `apps/indexer/src/config/index.ts` and
* `apps/relay/src/config/index.ts`).
*/
import { readFileSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
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── ansible-env-template-required-vars smoke (cp52 LL #56 / O-6) ──\n');
interface SubsystemDef {
name: string;
configPath: string;
templatePath: string;
}
const SUBSYSTEMS: SubsystemDef[] = [
{
name: 'indexer',
configPath: join(__dirname, '..', 'src', 'config', 'index.ts'),
templatePath: join(__dirname, '..', '..', '..', 'ops', 'ansible', 'roles', 'morphit', 'templates', 'indexer.env.j2')
},
{
name: 'relay',
configPath: join(__dirname, '..', '..', 'relay', 'src', 'config', 'index.ts'),
templatePath: join(__dirname, '..', '..', '..', 'ops', 'ansible', 'roles', 'morphit', 'templates', 'relay.env.j2')
},
{
// Added cp58 — matrix-bot has its own Ansible template under
// roles/matrix_bot/ separate from the morphit role. Its schema
// is `const SCHEMA = z.object({` (not `envSchema`); the parser
// regex above matches `envSchema|envSchema\s*=` — for matrix-bot
// the parser does not match. Cp58 also makes the regex match
// `SCHEMA`-named schemas.
name: 'matrix-bot',
configPath: join(__dirname, '..', '..', 'matrix-bot', 'src', 'config.ts'),
templatePath: join(__dirname, '..', '..', '..', 'ops', 'ansible', 'roles', 'matrix_bot', 'templates', 'matrix-bot.env.j2')
}
];
function extractRequiredEnvVars(configSrc: string): string[] {
// Find the envSchema (or SCHEMA — matrix-bot uses this) block start.
const schemaIdx = configSrc.search(/(const\s+envSchema|envSchema\s*=|const\s+SCHEMA|SCHEMA\s*=)\s*z\.object\(\{/);
if (schemaIdx === -1) {
throw new Error('envSchema|SCHEMA = z.object({ ... }) not found');
}
// Walk braces from the first `{` after envSchema to find the
// matching `}`. Comments and strings tracked to avoid
// `{` inside docblock prose or template-literal strings being
// counted as scope-opening.
const startBrace = configSrc.indexOf('{', schemaIdx);
let depth = 0;
let inString: false | "'" | '"' | '`' = false;
let inLineComment = false;
let inBlockComment = false;
let endBrace = startBrace;
for (let i = startBrace; i < configSrc.length; i++) {
const ch = configSrc[i]!;
const next = configSrc[i + 1];
if (inLineComment) {
if (ch === '\n') inLineComment = false;
continue;
}
if (inBlockComment) {
if (ch === '*' && next === '/') { inBlockComment = false; i++; }
continue;
}
if (inString) {
if (ch === '\\') { i++; continue; }
if (ch === inString) inString = false;
continue;
}
if (ch === '/' && next === '/') { inLineComment = true; i++; continue; }
if (ch === '/' && next === '*') { inBlockComment = true; i++; continue; }
if (ch === '"' || ch === "'" || ch === '`') { inString = ch; continue; }
if (ch === '{') depth++;
else if (ch === '}') {
depth--;
if (depth === 0) { endBrace = i + 1; break; }
}
}
const block = configSrc.slice(startBrace, endBrace);
// Find each MORPHIT_<X>: line. For each, slice from that
// position to the next MORPHIT_<X>: line (or end of block) and
// look for `.default(` or `.optional(` in the body.
const startRe = /^\s*(MORPHIT_[A-Z_]+):/gm;
const matches: Array<{ name: string; idx: number }> = [];
let m: RegExpExecArray | null;
while ((m = startRe.exec(block)) !== null) {
matches.push({ name: m[1]!, idx: m.index });
}
const required: string[] = [];
for (let i = 0; i < matches.length; i++) {
const start = matches[i]!.idx;
const end = i + 1 < matches.length ? matches[i + 1]!.idx : block.length;
const body = block.slice(start, end);
if (!bodyDeclaresOptional(body, configSrc)) {
required.push(matches[i]!.name);
}
}
return required;
}
/**
* True if a field body marks the var optional/defaulted — either inline
* (`.optional(` / `.default(`) OR by referencing a schema constant that is
* itself optional/defaulted, e.g. `MORPHIT_X: hmacSecretSchema,` where
* `const hmacSecretSchema = z.string()...optional()`. Resolving one level of
* indirection keeps shared schema constants (good DRY — see the relay's
* `hmacSecretSchema`) from being misread as required, which would otherwise
* demand they be templated into Ansible even though leaving them unset is a
* valid, secure default.
*/
function bodyDeclaresOptional(body: string, configSrc: string): boolean {
if (body.includes('.optional(') || body.includes('.default(')) return true;
const ref = body.match(/:\s*([A-Za-z_$][\w$]*)\s*,/);
if (ref) {
const ident = ref[1]!;
const declIdx = configSrc.search(new RegExp(`\\bconst\\s+${ident}\\b\\s*=`));
if (declIdx !== -1) {
const semi = configSrc.indexOf(';', declIdx);
const decl = configSrc.slice(declIdx, semi === -1 ? undefined : semi);
if (decl.includes('.optional(') || decl.includes('.default(')) return true;
}
}
return false;
}
for (const sub of SUBSYSTEMS) {
try {
const configSrc = readFileSync(sub.configPath, 'utf-8');
const requiredVars = extractRequiredEnvVars(configSrc);
console.log(`\n${sub.name}: ${requiredVars.length} required env vars from Zod schema`);
for (const v of requiredVars) console.log(` required: ${v}`);
const templateSrc = readFileSync(sub.templatePath, 'utf-8');
const missing: string[] = [];
for (const v of requiredVars) {
// Match `^<VAR>=` (top of line) in template
const lineRe = new RegExp(`^${v}=`, 'm');
if (!lineRe.test(templateSrc)) {
missing.push(v);
}
}
if (missing.length === 0) {
pass(`${sub.name}: every required env var (${requiredVars.length}) is present in Ansible template`);
} else {
fail(
`${sub.name}: every required env var is present in Ansible template`,
`missing: [${missing.join(', ')}]. ${sub.name} would fail Zod validation at service startup on a fresh deploy.`
);
}
} catch (e) {
fail(`${sub.name}: parse + verify`, String(e));
}
}
const total = passed + failed;
console.log(`\n${passed} passed, ${failed} failed (${total} total)`);
if (failed > 0) {
console.error('\nansible-env-template-required-vars smoke FAILED');
process.exit(1);
}
console.log(`✓ all ${total} scenarios passed`);