169 lines
6.9 KiB
TypeScript
169 lines
6.9 KiB
TypeScript
/**
|
|
* upgrade-mcp-reachability — cp449.
|
|
*
|
|
* `morphit-ops upgrade` redeploys + restarts the MCP server (its own vendored
|
|
* tree at /opt/morphit-mcp), but until now nothing confirmed the restarted
|
|
* service actually came back up. cp449 adds a post-restart reachability probe.
|
|
*
|
|
* The subtlety this smoke pins is a REAL deployment fact: the MCP binds
|
|
* `MORPHIT_MCP_HTTP_HOST:MORPHIT_MCP_HTTP_PORT`, and on the canonical BunkerWeb
|
|
* VPS `/etc/morphit/mcp.env` sets `MORPHIT_MCP_HTTP_HOST=172.18.0.1` (the Docker
|
|
* bridge gateway) so the WAF/reverse-proxy can reach it. A probe that assumed
|
|
* loopback (127.0.0.1) would FALSE-NEGATIVE on that box. So the probe must read
|
|
* the CONFIGURED bind from mcp.env, and this smoke proves the resolver does.
|
|
*
|
|
* 1. resolveMcpHttpBind — defaults to 127.0.0.1:8124 (the systemd unit
|
|
* defaults) with no env file; honors host-only, host+port, quotes,
|
|
* comments, an invalid port (kept at default), and last-write-wins.
|
|
* 2. buildMcpHealthUrl — IPv4 and bracketed-IPv6 forms.
|
|
* 3. classifyMcpHealth — 200+{status:'ok'} → ok; wrong status/body → not ok.
|
|
* 4. WIRING — the probe is called after the MCP restart, is NON-FATAL (warn,
|
|
* not rollback), is gated inside the unit-installed block, and its failure
|
|
* message points at journalctl + the mcp.env bind (naming the 172.18.0.1
|
|
* Docker-bridge/BunkerWeb case).
|
|
*/
|
|
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join, dirname } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { readFileSync } from 'node:fs';
|
|
|
|
import {
|
|
resolveMcpHttpBind,
|
|
buildMcpHealthUrl,
|
|
classifyMcpHealth,
|
|
mcpEnvFile
|
|
} from '../src/commands/upgrade.ts';
|
|
|
|
let failures = 0;
|
|
function check(name: string, cond: boolean, detail = ''): void {
|
|
if (cond) {
|
|
console.log(` ✓ ${name}`);
|
|
} else {
|
|
console.log(` ✗ ${name}${detail ? `\n ${detail}` : ''}`);
|
|
failures++;
|
|
}
|
|
}
|
|
|
|
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
|
|
const upgradeSrc = readFileSync(
|
|
join(repoRoot, 'apps', 'ops-cli', 'src', 'commands', 'upgrade.ts'),
|
|
'utf8'
|
|
);
|
|
|
|
// Temp dir for mcp.env fixtures.
|
|
const dir = mkdtempSync(join(tmpdir(), 'mcp-reach-'));
|
|
function writeEnv(contents: string): string {
|
|
const p = join(dir, `mcp-${Math.random().toString(36).slice(2)}.env`);
|
|
writeFileSync(p, contents);
|
|
return p;
|
|
}
|
|
|
|
try {
|
|
// ─── 1. resolveMcpHttpBind ─────────────────────────────────────────────
|
|
const missing = join(dir, 'does-not-exist.env');
|
|
const d0 = resolveMcpHttpBind(missing);
|
|
check(
|
|
'no env file → unit defaults 127.0.0.1:8124',
|
|
d0.host === '127.0.0.1' && d0.port === 8124,
|
|
JSON.stringify(d0)
|
|
);
|
|
|
|
const d1 = resolveMcpHttpBind(writeEnv('MORPHIT_MCP_HTTP_HOST=172.18.0.1\n'));
|
|
check(
|
|
'host-only (172.18.0.1) → host set, port default 8124',
|
|
d1.host === '172.18.0.1' && d1.port === 8124,
|
|
JSON.stringify(d1)
|
|
);
|
|
|
|
const d2 = resolveMcpHttpBind(
|
|
writeEnv('# MCP bridge bind\nMORPHIT_MCP_HTTP_HOST="172.18.0.1"\nMORPHIT_MCP_HTTP_PORT=8200\n')
|
|
);
|
|
check(
|
|
'quoted host + explicit port, comment ignored',
|
|
d2.host === '172.18.0.1' && d2.port === 8200,
|
|
JSON.stringify(d2)
|
|
);
|
|
|
|
const d3 = resolveMcpHttpBind(writeEnv('MORPHIT_MCP_HTTP_PORT=not-a-number\n'));
|
|
check('invalid port kept at default 8124', d3.port === 8124, JSON.stringify(d3));
|
|
|
|
const d4 = resolveMcpHttpBind(writeEnv('MORPHIT_MCP_HTTP_PORT=70000\n'));
|
|
check('out-of-range port kept at default 8124', d4.port === 8124, JSON.stringify(d4));
|
|
|
|
const d5 = resolveMcpHttpBind(
|
|
writeEnv('MORPHIT_MCP_HTTP_HOST=127.0.0.1\nMORPHIT_MCP_HTTP_HOST=172.18.0.1\n')
|
|
);
|
|
check('last assignment wins (set -a semantics)', d5.host === '172.18.0.1', JSON.stringify(d5));
|
|
|
|
// mcpEnvFile honors MORPHIT_ETC_DIR
|
|
const prevEtc = process.env.MORPHIT_ETC_DIR;
|
|
process.env.MORPHIT_ETC_DIR = '/tmp/etc-morphit-test';
|
|
check(
|
|
'mcpEnvFile honors MORPHIT_ETC_DIR',
|
|
mcpEnvFile() === join('/tmp/etc-morphit-test', 'mcp.env'),
|
|
mcpEnvFile()
|
|
);
|
|
if (prevEtc === undefined) delete process.env.MORPHIT_ETC_DIR;
|
|
else process.env.MORPHIT_ETC_DIR = prevEtc;
|
|
|
|
// ─── 2. buildMcpHealthUrl ──────────────────────────────────────────────
|
|
check(
|
|
'IPv4 health URL',
|
|
buildMcpHealthUrl('172.18.0.1', 8124) === 'http://172.18.0.1:8124/health',
|
|
buildMcpHealthUrl('172.18.0.1', 8124)
|
|
);
|
|
check(
|
|
'IPv6 health URL is bracketed',
|
|
buildMcpHealthUrl('::1', 8124) === 'http://[::1]:8124/health',
|
|
buildMcpHealthUrl('::1', 8124)
|
|
);
|
|
|
|
// ─── 3. classifyMcpHealth ──────────────────────────────────────────────
|
|
check('200 + {status:ok} → ok', classifyMcpHealth(200, '{"status":"ok","transport":"http"}') === 'ok');
|
|
check('200 + wrong status field → bad_body', classifyMcpHealth(200, '{"status":"degraded"}') === 'bad_body');
|
|
check('500 → bad_status', classifyMcpHealth(500, '{"status":"ok"}') === 'bad_status');
|
|
check('200 + non-JSON → bad_body', classifyMcpHealth(200, '<html>oops</html>') === 'bad_body');
|
|
|
|
// ─── 4. WIRING (upgrade.ts) ────────────────────────────────────────────
|
|
check(
|
|
'upgrade calls resolveMcpHttpBind(mcpEnvFile())',
|
|
/resolveMcpHttpBind\(\s*mcpEnvFile\(\)\s*\)/.test(upgradeSrc)
|
|
);
|
|
check('upgrade awaits probeMcpHealth', /await\s+probeMcpHealth\(/.test(upgradeSrc));
|
|
// The probe lives inside the MCP-unit-installed block AND after the restart.
|
|
const mcpBlockIdx = upgradeSrc.indexOf('morphit-mcp.service');
|
|
const probeIdx = upgradeSrc.indexOf('await probeMcpHealth('); // the CALL site, not the def
|
|
const restartIdx = upgradeSrc.indexOf("['restart', 'morphit-mcp.service']");
|
|
check(
|
|
'probe is wired after the MCP restart',
|
|
restartIdx !== -1 && probeIdx !== -1 && probeIdx > restartIdx
|
|
);
|
|
check('probe is under the MCP block (unit-installed gate)', mcpBlockIdx !== -1 && probeIdx > mcpBlockIdx);
|
|
// Failure path is a warn (non-fatal), NOT a rollback.
|
|
const afterProbe = upgradeSrc.slice(probeIdx, probeIdx + 900);
|
|
check(
|
|
'unreachable path warns (non-fatal), does not roll back',
|
|
/probe\.reachable/.test(afterProbe) &&
|
|
/warn\(/.test(afterProbe) &&
|
|
!/rollback\(/.test(afterProbe)
|
|
);
|
|
check(
|
|
'failure guidance names journalctl + the mcp.env bind + the 172.18.0.1 bridge case',
|
|
/journalctl -u morphit-mcp/.test(afterProbe) &&
|
|
/MORPHIT_MCP_HTTP_HOST/.test(afterProbe) &&
|
|
/172\.18\.0\.1/.test(afterProbe)
|
|
);
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
|
|
const scenarios = 18;
|
|
console.log(`\n${'─'.repeat(56)}`);
|
|
if (failures === 0) {
|
|
console.log(`✓ all ${scenarios} upgrade-mcp-reachability scenarios passed`);
|
|
process.exit(0);
|
|
} else {
|
|
console.log(`✗ ${failures}/${scenarios} upgrade-mcp-reachability scenarios failed`);
|
|
process.exit(1);
|
|
}
|