morphit/apps/ops-cli/scripts/rpc-endpoint-canon-smoke.ts

239 lines
10 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* rpc-endpoint-canon-smoke (beta5 item D).
*
* Pins the canonical Blurt RPC endpoint set (the single source of truth
* in @morphit/operator-config) against the copies that CANNOT import it:
* - the frontend's DEFAULT_RPC_ENDPOINTS (browser bundle — can't pull
* in a node package). cp268: this is now the browser-CORS-clean
* SUBSET of canon (a browser can only use nodes that return a valid
* single Access-Control-Allow-Origin), so it is checked as a non-empty
* SUBSET with no stray node, NOT set-equal.
* - the env examples (ops/env/indexer.env.example,
* ops/env/relay.env.example). These are SERVER-side (no CORS), so they
* stay set-EQUAL to canon.
*
* The node-side consumers (indexer config, relay config, ops-cli
* chainCheck/chainErrors/steps) all import the constant directly, so
* TypeScript already guarantees they can't drift; this smoke covers the
* non-importing copies. Order-independent (the pool learns fastest-first),
* so a cosmetic reorder is fine but a stray/missing endpoint fails.
*
* This is the guard that would have caught the beta5 firefight's root
* config bug: the wizard's list contained `rpc.blurt.world` and was
* missing two endpoints the rest of the app used.
*/
import { readFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { DEFAULT_BLURT_RPC_ENDPOINTS } from '@morphit/operator-config';
const HERE = dirname(fileURLToPath(import.meta.url));
const REPO = join(HERE, '..', '..', '..');
let pass = 0;
let fail = 0;
const ok = (m: string) => {
pass++;
console.log(` \u2713 ${m}`);
};
const bad = (m: string, detail = '') => {
fail++;
console.log(` \u2717 ${m}`);
if (detail) console.log(` ${detail}`);
};
const canon = new Set(DEFAULT_BLURT_RPC_ENDPOINTS);
const setEq = (a: Set<string>, b: Set<string>) =>
a.size === b.size && [...a].every((x) => b.has(x));
const show = (s: Iterable<string>) => [...s].sort().join(', ');
// ── canonical sanity ────────────────────────────────────────────────
if (DEFAULT_BLURT_RPC_ENDPOINTS.length >= 3) ok(`canonical set has ${DEFAULT_BLURT_RPC_ENDPOINTS.length} endpoints (>=3 for redundancy)`);
else bad('canonical set has fewer than 3 endpoints — too little RPC redundancy', show(canon));
if (DEFAULT_BLURT_RPC_ENDPOINTS.every((u) => u.startsWith('https://'))) ok('canonical endpoints are all https://');
else bad('a canonical endpoint is not https://', show(canon));
// Regression guard for the exact firefight artefact.
if (!canon.has('https://rpc.blurt.world')) ok('canonical set does not contain the un-attributed rpc.blurt.world (firefight regression)');
else bad('rpc.blurt.world is back in the canonical set — confirm it is a real, attributed node first');
// ── frontend literal (browser-CORS-clean SUBSET of canon) ───────────
// cp268: the browser list is the CORS-clean SUBSET of the canonical
// pool — a browser can only use a node that returns a single valid
// Access-Control-Allow-Origin, and three canonical nodes fail browser
// CORS (beblurt double-value; blurt.one + dagobert missing header). So
// the frontend list is NOT set-equal to canon; it must be a non-empty
// subset with NO stray node (drift guard) + enough nodes for failover.
{
const src = readFileSync(join(REPO, 'apps', 'web', 'src', 'lib', 'net', 'config.ts'), 'utf8');
const m = /DEFAULT_RPC_ENDPOINTS[^=]*=\s*\[([\s\S]*?)\]/.exec(src);
if (!m) {
bad('could not find DEFAULT_RPC_ENDPOINTS in apps/web/src/lib/net/config.ts');
} else {
const urls = new Set(Array.from(m[1]!.matchAll(/'(https:\/\/[^']+)'/g)).map((x) => x[1]!));
const stray = [...urls].filter((u) => !canon.has(u));
if (stray.length === 0)
ok('frontend DEFAULT_RPC_ENDPOINTS is a subset of the canonical pool (no stray node)');
else
bad(
'frontend DEFAULT_RPC_ENDPOINTS contains node(s) not in the canonical pool',
`stray=[${show(stray)}] canon=[${show(canon)}]`
);
if (urls.size >= 2) ok(`frontend browser pool has ${urls.size} endpoints (>=2 for failover)`);
else bad('frontend browser pool has fewer than 2 endpoints — too little browser failover', show(urls));
if ([...urls].every((u) => u.startsWith('https://'))) ok('frontend endpoints are all https://');
else bad('a frontend endpoint is not https://', show(urls));
// cp(post-beta28): SERVER_ONLY_CANONICAL_RPC_ENDPOINTS — the
// CORS-omitted canonical nodes the settings panel shows READ-ONLY
// so the operator sees the complete 6-node pool. Must be a subset
// of canon, DISJOINT from the browser list, and together with it
// cover the WHOLE canonical pool (the "show all 6" guarantee).
const soM = /SERVER_ONLY_CANONICAL_RPC_ENDPOINTS[^=]*=\s*\[([\s\S]*?)\]/.exec(src);
if (!soM) {
bad('could not find SERVER_ONLY_CANONICAL_RPC_ENDPOINTS in apps/web/src/lib/net/config.ts');
} else {
const serverOnly = new Set(
Array.from(soM[1]!.matchAll(/'(https:\/\/[^']+)'/g)).map((x) => x[1]!)
);
const soStray = [...serverOnly].filter((u) => !canon.has(u));
if (soStray.length === 0)
ok('SERVER_ONLY_CANONICAL_RPC_ENDPOINTS is a subset of the canonical pool (no stray node)');
else
bad(
'SERVER_ONLY_CANONICAL_RPC_ENDPOINTS contains node(s) not in the canonical pool',
`stray=[${show(soStray)}]`
);
const overlap = [...serverOnly].filter((u) => urls.has(u));
if (overlap.length === 0) ok('browser DEFAULT and SERVER_ONLY endpoint lists are disjoint');
else
bad(
'an endpoint is in BOTH the browser DEFAULT and SERVER_ONLY lists',
`overlap=[${show(overlap)}]`
);
const union = new Set([...urls, ...serverOnly]);
if (setEq(union, canon))
ok('browser DEFAULT SERVER_ONLY === the full canonical pool (settings panel shows every canonical node)');
else
bad(
'browser DEFAULT SERVER_ONLY does not equal the canonical pool',
`union=[${show(union)}] canon=[${show(canon)}]`
);
}
}
}
// ── env examples ────────────────────────────────────────────────────
function envListVar(path: string, varName: string): Set<string> | null {
const src = readFileSync(join(REPO, path), 'utf8');
const line = src.split('\n').find((l) => l.startsWith(`${varName}=`));
if (line === undefined) return null;
const val = line.slice(varName.length + 1).trim();
return new Set(
val
.split(',')
.map((u) => u.trim())
.filter((u) => u !== '')
);
}
for (const [path, varName] of [
['ops/env/indexer.env.example', 'MORPHIT_INDEXER_RPC_ENDPOINTS'],
['ops/env/relay.env.example', 'MORPHIT_RELAY_BLURT_RPC']
] as const) {
const urls = envListVar(path, varName);
if (urls === null) {
bad(`could not find ${varName} in ${path}`);
} else if (setEq(urls, canon)) {
ok(`${path} ${varName} matches the canonical set`);
} else {
bad(`${path} ${varName} differs from canonical`, `example=[${show(urls)}] canon=[${show(canon)}]`);
}
}
// ── Ansible deploy defaults (cp328) ─────────────────────────────────
// The env examples above were guarded, but the Ansible group_vars copy
// was NOT — and that is exactly where the dead rpc.blurt.world node
// survived (group_vars/all.yml pinned the indexer to it as "primary",
// and the egress allowlist opened it while BLOCKING the real six). Pin
// the Ansible indexer-endpoint default to canon too, and keep the
// decommissioned node out of the whole file (endpoints AND allowlist).
function yamlScalarList(path: string, key: string): Set<string> | null {
const src = readFileSync(join(REPO, path), 'utf8');
const line = src.split('\n').find((l) => l.trimStart().startsWith(`${key}:`));
if (line === undefined) return null;
let val = line.slice(line.indexOf(':') + 1).trim();
if (
(val.startsWith('"') && val.endsWith('"')) ||
(val.startsWith("'") && val.endsWith("'"))
) {
val = val.slice(1, -1);
}
return new Set(
val
.split(',')
.map((u) => u.trim())
.filter((u) => u !== '')
);
}
{
const ansiblePath = 'ops/ansible/group_vars/all.yml';
const urls = yamlScalarList(ansiblePath, 'morphit_indexer_blurt_rpc_endpoints');
if (urls === null) {
bad(`could not find morphit_indexer_blurt_rpc_endpoints in ${ansiblePath}`);
} else if (setEq(urls, canon)) {
ok(`${ansiblePath} morphit_indexer_blurt_rpc_endpoints matches the canonical set`);
} else {
bad(
`${ansiblePath} indexer RPC endpoints differ from canonical`,
`ansible=[${show(urls)}] canon=[${show(canon)}]`
);
}
const ay = readFileSync(join(REPO, ansiblePath), 'utf8');
if (!ay.includes('rpc.blurt.world')) {
ok(`${ansiblePath} is free of the dead rpc.blurt.world node (endpoints + egress allowlist)`);
} else {
bad(
`rpc.blurt.world reappeared in ${ansiblePath} — decommissioned node; use the canonical set`
);
}
}
// ── verify-download.mjs (cp560: this copy rotted — it still pinned the
// dead rpc.blurt.world and a downloader hit "could not reach the
// chain" during the v1.8.15 ceremony). It's a standalone Node script
// (no browser CORS), so its DEFAULT_RPCS must equal the WHOLE 6-node
// canonical pool, and must never contain the decommissioned node. ──
{
const src = readFileSync(join(REPO, 'scripts', 'verify-download.mjs'), 'utf8');
const m = /DEFAULT_RPCS\s*=\s*\[([\s\S]*?)\]/.exec(src);
if (!m) {
bad('could not find DEFAULT_RPCS in scripts/verify-download.mjs');
} else {
const urls = new Set(Array.from(m[1]!.matchAll(/'(https:\/\/[^']+)'/g)).map((x) => x[1]!));
if (setEq(urls, canon))
ok('verify-download.mjs DEFAULT_RPCS === the full canonical pool');
else
bad(
'verify-download.mjs DEFAULT_RPCS differs from the canonical pool',
`script=[${show(urls)}] canon=[${show(canon)}]`
);
}
if (!/rpc\.blurt\.world/.test(src))
ok('verify-download.mjs is free of the dead rpc.blurt.world node (list + usage + error suggestion)');
else bad('rpc.blurt.world reappeared in scripts/verify-download.mjs — decommissioned node');
}
console.log('');
console.log(`${pass} passed, ${fail} failed`);
if (fail > 0) {
console.log('\u2717 rpc-endpoint-canon smoke FAILED');
process.exit(1);
}
console.log(`\u2713 all ${pass} rpc-endpoint-canon scenarios passed`);