mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
The healthcheck probed only 127.0.0.1 and swallowed every error (empty Output), so containers binding to a non-loopback address always reported unhealthy with no diagnostic. Add a multi-host probe helper that succeeds on the first 2xx and prints the last error to stderr on total failure. Co-authored-by: naimo84 <naimo84@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
fa0aa1e25d
commit
9032a5a4ab
@@ -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<string>} 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();
|
||||
}
|
||||
|
||||
94
tests/unit/docker-healthcheck-3151.test.ts
Normal file
94
tests/unit/docker-healthcheck-3151.test.ts
Normal file
@@ -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<string>;
|
||||
};
|
||||
|
||||
/** 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<void> {
|
||||
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;
|
||||
}
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user