Skip to content

Commit 53527e6

Browse files
authored
feat(miner): add AMS hosted-container health HTTP endpoint (#7177) (#7185)
1 parent bc49b20 commit 53527e6

3 files changed

Lines changed: 249 additions & 0 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import type { Server } from "node:http";
2+
3+
export type ReadinessProbe = { name: string; check: () => Promise<boolean> };
4+
5+
export type Readiness = {
6+
ok: boolean;
7+
checks: Record<string, boolean>;
8+
durationsMs: Record<string, number>;
9+
};
10+
11+
export function buildHealthBody(): { status: "ok" };
12+
13+
export function readiness(probes?: ReadinessProbe[]): Promise<Readiness>;
14+
15+
export function createAmsHealthHandler(
16+
probes?: ReadinessProbe[],
17+
): (req: { method?: string; url?: string }, res: { writeHead: (status: number, headers: Record<string, string>) => void; end: (body: string) => void }) => Promise<void>;
18+
19+
export function startAmsHealthServer(options?: { port?: number; host?: string; probes?: ReadinessProbe[] }): Promise<Server>;
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { createServer } from "node:http";
2+
3+
// Minimal HTTP health surface for a hosted AMS container (#7177). AMS is otherwise CLI-only (loopover-miner
4+
// status/doctor) and the operator UI reads its SQLite files directly -- but a hosted control-plane polling
5+
// container health across a fleet (#4933/#4934) needs each container to answer over HTTP. This deliberately
6+
// mirrors ORB's src/selfhost/health.ts SHAPE -- `/health` -> `{ status: "ok" }` liveness, `/ready` -> a
7+
// `{ ok, checks, durationsMs }` readiness built from injectable ReadinessProbes -- so the same aggregator can
8+
// poll both products identically. It runs ONLY from the hosted-container entry point; the self-host CLI never
9+
// starts it, so self-host behavior is unchanged. No HTTP framework dependency: node:http is enough for two routes.
10+
11+
/** @typedef {{ name: string, check: () => Promise<boolean> }} ReadinessProbe */
12+
13+
/** Bare liveness body: the process is up and answering, independent of any backend it depends on. */
14+
export function buildHealthBody() {
15+
return { status: "ok" };
16+
}
17+
18+
/**
19+
* Readiness: run every injected probe and report per-probe pass/fail plus how long each took. `ok` is true only
20+
* when every probe passed -- a container that can't reach a backend it depends on must stop reporting ready so
21+
* the fleet aggregator can route around it. A probe that throws counts as failed (never crashes readiness), and
22+
* its duration is still recorded. Mirrors src/selfhost/health.ts's `readiness`/`timedReadinessCheck` behavior.
23+
*
24+
* @param {ReadinessProbe[]} [probes]
25+
* @returns {Promise<{ ok: boolean, checks: Record<string, boolean>, durationsMs: Record<string, number> }>}
26+
*/
27+
export async function readiness(probes = []) {
28+
const checks = {};
29+
const durationsMs = {};
30+
let ok = true;
31+
for (const probe of probes) {
32+
const startedAt = performance.now();
33+
let passed = false;
34+
try {
35+
passed = (await probe.check()) === true;
36+
} catch {
37+
passed = false;
38+
} finally {
39+
durationsMs[probe.name] = Math.max(0, performance.now() - startedAt);
40+
}
41+
checks[probe.name] = passed;
42+
if (!passed) ok = false;
43+
}
44+
return { ok, checks, durationsMs };
45+
}
46+
47+
function sendJson(res, status, body) {
48+
const payload = JSON.stringify(body);
49+
res.writeHead(status, { "content-type": "application/json" });
50+
res.end(payload);
51+
}
52+
53+
/**
54+
* Build the request handler for the AMS health surface: `GET /health` -> 200 liveness, `GET /ready` -> 200/503
55+
* readiness (503 when any probe fails, so a load balancer stops routing to a degraded container), anything else
56+
* -> 404. Exported separately from {@link startAmsHealthServer} so it can be exercised without binding a socket.
57+
*
58+
* @param {ReadinessProbe[]} [probes]
59+
*/
60+
export function createAmsHealthHandler(probes = []) {
61+
return async (req, res) => {
62+
const path = (req.url ?? "").split("?", 1)[0];
63+
if (req.method === "GET" && path === "/health") {
64+
sendJson(res, 200, buildHealthBody());
65+
return;
66+
}
67+
if (req.method === "GET" && path === "/ready") {
68+
const result = await readiness(probes);
69+
sendJson(res, result.ok ? 200 : 503, result);
70+
return;
71+
}
72+
sendJson(res, 404, { error: "not_found" });
73+
};
74+
}
75+
76+
/**
77+
* Start the AMS health HTTP server. Resolves once it is listening. `port: 0` binds an ephemeral port (the caller
78+
* reads `server.address()`), which is what the tests use. The hosted-container entry point owns the lifecycle and
79+
* passes the AMS-specific probes (store reachable, loop cycle alive); the returned server is closed on shutdown.
80+
*
81+
* @param {{ port?: number, host?: string, probes?: ReadinessProbe[] }} [options]
82+
* @returns {Promise<import("node:http").Server>}
83+
*/
84+
export function startAmsHealthServer(options = {}) {
85+
const port = Number.isInteger(options.port) ? options.port : 0;
86+
const host = typeof options.host === "string" && options.host ? options.host : "0.0.0.0";
87+
const probes = Array.isArray(options.probes) ? options.probes : [];
88+
const server = createServer(createAmsHealthHandler(probes));
89+
return new Promise((resolve) => {
90+
server.listen(port, host, () => resolve(server));
91+
});
92+
}
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import { afterEach, describe, expect, it } from "vitest";
2+
import type { Server } from "node:http";
3+
import {
4+
buildHealthBody,
5+
createAmsHealthHandler,
6+
readiness,
7+
startAmsHealthServer,
8+
} from "../../packages/loopover-miner/lib/ams-health-server.js";
9+
10+
const servers: Server[] = [];
11+
afterEach(() => {
12+
for (const server of servers.splice(0)) server.close();
13+
});
14+
15+
type CapturedRes = {
16+
out: { status: number; headers: Record<string, string>; body: string };
17+
writeHead(status: number, headers: Record<string, string>): void;
18+
end(body: string): void;
19+
};
20+
21+
function mockRes(): CapturedRes {
22+
const out = { status: 0, headers: {} as Record<string, string>, body: "" };
23+
return {
24+
out,
25+
writeHead(status, headers) {
26+
out.status = status;
27+
out.headers = headers;
28+
},
29+
end(body) {
30+
out.body = body;
31+
},
32+
};
33+
}
34+
35+
const passProbe = (name: string) => ({ name, check: async () => true });
36+
const failProbe = (name: string) => ({ name, check: async () => false });
37+
const throwProbe = (name: string) => ({
38+
name,
39+
check: async () => {
40+
throw new Error("backend unreachable");
41+
},
42+
});
43+
44+
describe("ams-health-server buildHealthBody / readiness (#7177)", () => {
45+
it("liveness body is a bare { status: 'ok' }", () => {
46+
expect(buildHealthBody()).toEqual({ status: "ok" });
47+
});
48+
49+
it("readiness with no probes is ok with empty check maps", async () => {
50+
expect(await readiness()).toEqual({ ok: true, checks: {}, durationsMs: {} });
51+
});
52+
53+
it("readiness is ok only when every probe passes, and times each one", async () => {
54+
const result = await readiness([passProbe("store"), passProbe("loop")]);
55+
expect(result.ok).toBe(true);
56+
expect(result.checks).toEqual({ store: true, loop: true });
57+
expect(typeof result.durationsMs.store).toBe("number");
58+
expect(result.durationsMs.loop).toBeGreaterThanOrEqual(0);
59+
});
60+
61+
it("readiness fails when any probe returns false or throws (never crashing)", async () => {
62+
const result = await readiness([passProbe("store"), failProbe("loop"), throwProbe("queue")]);
63+
expect(result.ok).toBe(false);
64+
expect(result.checks).toEqual({ store: true, loop: false, queue: false });
65+
expect(typeof result.durationsMs.queue).toBe("number"); // duration recorded even for the throwing probe
66+
});
67+
});
68+
69+
describe("ams-health-server request routing (#7177)", () => {
70+
it("GET /health returns 200 liveness", async () => {
71+
const res = mockRes();
72+
await createAmsHealthHandler()({ method: "GET", url: "/health" }, res);
73+
expect(res.out.status).toBe(200);
74+
expect(res.out.headers["content-type"]).toBe("application/json");
75+
expect(JSON.parse(res.out.body)).toEqual({ status: "ok" });
76+
});
77+
78+
it("GET /ready returns 200 when probes pass, 503 when they don't", async () => {
79+
const okRes = mockRes();
80+
await createAmsHealthHandler([passProbe("store")])({ method: "GET", url: "/ready" }, okRes);
81+
expect(okRes.out.status).toBe(200);
82+
expect(JSON.parse(okRes.out.body).ok).toBe(true);
83+
84+
const degradedRes = mockRes();
85+
await createAmsHealthHandler([failProbe("store")])({ method: "GET", url: "/ready?verbose=1" }, degradedRes);
86+
expect(degradedRes.out.status).toBe(503); // query string stripped, still matched /ready
87+
expect(JSON.parse(degradedRes.out.body).ok).toBe(false);
88+
});
89+
90+
it("unknown paths, non-GET methods, and missing urls all 404", async () => {
91+
const handler = createAmsHealthHandler();
92+
const unknown = mockRes();
93+
await handler({ method: "GET", url: "/metrics" }, unknown);
94+
expect(unknown.out.status).toBe(404);
95+
96+
const wrongMethod = mockRes();
97+
await handler({ method: "POST", url: "/health" }, wrongMethod);
98+
expect(wrongMethod.out.status).toBe(404);
99+
100+
const noUrl = mockRes();
101+
await handler({ method: "GET" }, noUrl); // url undefined -> "" -> 404
102+
expect(noUrl.out.status).toBe(404);
103+
expect(JSON.parse(noUrl.out.body)).toEqual({ error: "not_found" });
104+
});
105+
});
106+
107+
describe("ams-health-server listener (#7177)", () => {
108+
async function base(server: Server) {
109+
const address = server.address();
110+
if (address === null || typeof address === "string") throw new Error("expected a bound TCP address");
111+
return `http://127.0.0.1:${address.port}`;
112+
}
113+
114+
it("serves /health, /ready, and 404s over a real socket on an ephemeral port", async () => {
115+
const server = await startAmsHealthServer(); // all defaults: port 0, host 0.0.0.0, no probes
116+
servers.push(server);
117+
const url = await base(server);
118+
119+
const health = await fetch(`${url}/health`);
120+
expect(health.status).toBe(200);
121+
expect(await health.json()).toEqual({ status: "ok" });
122+
123+
const ready = await fetch(`${url}/ready`);
124+
expect(ready.status).toBe(200);
125+
126+
const missing = await fetch(`${url}/nope`);
127+
expect(missing.status).toBe(404);
128+
});
129+
130+
it("reports 503 over the socket when a wired probe fails", async () => {
131+
const server = await startAmsHealthServer({ port: 0, host: "127.0.0.1", probes: [failProbe("store")] });
132+
servers.push(server);
133+
const ready = await fetch(`${await base(server)}/ready`);
134+
expect(ready.status).toBe(503);
135+
const body = (await ready.json()) as { checks: Record<string, boolean> };
136+
expect(body.checks).toEqual({ store: false });
137+
});
138+
});

0 commit comments

Comments
 (0)