Skip to content

Commit 041c24e

Browse files
authored
feat: Add Docker healthcheck (#70)
* feat: add healthcheck * Move healthcheck to typescript and add tests
1 parent edb6a87 commit 041c24e

File tree

7 files changed

+156
-3
lines changed

7 files changed

+156
-3
lines changed

Dockerfile

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,5 +25,9 @@ WORKDIR /app
2525
COPY --from=builder /build/node_modules ./node_modules
2626
COPY . .
2727

28+
# healthcheck script
29+
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
30+
CMD npm -s run healthcheck
31+
2832
# default command
2933
CMD ["npm", "run", "start"]

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@
1414
"lint": "eslint",
1515
"format": "prettier -l --write \"src/**/*.{ts,js,json,md}\" \"tests/**/*.{ts,js,json,md}\"",
1616
"format:check": "prettier --check \"src/**/*.{ts,js,json,md}\" \"tests/**/*.{ts,js,json,md}\"",
17-
"agent": "tsx src/cli-agent.ts"
17+
"agent": "tsx src/cli-agent.ts",
18+
"healthcheck": "tsx src/healthcheck.ts"
1819
},
1920
"author": "",
2021
"license": "ISC",

src/bot.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,7 @@ export function useBot(bot_token?: string) {
1212
}
1313
return bots[bot_token];
1414
}
15+
16+
export function getBots() {
17+
return bots;
18+
}

src/healthcheck.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import http from "http";
2+
import { fileURLToPath } from "url";
3+
import { resolve } from "path";
4+
5+
type HealthResponse = {
6+
botsRunning: boolean;
7+
mqttConnected: boolean;
8+
};
9+
10+
export async function request(path: string): Promise<{
11+
statusCode: number;
12+
data: string;
13+
}> {
14+
return new Promise((resolve) => {
15+
const req = http.get(
16+
{ host: "localhost", port: process.env.PORT || 7586, path },
17+
(res) => {
18+
let data = "";
19+
res.on("data", (c) => (data += c));
20+
res.on("end", () => resolve({ statusCode: res.statusCode || 0, data }));
21+
},
22+
);
23+
req.on("error", () => resolve({ statusCode: 0, data: "" }));
24+
});
25+
}
26+
27+
export const runHealthcheck = async () => {
28+
const ping = await request("/ping");
29+
if (ping.statusCode !== 200) {
30+
console.error("Ping failed");
31+
return false;
32+
}
33+
const health = await request("/health");
34+
if (health.statusCode !== 200) {
35+
console.error("Health endpoint unavailable");
36+
return false;
37+
}
38+
try {
39+
const { botsRunning, mqttConnected } = JSON.parse(
40+
health.data,
41+
) as HealthResponse;
42+
if (!botsRunning || !mqttConnected) {
43+
console.error("Bots or MQTT unhealthy");
44+
return false;
45+
}
46+
} catch {
47+
console.error("Invalid health response");
48+
return false;
49+
}
50+
return true;
51+
};
52+
53+
(async () => {
54+
const isMain = (() => {
55+
const currentFilePath = fileURLToPath(import.meta.url);
56+
const scriptPath = process.argv[1]
57+
? resolve(process.cwd(), process.argv[1])
58+
: "";
59+
return scriptPath === currentFilePath;
60+
})();
61+
if (isMain) {
62+
const ok = await runHealthcheck();
63+
process.exit(ok ? 0 : 1);
64+
}
65+
})();

src/index.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
import { initCommands } from "./commands.ts";
1212
import { log } from "./helpers.ts";
1313
import express from "express";
14-
import { useBot } from "./bot";
14+
import { useBot, getBots } from "./bot";
1515
import onTextMessage from "./handlers/onTextMessage.ts";
1616
import onPhoto from "./handlers/onPhoto.ts";
1717
import onAudio from "./handlers/onAudio.ts";
@@ -22,7 +22,7 @@ import {
2222
agentPostHandler,
2323
toolPostHandler,
2424
} from "./httpHandlers.ts";
25-
import { useMqtt } from "./mqtt.ts";
25+
import { useMqtt, isMqttConnected } from "./mqtt.ts";
2626

2727
process.on("uncaughtException", (error, source) => {
2828
console.log("Uncaught Exception:", error);
@@ -160,6 +160,19 @@ function initHttp() {
160160
res.send("pong");
161161
});
162162

163+
app.get("/health", (_req, res) => {
164+
const bots = getBots();
165+
const botsRunning = Object.values(bots).every((bot) => {
166+
const polling = (
167+
bot as unknown as {
168+
polling?: { abortController: { signal: AbortSignal } };
169+
}
170+
).polling;
171+
return polling && !polling.abortController.signal.aborted;
172+
});
173+
res.json({ botsRunning, mqttConnected: isMqttConnected() });
174+
});
175+
163176
// Add route handler to create a virtual message and call onMessage
164177
// @ts-expect-error express types need proper request/response typing
165178
app.post("/telegram/:chatId", telegramPostHandler);

src/mqtt.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { log } from "./helpers.ts";
55

66
const MQTT_LOG_PATH = "data/mqtt.log";
77
let client: mqtt.MqttClient | undefined;
8+
let connected = false;
89

910
export function useMqtt() {
1011
if (client) return client;
@@ -18,10 +19,12 @@ export function useMqtt() {
1819
});
1920
client.on("connect", () => {
2021
log({ msg: "mqtt connected", logPath: MQTT_LOG_PATH });
22+
connected = true;
2123
client?.subscribe(`${cfg.base}/+`);
2224
});
2325
client.on("offline", () => {
2426
log({ msg: "mqtt offline", logPath: MQTT_LOG_PATH });
27+
connected = false;
2528
});
2629
client.on("message", async (topic, message) => {
2730
if (!client) return;
@@ -51,3 +54,7 @@ export function publishMqttProgress(msg: string, agent?: string) {
5154
if (!agent) return;
5255
client?.publish(`${cfg.base}/${agent}/progress`, msg);
5356
}
57+
58+
export function isMqttConnected() {
59+
return connected;
60+
}

tests/healthcheck.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { jest, describe, it, expect, beforeEach } from "@jest/globals";
2+
import { EventEmitter } from "events";
3+
4+
const mockGet = jest.fn();
5+
6+
jest.unstable_mockModule("http", () => ({
7+
__esModule: true,
8+
default: { get: (...args: unknown[]) => mockGet(...args) },
9+
get: (...args: unknown[]) => mockGet(...args),
10+
}));
11+
12+
let runHealthcheck: typeof import("../src/healthcheck").runHealthcheck;
13+
14+
beforeEach(async () => {
15+
jest.resetModules();
16+
mockGet.mockReset();
17+
({ runHealthcheck } = await import("../src/healthcheck"));
18+
});
19+
20+
function mockResponse(path: string, status: number, data: string) {
21+
mockGet.mockImplementationOnce(
22+
(
23+
_opts: unknown,
24+
cb: (res: EventEmitter & { statusCode?: number }) => void,
25+
) => {
26+
const res = new EventEmitter() as EventEmitter & { statusCode?: number };
27+
res.statusCode = status;
28+
cb(res);
29+
process.nextTick(() => {
30+
if (data) res.emit("data", data);
31+
res.emit("end");
32+
});
33+
return { on: jest.fn() } as unknown as EventEmitter;
34+
},
35+
);
36+
}
37+
38+
describe("runHealthcheck", () => {
39+
it("returns true on healthy response", async () => {
40+
mockResponse("/ping", 200, "pong");
41+
mockResponse(
42+
"/health",
43+
200,
44+
JSON.stringify({ botsRunning: true, mqttConnected: true }),
45+
);
46+
await expect(runHealthcheck()).resolves.toBe(true);
47+
});
48+
49+
it("returns false on ping failure", async () => {
50+
mockResponse("/ping", 500, "");
51+
await expect(runHealthcheck()).resolves.toBe(false);
52+
});
53+
54+
it("returns false on invalid health", async () => {
55+
mockResponse("/ping", 200, "pong");
56+
mockResponse("/health", 200, "oops");
57+
await expect(runHealthcheck()).resolves.toBe(false);
58+
});
59+
});

0 commit comments

Comments
 (0)