285 lines
13 KiB
TypeScript
285 lines
13 KiB
TypeScript
#!/usr/bin/env tsx
|
|
/**
|
|
* mcp-webpush-install-defaults-smoke — locks down Ken's cp251
|
|
* requirement: web push AND the MCP server are installed, enabled,
|
|
* and started BY DEFAULT on a fresh node (via the canonical Ansible
|
|
* installer), and stay operator-controllable.
|
|
*
|
|
* This is a STATIC wiring smoke — it reads the role / unit / script /
|
|
* group_var files and asserts the wiring is present. It does NOT run
|
|
* Ansible or systemd (neither is available in CI / the dev sandbox);
|
|
* the runtime behaviour (the isolated MCP deploy actually resolving
|
|
* its deps, the relay sourcing the VAPID file) is exercised
|
|
* separately — the deploy was verified end-to-end by hand, and the
|
|
* relay ExecStart snippet by a bash dry-run — but ONLY a real fresh
|
|
* Ubuntu box validates the full systemd activation. (See REVISIT.)
|
|
*
|
|
* Guards, by area:
|
|
* MCP (§45):
|
|
* - group_var morphit_mcp_enabled defaults true
|
|
* - the morphit role creates the morphit-mcp group + user, the
|
|
* isolated /opt/morphit-mcp dir, runs deploy-mcp.sh, installs
|
|
* the unit, and enables+starts it — ALL gated on the toggle
|
|
* - a Restart morphit-mcp handler exists
|
|
* - deploy-mcp.sh vendors BOTH workspace deps + rewrites them to
|
|
* file: deps + runs npm install (the isolation contract)
|
|
* Web push (§46):
|
|
* - group_vars morphit_enable_web_push (true) + morphit_vapid_subject
|
|
* - the role generates VAPID ONCE (creates: guard) gated on the
|
|
* toggle, and locks the file down
|
|
* - the relay unit sources /etc/morphit/relay-vapid.env optionally
|
|
* - generate-vapid-keys.sh supports --subject + --bare
|
|
*
|
|
* Emits one canonical line at column 0 on success.
|
|
*/
|
|
|
|
import { readFileSync } from 'node:fs';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { dirname, join } from 'node:path';
|
|
|
|
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
const read = (rel: string): string => readFileSync(join(repoRoot, rel), 'utf8');
|
|
|
|
let checks = 0;
|
|
const failures: string[] = [];
|
|
function check(label: string, cond: boolean): void {
|
|
checks++;
|
|
if (!cond) failures.push(label);
|
|
}
|
|
|
|
const roleMain = read('ops/ansible/roles/morphit/tasks/main.yml');
|
|
const roleHandlers = read('ops/ansible/roles/morphit/handlers/main.yml');
|
|
const groupVars = read('ops/ansible/group_vars/all.yml');
|
|
const relayUnit = read('ops/systemd/morphit-relay.service');
|
|
const deployScript = read('ops/scripts/deploy-mcp.sh');
|
|
const vapidScript = read('scripts/generate-vapid-keys.sh');
|
|
const mcpUnit = read('ops/systemd/morphit-mcp.service');
|
|
const indexerUnit = read('ops/systemd/morphit-indexer.service');
|
|
const mcpMain = read('apps/mcp-server/src/main.ts');
|
|
const upgradeTs = read('apps/ops-cli/src/commands/upgrade.ts');
|
|
const mcpEnvTemplate = read('ops/ansible/roles/morphit/templates/mcp.env.j2');
|
|
const bunkerwebTasks = read('ops/ansible/roles/bunkerweb/tasks/main.yml');
|
|
const frontendNginx = read('ops/bunkerweb/frontend/nginx.conf');
|
|
const webConf = read('ops/nginx/web.conf');
|
|
const indexerEnvTmpl = read('ops/ansible/roles/morphit/templates/indexer.env.j2');
|
|
|
|
// ── group_vars defaults ───────────────────────────────────────────
|
|
check(
|
|
'group_vars: morphit_mcp_enabled defaults true',
|
|
/^morphit_mcp_enabled:\s*true\s*$/m.test(groupVars)
|
|
);
|
|
check(
|
|
'group_vars: morphit_enable_web_push defaults true',
|
|
/^morphit_enable_web_push:\s*true\s*$/m.test(groupVars)
|
|
);
|
|
check(
|
|
'group_vars: morphit_vapid_subject defined (origin-derived)',
|
|
/^morphit_vapid_subject:\s*".*morphit_domain.*"\s*$/m.test(groupVars)
|
|
);
|
|
|
|
// ── MCP role wiring ───────────────────────────────────────────────
|
|
check('role: creates morphit-mcp group', /ansible\.builtin\.group:[\s\S]*?name:\s*morphit-mcp/.test(roleMain));
|
|
check(
|
|
'role: creates morphit-mcp user (system, nologin)',
|
|
/ansible\.builtin\.user:[\s\S]*?name:\s*morphit-mcp[\s\S]*?system:\s*true[\s\S]*?nologin/.test(roleMain)
|
|
);
|
|
check('role: ensures /opt/morphit-mcp dir', /path:\s*\/opt\/morphit-mcp[\s\S]*?state:\s*directory/.test(roleMain));
|
|
check('role: runs deploy-mcp.sh', /deploy-mcp\.sh/.test(roleMain));
|
|
check(
|
|
'role: installs morphit-mcp.service unit',
|
|
/dest:\s*\/etc\/systemd\/system\/morphit-mcp\.service/.test(roleMain)
|
|
);
|
|
check(
|
|
'role: enables + starts morphit-mcp',
|
|
/name:\s*morphit-mcp\s*\n\s*enabled:\s*true\s*\n\s*state:\s*started/.test(roleMain)
|
|
);
|
|
// every MCP task is gated on the toggle (count `when: morphit_mcp_enabled`
|
|
// occurrences ≥ the number of MCP tasks: group, user, dir, deploy, unit, enable = 6)
|
|
const mcpGateCount = (roleMain.match(/when:\s*morphit_mcp_enabled\s*\|\s*bool/g) ?? []).length;
|
|
check(`role: MCP tasks gated on morphit_mcp_enabled (found ${mcpGateCount}, need >=6)`, mcpGateCount >= 6);
|
|
check('handlers: Restart morphit-mcp exists', /name:\s*Restart morphit-mcp/.test(roleHandlers));
|
|
|
|
// ── deploy-mcp.sh isolation contract ──────────────────────────────
|
|
check('deploy-mcp.sh: vendors asset-registry', /vendor\/asset-registry/.test(deployScript));
|
|
check('deploy-mcp.sh: vendors net-defense', /vendor\/net-defense/.test(deployScript));
|
|
check(
|
|
'deploy-mcp.sh: rewrites @morphit/* to file: deps',
|
|
/file:\.\/vendor\/asset-registry/.test(deployScript) && /file:\.\/vendor\/net-defense/.test(deployScript)
|
|
);
|
|
check('deploy-mcp.sh: runs npm install', /npm install/.test(deployScript));
|
|
check('deploy-mcp.sh: chowns to the service user (isolation)', /chown\s+-R\s+"?\$SVC_USER/.test(deployScript));
|
|
// the unit it deploys for must actually be the isolated one
|
|
check('mcp unit: runs as morphit-mcp from /opt/morphit-mcp', /User=morphit-mcp/.test(mcpUnit) && /WorkingDirectory=\/opt\/morphit-mcp/.test(mcpUnit));
|
|
|
|
// ── Web push / VAPID wiring ───────────────────────────────────────
|
|
check(
|
|
'role: generates VAPID with a creates: guard (generate-once)',
|
|
/generate-vapid-keys\.sh[\s\S]*?creates:\s*\/etc\/morphit\/relay-vapid\.env/.test(roleMain)
|
|
);
|
|
check(
|
|
'role: VAPID generation gated on morphit_enable_web_push',
|
|
/generate-vapid-keys\.sh[\s\S]*?when:\s*morphit_enable_web_push\s*\|\s*bool/.test(roleMain)
|
|
);
|
|
check(
|
|
'role: VAPID generation passes --bare --subject',
|
|
/generate-vapid-keys\.sh[\s\S]*?--bare[\s\S]*?--subject/.test(roleMain)
|
|
);
|
|
check(
|
|
'role: locks down the VAPID env file',
|
|
/path:\s*\/etc\/morphit\/relay-vapid\.env[\s\S]*?mode:\s*'0640'/.test(roleMain)
|
|
);
|
|
check(
|
|
'relay unit: sources /etc/morphit/relay-vapid.env (in the guarded source list)',
|
|
/\/etc\/morphit\/relay-vapid\.env/.test(relayUnit) &&
|
|
/for f in[^;]*relay-vapid\.env[^;]*;\s*do\s*\[\s*-f\s*"\$f"\s*\]\s*&&\s*\.\s*"\$f"/.test(relayUnit)
|
|
);
|
|
check('vapid script: supports --subject', /--subject/.test(vapidScript));
|
|
check('vapid script: supports --bare/--env', /--bare\|--env/.test(vapidScript));
|
|
|
|
// ── Env-routing: relay + indexer units source BOTH layouts ────────
|
|
// (the cp251 divergence fix — they must source the /etc/morphit/*.env
|
|
// files the Ansible playbook writes, not only the ops-cli /opt files)
|
|
check(
|
|
'relay unit sources /etc/morphit/relay.env (Ansible layout)',
|
|
/\/etc\/morphit\/relay\.env/.test(relayUnit)
|
|
);
|
|
check(
|
|
'relay unit still sources /opt/morphit/morphit.env (ops-cli layout)',
|
|
/\/opt\/morphit\/morphit\.env/.test(relayUnit)
|
|
);
|
|
check(
|
|
'indexer unit sources /etc/morphit/indexer.env (Ansible layout)',
|
|
/\/etc\/morphit\/indexer\.env/.test(indexerUnit)
|
|
);
|
|
check(
|
|
'indexer unit still sources /opt/morphit/morphit.env (ops-cli layout)',
|
|
/\/opt\/morphit\/morphit\.env/.test(indexerUnit)
|
|
);
|
|
|
|
// ── MCP config: own optional mcp.env, NOT the stale required relay.env
|
|
check(
|
|
'mcp unit reads optional /etc/morphit/mcp.env',
|
|
/EnvironmentFile=-\/etc\/morphit\/mcp\.env/.test(mcpUnit)
|
|
);
|
|
check(
|
|
'mcp unit no longer hard-requires relay.env (isolation + no stale dep)',
|
|
!/EnvironmentFile=\/etc\/morphit\/relay\.env/.test(mcpUnit)
|
|
);
|
|
check('role deploys mcp.env template', /mcp\.env\.j2/.test(roleMain) && /dest:\s*\/etc\/morphit\/mcp\.env/.test(roleMain));
|
|
check(
|
|
'group_var morphit_mcp_instance_url defined (origin-derived)',
|
|
/^morphit_mcp_instance_url:\s*".*morphit_domain.*"\s*$/m.test(groupVars)
|
|
);
|
|
|
|
// ── MCP HTTP transport wiring (beta16 §45) ────────────────────────
|
|
// The unit MUST run the HTTP transport: a plain stdio daemon reads EOF
|
|
// on a service's empty stdin and exits 0 in <1s (correct for local
|
|
// agent spawning, fatal for a persistent service). And it must bind
|
|
// loopback + restart forever.
|
|
check(
|
|
'mcp unit runs the HTTP transport (Environment=MORPHIT_MCP_TRANSPORT=http)',
|
|
/^Environment=MORPHIT_MCP_TRANSPORT=http\s*$/m.test(mcpUnit)
|
|
);
|
|
check(
|
|
'mcp unit pins a loopback bind (Environment=MORPHIT_MCP_HTTP_HOST=127.0.0.1)',
|
|
/^Environment=MORPHIT_MCP_HTTP_HOST=127\.0\.0\.1\s*$/m.test(mcpUnit)
|
|
);
|
|
check('mcp unit restarts forever (Restart=always)', /^Restart=always\s*$/m.test(mcpUnit));
|
|
check(
|
|
'mcp unit hardened with a seccomp allowlist (SystemCallFilter=@system-service)',
|
|
/^SystemCallFilter=@system-service\s*$/m.test(mcpUnit)
|
|
);
|
|
// The server actually implements an HTTP transport (not stdio-only).
|
|
check(
|
|
'main.ts imports StreamableHTTPServerTransport',
|
|
/import\s*\{\s*StreamableHTTPServerTransport\s*\}\s*from\s*'@modelcontextprotocol\/sdk\/server\/streamableHttp\.js'/.test(
|
|
mcpMain
|
|
)
|
|
);
|
|
check(
|
|
'main.ts selects transport on MORPHIT_MCP_TRANSPORT and has startHttpTransport',
|
|
/MORPHIT_MCP_TRANSPORT/.test(mcpMain) && /startHttpTransport/.test(mcpMain)
|
|
);
|
|
check(
|
|
'main.ts is fail-closed: refuses non-loopback bind without override',
|
|
/MORPHIT_MCP_ALLOW_PUBLIC_BIND/.test(mcpMain) && /isLoopbackHost/.test(mcpMain)
|
|
);
|
|
check('main.ts serves a /health endpoint', /'\/health'/.test(mcpMain));
|
|
check(
|
|
'main.ts allows private/bridge binds (isPrivateIp in the bind guard, not loopback-only)',
|
|
/isPrivateIp/.test(mcpMain) && /bindAllowedByDefault/.test(mcpMain)
|
|
);
|
|
// The unit's EnvironmentFile must be read AFTER the Environment= defaults
|
|
// so /etc/morphit/mcp.env can override the bind host (e.g. a dockerized
|
|
// proxy host sets MORPHIT_MCP_HTTP_HOST=172.18.0.1). systemd is
|
|
// last-assignment-wins, so order matters.
|
|
const envHostIdx = mcpUnit.indexOf('Environment=MORPHIT_MCP_HTTP_HOST=127.0.0.1');
|
|
const envFileIdx = mcpUnit.indexOf('EnvironmentFile=-/etc/morphit/mcp.env');
|
|
check(
|
|
'mcp unit reads mcp.env AFTER the Environment= defaults (override ordering)',
|
|
envHostIdx > -1 && envFileIdx > envHostIdx
|
|
);
|
|
|
|
// ── upgrade.ts redeploys + restarts the MCP (existing nodes) ──────
|
|
// The MCP's vendored tree at /opt/morphit-mcp is NOT updated by the
|
|
// install-dir swap, so upgrade must re-run deploy-mcp.sh + restart it,
|
|
// gated on the unit being installed.
|
|
check('upgrade.ts re-runs deploy-mcp.sh', /deploy-mcp\.sh/.test(upgradeTs));
|
|
check(
|
|
'upgrade.ts restarts morphit-mcp after redeploy',
|
|
/\['restart',\s*'morphit-mcp\.service'\]/.test(upgradeTs)
|
|
);
|
|
check(
|
|
'upgrade.ts gates the MCP step on the unit being installed',
|
|
/morphit-mcp\.service/.test(upgradeTs) && /existsSync\(mcpUnitPath\)/.test(upgradeTs)
|
|
);
|
|
check(
|
|
'mcp.env.j2 documents the MORPHIT_MCP_TRANSPORT=http knob',
|
|
/MORPHIT_MCP_TRANSPORT=http/.test(mcpEnvTemplate)
|
|
);
|
|
|
|
// ── MCP public exposure wired into the canonical BunkerWeb path (§45) ──
|
|
// Closes the cp255 gap: a fresh Ansible node's MCP must be reachable
|
|
// through BunkerWeb with NO manual step (it was loopback-only + unrouted).
|
|
check(
|
|
'group_vars: morphit_mcp_bind_host + morphit_mcp_bind_port defined',
|
|
/^morphit_mcp_bind_host:/m.test(groupVars) && /^morphit_mcp_bind_port:/m.test(groupVars)
|
|
);
|
|
check('group_vars: morphit_mcp_advertise defined', /^morphit_mcp_advertise:/m.test(groupVars));
|
|
check(
|
|
'mcp.env.j2 binds from morphit_mcp_bind_host and pairs an all-interfaces bind with ALLOW_PUBLIC_BIND',
|
|
/MORPHIT_MCP_HTTP_HOST=\{\{\s*morphit_mcp_bind_host/.test(mcpEnvTemplate) &&
|
|
/MORPHIT_MCP_ALLOW_PUBLIC_BIND=1/.test(mcpEnvTemplate) &&
|
|
/morphit_mcp_bind_host in \['0\.0\.0\.0'/.test(mcpEnvTemplate)
|
|
);
|
|
check(
|
|
'bunkerweb role opens UFW for the MCP port from bunkerweb_net, gated on morphit_mcp_enabled',
|
|
/port:\s*"\{\{\s*morphit_mcp_bind_port\s*\}\}"/.test(bunkerwebTasks) &&
|
|
/when:\s*morphit_mcp_enabled\s*\|\s*bool/.test(bunkerwebTasks)
|
|
);
|
|
check(
|
|
'BunkerWeb frontend nginx proxies /mcp to the host MCP with a loopback Host upstream',
|
|
/location \/mcp\b/.test(frontendNginx) &&
|
|
/proxy_pass http:\/\/host\.docker\.internal:8124/.test(frontendNginx) &&
|
|
/proxy_set_header Host 127\.0\.0\.1:8124/.test(frontendNginx)
|
|
);
|
|
check(
|
|
'bare-metal web.conf proxies /mcp to the loopback MCP with a loopback Host upstream',
|
|
/location \/mcp\b/.test(webConf) &&
|
|
/proxy_pass http:\/\/127\.0\.0\.1:8124/.test(webConf) &&
|
|
/proxy_set_header Host 127\.0\.0\.1:8124/.test(webConf)
|
|
);
|
|
check(
|
|
'indexer.env.j2 advertises mcp_url only when the MCP is BOTH enabled and advertise-opted-in',
|
|
/MORPHIT_MCP_ADVERTISE=\{\{\s*\(morphit_mcp_enabled\s*\|\s*bool\s*and\s*morphit_mcp_advertise\s*\|\s*bool\)/.test(
|
|
indexerEnvTmpl
|
|
)
|
|
);
|
|
|
|
// ── Result ────────────────────────────────────────────────────────
|
|
if (failures.length > 0) {
|
|
console.error(`mcp-webpush-install-defaults-smoke: ${failures.length} FAILED of ${checks}:`);
|
|
for (const f of failures) console.error(` ✗ ${f}`);
|
|
process.exit(1);
|
|
}
|
|
console.log(`✓ all ${checks} mcp-webpush-install-defaults-smoke scenarios passed`);
|