diff --git a/scripts/dev/healthcheck.mjs b/scripts/dev/healthcheck.mjs index 7cb2cb2359..c54e876e27 100644 --- a/scripts/dev/healthcheck.mjs +++ b/scripts/dev/healthcheck.mjs @@ -2,13 +2,83 @@ /** * Docker healthcheck script for OmniRoute. - * Checks the /api/monitoring/health endpoint on the dashboard port. + * Probes the /api/monitoring/health endpoint on the dashboard port. * Used by Dockerfile and docker-compose files. + * + * #3151 — in some Docker network setups the server binds to a container IP and + * a probe against `127.0.0.1` is not reachable, while `localhost`/`::1` (or vice + * versa) is. The previous version probed ONLY `127.0.0.1` and swallowed every + * error, so the container was reported `unhealthy` with an empty, undiagnosable + * `State.Health[].Output`. We now try an ordered list of hosts and surface the + * last error on total failure. */ -const port = process.env.DASHBOARD_PORT || process.env.PORT || "20128"; -fetch(`http://127.0.0.1:${port}/api/monitoring/health`) - .then((r) => { - if (!r.ok) throw new Error(`HTTP ${r.status}`); - }) - .catch(() => process.exit(1)); +import { pathToFileURL } from "node:url"; + +const DEFAULT_HOSTS = ["127.0.0.1", "localhost", "::1"]; +const DEFAULT_TIMEOUT_MS = 4000; + +/** + * Build the health URL for a host, bracketing IPv6 literals (e.g. `::1`). + * @param {string} host + * @param {string|number} port + */ +function healthUrl(host, port) { + const hostPart = host.includes(":") ? `[${host}]` : host; + return `http://${hostPart}:${port}/api/monitoring/health`; +} + +/** + * Probe the health endpoint across an ordered list of hosts. Resolves with the + * first host that returns a 2xx response; rejects with the last error if every + * host fails. Each attempt is bounded by a per-host timeout so one unreachable + * host cannot hang the whole probe. + * + * @param {object} opts + * @param {string|number} opts.port + * @param {string[]} [opts.hosts] + * @param {typeof fetch} [opts.fetchImpl] + * @param {number} [opts.timeoutMs] + * @returns {Promise} the host that succeeded + */ +export async function probeHealth({ + port, + hosts = DEFAULT_HOSTS, + fetchImpl = fetch, + timeoutMs = DEFAULT_TIMEOUT_MS, +} = {}) { + let lastError = new Error("no hosts to probe"); + for (const host of hosts) { + try { + const res = await fetchImpl(healthUrl(host, port), { + signal: AbortSignal.timeout(timeoutMs), + }); + if (res.ok) return host; + lastError = new Error(`${host}: HTTP ${res.status}`); + } catch (err) { + lastError = new Error(`${host}: ${err instanceof Error ? err.message : String(err)}`); + } + } + throw lastError; +} + +async function main() { + const port = process.env.DASHBOARD_PORT || process.env.PORT || "20128"; + try { + await probeHealth({ port }); + process.exit(0); + } catch (err) { + // Surface the failure so `docker inspect ... .State.Health[].Output` is + // diagnostic instead of empty (#3151). + process.stderr.write(`healthcheck failed: ${err instanceof Error ? err.message : err}\n`); + process.exit(1); + } +} + +// Only auto-run when invoked as the entrypoint (so importing the helper in +// tests does not trigger a real probe + process.exit). +const isEntrypoint = + Boolean(process.argv[1]) && import.meta.url === pathToFileURL(process.argv[1]).href; +if (isEntrypoint) { + main(); +} diff --git a/tests/unit/docker-healthcheck-3151.test.ts b/tests/unit/docker-healthcheck-3151.test.ts new file mode 100644 index 0000000000..a8717d521d --- /dev/null +++ b/tests/unit/docker-healthcheck-3151.test.ts @@ -0,0 +1,94 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import http from "node:http"; + +// #3151 — Docker healthcheck always reports unhealthy because the probe only +// hit 127.0.0.1 and swallowed every error (empty Output, undiagnosable). +// The hardened script exports an injectable `probeHealth` helper that tries an +// ordered host list and surfaces the last error on total failure. +const { probeHealth } = (await import("../../scripts/dev/healthcheck.mjs")) as { + probeHealth: (opts: { + port: number | string; + hosts?: string[]; + fetchImpl?: typeof fetch; + timeoutMs?: number; + }) => Promise; +}; + +/** Start an ephemeral HTTP server bound only to the given host. */ +function startServer(host: string): Promise<{ server: http.Server; port: number }> { + return new Promise((resolve, reject) => { + const server = http.createServer((req, res) => { + if (req.url === "/api/monitoring/health") { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ status: "ok" })); + } else { + res.writeHead(404); + res.end(); + } + }); + server.on("error", reject); + server.listen(0, host, () => { + const addr = server.address(); + if (addr && typeof addr === "object") { + resolve({ server, port: addr.port }); + } else { + reject(new Error("no address")); + } + }); + }); +} + +function closeServer(server: http.Server): Promise { + return new Promise((resolve) => server.close(() => resolve())); +} + +const servers: http.Server[] = []; + +test.after(async () => { + for (const s of servers) { + await closeServer(s); + } +}); + +test("probeHealth resolves the host when the server is on 127.0.0.1", async () => { + const { server, port } = await startServer("127.0.0.1"); + servers.push(server); + + const ok = await probeHealth({ port, hosts: ["127.0.0.1", "localhost", "::1"] }); + assert.equal(ok, "127.0.0.1"); +}); + +test("probeHealth falls through to a later host when 127.0.0.1 is unreachable", async () => { + // Bind the real server on 127.0.0.1, but list an unreachable host first so + // the helper must fall through. We point the first host at a port with no + // listener to simulate ECONNREFUSED, then the real host on the same port. + const { server, port } = await startServer("127.0.0.1"); + servers.push(server); + + // First host resolves to nothing listening (use a host alias that will fail), + // second host is the working loopback. + const ok = await probeHealth({ + port, + hosts: ["192.0.2.1", "127.0.0.1"], // 192.0.2.1 = TEST-NET-1, unroutable + timeoutMs: 300, + }); + assert.equal(ok, "127.0.0.1"); +}); + +test("probeHealth throws a non-empty error string when every host fails", async () => { + // No server started: pick a port unlikely to have a listener. + await assert.rejects( + () => + probeHealth({ + port: 1, // privileged/closed port → connection refused + hosts: ["127.0.0.1", "localhost"], + timeoutMs: 300, + }), + (err: unknown) => { + assert.ok(err instanceof Error); + assert.ok(err.message.length > 0, "error message must be non-empty (not swallowed)"); + return true; + } + ); +});