238 lines
8.7 KiB
TypeScript
238 lines
8.7 KiB
TypeScript
#!/usr/bin/env tsx
|
|
/**
|
|
* instance-env-loader-smoke — `loadInstanceEnv()` must populate process.env
|
|
* from an instance's on-disk env files for the on-chain commands
|
|
* (payment-method / register / show-key), without overwriting values the
|
|
* operator set explicitly, and without throwing when files are absent.
|
|
*
|
|
* It ALSO covers the DB-command bridge (cp248): the DB-backed commands
|
|
* (status / signups / drain-queue / failed-broadcasts / moderation) resolve
|
|
* their database URL through loadConfig(), which reads process.env. On a
|
|
* systemd deploy the DB URL lives in morphit.env (unit-sourced only), so
|
|
* `sudo morphit-ops status` failed with "No database URL configured" until
|
|
* main.ts began calling loadInstanceEnv() BEFORE loadConfig(). Scenarios 5
|
|
* + 6 pin that the DB URL bridges through to loadConfig and that main.ts
|
|
* keeps the call ordering.
|
|
*
|
|
* Why: on a systemd deployment morphit.env is sourced only by the unit, so
|
|
* `morphit-ops payment-method` previously failed with
|
|
* "✗ MORPHIT_RELAY_ACCOUNT is not set" even on a healthy instance.
|
|
*
|
|
* Scenarios:
|
|
* 1. infra var (MORPHIT_RELAY_ACCOUNT) is read from morphit.env.
|
|
* 2. OS env wins — a pre-set value is NOT overwritten.
|
|
* 3. no env files present → no throw, both flags false.
|
|
* 4. only morphit.env present (no config) → infraLoaded true, no throw.
|
|
* 5. a DB URL in morphit.env bridges through to loadConfig().databaseUrl.
|
|
* 6. main.ts calls loadInstanceEnv() BEFORE loadConfig() (static wiring).
|
|
* 7. a DB URL with an unexpanded shell command substitution (`$(…)` or
|
|
* backticks) fails with a clear, actionable error — not a cryptic
|
|
* `getaddrinfo ENOTFOUND $(docker inspect …)`.
|
|
*
|
|
* Usage:
|
|
* tsx apps/ops-cli/scripts/instance-env-loader-smoke.ts
|
|
*/
|
|
|
|
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join, dirname } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { loadInstanceEnv } from '../src/lib/instanceEnv.ts';
|
|
import { loadConfig } from '../src/config.ts';
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
|
|
interface R {
|
|
readonly name: string;
|
|
readonly ok: boolean;
|
|
readonly detail?: string;
|
|
}
|
|
const results: R[] = [];
|
|
function ok(name: string, cond: boolean, detail?: string): void {
|
|
results.push({ name, ok: cond, detail });
|
|
}
|
|
|
|
// Env keys this smoke touches — saved and restored so we never leak state.
|
|
const TOUCHED = [
|
|
'MORPHIT_RELAY_ACCOUNT',
|
|
'MORPHIT_OPERATOR_POSTING_KEY_FILE',
|
|
'MORPHIT_OPERATOR_CONFIG_FILE',
|
|
'MORPHIT_OPS_DATABASE_URL',
|
|
'MORPHIT_INDEXER_DATABASE_URL',
|
|
'DATABASE_URL'
|
|
];
|
|
const saved = new Map<string, string | undefined>();
|
|
for (const k of TOUCHED) saved.set(k, process.env[k]);
|
|
function resetEnv(): void {
|
|
for (const k of TOUCHED) delete process.env[k];
|
|
}
|
|
|
|
function writeInfra(dir: string, body: string): void {
|
|
mkdirSync(dir, { recursive: true });
|
|
writeFileSync(join(dir, 'morphit.env'), body);
|
|
}
|
|
|
|
function main(): void {
|
|
// Scenario 1: infra var read from morphit.env.
|
|
{
|
|
resetEnv();
|
|
const dir = mkdtempSync(join(tmpdir(), 'ienv-load-'));
|
|
writeInfra(dir, 'MORPHIT_RELAY_ACCOUNT=test-relay\nMORPHIT_OPERATOR_POSTING_KEY_FILE=/keys/posting.key\n');
|
|
const res = loadInstanceEnv(dir);
|
|
ok('infra_var_loaded', process.env.MORPHIT_RELAY_ACCOUNT === 'test-relay', `got ${process.env.MORPHIT_RELAY_ACCOUNT}`);
|
|
ok('infra_keyfile_loaded', process.env.MORPHIT_OPERATOR_POSTING_KEY_FILE === '/keys/posting.key');
|
|
ok('infra_flag_true', res.infraLoaded === true);
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
|
|
// Scenario 2: OS env wins — preset value not overwritten.
|
|
{
|
|
resetEnv();
|
|
process.env.MORPHIT_RELAY_ACCOUNT = 'preset-from-shell';
|
|
const dir = mkdtempSync(join(tmpdir(), 'ienv-oswins-'));
|
|
writeInfra(dir, 'MORPHIT_RELAY_ACCOUNT=should-not-win\n');
|
|
loadInstanceEnv(dir);
|
|
ok('os_env_wins', process.env.MORPHIT_RELAY_ACCOUNT === 'preset-from-shell', `got ${process.env.MORPHIT_RELAY_ACCOUNT}`);
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
|
|
// Scenario 3: no files → no throw, both flags false.
|
|
{
|
|
resetEnv();
|
|
const dir = mkdtempSync(join(tmpdir(), 'ienv-empty-'));
|
|
let threw = false;
|
|
let res = { configLoaded: true, infraLoaded: true } as ReturnType<typeof loadInstanceEnv>;
|
|
try {
|
|
res = loadInstanceEnv(dir);
|
|
} catch {
|
|
threw = true;
|
|
}
|
|
ok('no_files_no_throw', !threw);
|
|
ok('no_files_flags_false', res.infraLoaded === false && res.configLoaded === false);
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
|
|
// Scenario 4: only morphit.env present → infraLoaded true, no throw.
|
|
{
|
|
resetEnv();
|
|
const dir = mkdtempSync(join(tmpdir(), 'ienv-infraonly-'));
|
|
writeInfra(dir, 'MORPHIT_RELAY_ACCOUNT=infra-only\n');
|
|
let threw = false;
|
|
try {
|
|
const res = loadInstanceEnv(dir);
|
|
ok('infra_only_loaded', res.infraLoaded === true && process.env.MORPHIT_RELAY_ACCOUNT === 'infra-only');
|
|
} catch {
|
|
threw = true;
|
|
}
|
|
ok('infra_only_no_throw', !threw);
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
|
|
// Scenario 5: a DB URL in morphit.env bridges through to loadConfig() —
|
|
// the exact path that fixes `sudo morphit-ops status` ("No database URL
|
|
// configured") on a systemd deploy. We clear all three DB-URL env keys
|
|
// first so the only source is the file.
|
|
{
|
|
resetEnv();
|
|
const dir = mkdtempSync(join(tmpdir(), 'ienv-dburl-'));
|
|
writeInfra(dir, 'MORPHIT_INDEXER_DATABASE_URL=postgres://u:p@localhost:5432/morphit\n');
|
|
loadInstanceEnv(dir);
|
|
let threw = false;
|
|
let url: string | undefined;
|
|
try {
|
|
url = loadConfig().databaseUrl;
|
|
} catch {
|
|
threw = true;
|
|
}
|
|
ok(
|
|
'db_url_bridged_to_loadConfig',
|
|
!threw && url === 'postgres://u:p@localhost:5432/morphit',
|
|
threw ? 'loadConfig threw (DB URL not bridged)' : `got ${url}`
|
|
);
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
|
|
// Scenario 6 (static wiring): main.ts must call loadInstanceEnv() BEFORE
|
|
// loadConfig() — otherwise the bridge never runs for the DB commands and
|
|
// the whole "Check & operate" menu group regresses to the DB-URL error.
|
|
{
|
|
const mainSrc = readFileSync(join(__dirname, '..', 'src', 'main.ts'), 'utf-8');
|
|
const iEnv = mainSrc.indexOf('loadInstanceEnv(');
|
|
const iCfg = mainSrc.indexOf('loadConfig()');
|
|
ok('main_imports_loadInstanceEnv', /import \{ loadInstanceEnv \}/.test(mainSrc));
|
|
ok(
|
|
'main_calls_loadInstanceEnv_before_loadConfig',
|
|
iEnv !== -1 && iCfg !== -1 && iEnv < iCfg,
|
|
`iEnv=${iEnv} iCfg=${iCfg}`
|
|
);
|
|
}
|
|
|
|
// Scenario 7 (cp261): a DATABASE_URL whose host is an UNEXPANDED shell
|
|
// command substitution must fail with a CLEAR message, not the cryptic
|
|
// `getaddrinfo ENOTFOUND $(docker inspect …)` an operator hit on a manual
|
|
// deploy. Env files are read literally — the shell never runs — so a host
|
|
// like `$(docker inspect db | jq … IPAddress)` arrives verbatim; the guard
|
|
// in readDatabaseUrl() catches it before pg ever tries to resolve it.
|
|
{
|
|
resetEnv();
|
|
process.env.MORPHIT_OPS_DATABASE_URL =
|
|
'postgres://u:p@$(docker inspect bunkerweb-db-1 | jq -r ".[].NetworkSettings.Networks[\\"bunkerweb_bunkerweb-net\\"].IPAddress"):5432/morphit';
|
|
let msg = '';
|
|
try {
|
|
loadConfig();
|
|
} catch (e) {
|
|
msg = e instanceof Error ? e.message : String(e);
|
|
}
|
|
ok(
|
|
'unexpanded_command_substitution_clear_error',
|
|
/command substitution/i.test(msg) && msg.includes('read literally'),
|
|
msg === '' ? 'loadConfig did NOT throw on a $(…) host' : `msg: ${msg.slice(0, 70)}`
|
|
);
|
|
|
|
// A backtick command substitution is caught the same way.
|
|
resetEnv();
|
|
process.env.MORPHIT_OPS_DATABASE_URL = 'postgres://u:p@`hostname`:5432/morphit';
|
|
let backtickThrew = false;
|
|
try {
|
|
loadConfig();
|
|
} catch {
|
|
backtickThrew = true;
|
|
}
|
|
ok('backtick_command_substitution_caught', backtickThrew, 'backtick host did not throw');
|
|
|
|
// Sanity: a normal URL with no substitution is NOT flagged (the guard
|
|
// is not over-eager — scenario 5 proves the happy path through a file).
|
|
resetEnv();
|
|
process.env.MORPHIT_OPS_DATABASE_URL = 'postgres://u:p@db.internal:5432/morphit';
|
|
let cleanThrew = false;
|
|
try {
|
|
loadConfig();
|
|
} catch {
|
|
cleanThrew = true;
|
|
}
|
|
ok('clean_url_not_flagged', !cleanThrew, 'a normal URL was wrongly flagged as a substitution');
|
|
}
|
|
|
|
// Restore env.
|
|
resetEnv();
|
|
for (const [k, v] of saved) if (v !== undefined) process.env[k] = v;
|
|
|
|
let pass = 0;
|
|
let fail = 0;
|
|
for (const r of results) {
|
|
if (r.ok) {
|
|
pass += 1;
|
|
console.log(` PASS ${r.name}${r.detail ? ` — ${r.detail}` : ''}`);
|
|
} else {
|
|
fail += 1;
|
|
console.error(` FAIL ${r.name}${r.detail ? ` — ${r.detail}` : ''}`);
|
|
}
|
|
}
|
|
if (fail > 0) {
|
|
console.error(`\ninstance-env-loader-smoke: ${pass} pass / ${fail} fail`);
|
|
process.exit(1);
|
|
}
|
|
console.log(`\n✓ all ${pass} instance-env-loader scenarios passed`);
|
|
}
|
|
|
|
main();
|