Skip to content

Commit 17c6d42

Browse files
feat: add health check server implementation (#76)
- Add HealthCheckServer class for server health monitoring - Implement health check types and utilities - Add comprehensive unit tests for health check functionality - Integrate health check endpoints into server configuration - Update main server to include health check initialization
1 parent e37cee9 commit 17c6d42

File tree

7 files changed

+637
-0
lines changed

7 files changed

+637
-0
lines changed
Lines changed: 291 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,291 @@
1+
/**
2+
* Health Check Server Tests
3+
*/
4+
5+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
6+
import * as http from "node:http";
7+
import { HealthCheckServer, HealthCheckDependencies } from "../health/HealthCheckServer.js";
8+
import { HealthCheckConfig } from "../health/types.js";
9+
import { Logger } from "@lighthouse-tooling/shared";
10+
11+
function makeRequest(
12+
port: number,
13+
path: string,
14+
): Promise<{ statusCode: number; headers: http.IncomingHttpHeaders; body: string }> {
15+
return new Promise((resolve, reject) => {
16+
const req = http.get(`http://127.0.0.1:${port}${path}`, (res) => {
17+
let data = "";
18+
res.on("data", (chunk) => (data += chunk));
19+
res.on("end", () => {
20+
resolve({
21+
statusCode: res.statusCode ?? 0,
22+
headers: res.headers,
23+
body: data,
24+
});
25+
});
26+
});
27+
req.on("error", reject);
28+
});
29+
}
30+
31+
function createMockDeps(): HealthCheckDependencies {
32+
const mockAuthManager = {
33+
getCacheStats: vi.fn().mockReturnValue({
34+
enabled: true,
35+
size: 10,
36+
maxSize: 1000,
37+
hitRate: 0.85,
38+
}),
39+
authenticate: vi.fn(),
40+
getEffectiveApiKey: vi.fn(),
41+
destroy: vi.fn(),
42+
} as unknown as HealthCheckDependencies["authManager"];
43+
44+
const mockServiceFactory = {
45+
getStats: vi.fn().mockReturnValue({
46+
size: 3,
47+
maxSize: 50,
48+
oldestServiceAge: 5000,
49+
}),
50+
getService: vi.fn(),
51+
destroy: vi.fn(),
52+
} as unknown as HealthCheckDependencies["serviceFactory"];
53+
54+
const mockLighthouseService = {
55+
getStorageStats: vi.fn().mockReturnValue({
56+
fileCount: 5,
57+
totalSize: 1024,
58+
maxSize: 1073741824,
59+
utilization: 0.001,
60+
}),
61+
initialize: vi.fn(),
62+
uploadFile: vi.fn(),
63+
fetchFile: vi.fn(),
64+
pinFile: vi.fn(),
65+
unpinFile: vi.fn(),
66+
getFileInfo: vi.fn(),
67+
listFiles: vi.fn(),
68+
clear: vi.fn(),
69+
createDataset: vi.fn(),
70+
updateDataset: vi.fn(),
71+
getDataset: vi.fn(),
72+
listDatasets: vi.fn(),
73+
deleteDataset: vi.fn(),
74+
batchUploadFiles: vi.fn(),
75+
batchDownloadFiles: vi.fn(),
76+
} as unknown as HealthCheckDependencies["lighthouseService"];
77+
78+
const mockRegistry = {
79+
getMetrics: vi.fn().mockReturnValue({}),
80+
listTools: vi.fn().mockReturnValue([]),
81+
} as unknown as HealthCheckDependencies["registry"];
82+
83+
const logger = Logger.getInstance({ level: "error", component: "test" });
84+
85+
return {
86+
authManager: mockAuthManager,
87+
serviceFactory: mockServiceFactory,
88+
lighthouseService: mockLighthouseService,
89+
registry: mockRegistry,
90+
config: {
91+
name: "lighthouse-storage",
92+
version: "0.1.0",
93+
logLevel: "error" as const,
94+
maxStorageSize: 1073741824,
95+
enableMetrics: false,
96+
metricsInterval: 60000,
97+
},
98+
logger,
99+
};
100+
}
101+
102+
describe("HealthCheckServer", () => {
103+
let server: HealthCheckServer;
104+
let deps: HealthCheckDependencies;
105+
let port: number;
106+
107+
const healthConfig: HealthCheckConfig = {
108+
enabled: true,
109+
port: 0, // OS-assigned
110+
lighthouseApiUrl: "https://api.lighthouse.storage",
111+
connectivityCheckInterval: 30000,
112+
connectivityTimeout: 5000,
113+
};
114+
115+
beforeEach(async () => {
116+
deps = createMockDeps();
117+
server = new HealthCheckServer(deps, healthConfig);
118+
await server.start();
119+
port = server.getPort()!;
120+
});
121+
122+
afterEach(async () => {
123+
await server.stop();
124+
});
125+
126+
describe("/health endpoint", () => {
127+
it("should return 200 with healthy status", async () => {
128+
const res = await makeRequest(port, "/health");
129+
expect(res.statusCode).toBe(200);
130+
131+
const body = JSON.parse(res.body);
132+
expect(body.status).toBe("healthy");
133+
expect(body.version).toBe("0.1.0");
134+
expect(body.timestamp).toBeDefined();
135+
expect(typeof body.uptime).toBe("number");
136+
});
137+
138+
it("should include uptime in seconds", async () => {
139+
const res = await makeRequest(port, "/health");
140+
const body = JSON.parse(res.body);
141+
expect(body.uptime).toBeGreaterThanOrEqual(0);
142+
});
143+
144+
it("should set Content-Type to application/json", async () => {
145+
const res = await makeRequest(port, "/health");
146+
expect(res.headers["content-type"]).toBe("application/json");
147+
});
148+
});
149+
150+
describe("/ready endpoint", () => {
151+
it("should return 200 when all checks pass", async () => {
152+
// Mock the connectivity check to avoid real network calls
153+
vi.spyOn(server, "checkLighthouseConnectivity").mockResolvedValue({
154+
status: "up",
155+
latency_ms: 42,
156+
});
157+
158+
const res = await makeRequest(port, "/ready");
159+
expect(res.statusCode).toBe(200);
160+
161+
const body = JSON.parse(res.body);
162+
expect(body.status).toBe("ready");
163+
expect(body.timestamp).toBeDefined();
164+
expect(body.checks.sdk.status).toBe("up");
165+
expect(body.checks.cache.status).toBe("up");
166+
expect(body.checks.lighthouse_api.status).toBe("up");
167+
expect(body.checks.service_pool.status).toBe("up");
168+
});
169+
170+
it("should include cache stats in response", async () => {
171+
vi.spyOn(server, "checkLighthouseConnectivity").mockResolvedValue({
172+
status: "up",
173+
latency_ms: 10,
174+
});
175+
176+
const res = await makeRequest(port, "/ready");
177+
const body = JSON.parse(res.body);
178+
179+
expect(body.checks.cache.size).toBe(10);
180+
expect(body.checks.cache.maxSize).toBe(1000);
181+
expect(body.checks.cache.hitRate).toBe(0.85);
182+
});
183+
184+
it("should include service pool stats in response", async () => {
185+
vi.spyOn(server, "checkLighthouseConnectivity").mockResolvedValue({
186+
status: "up",
187+
latency_ms: 10,
188+
});
189+
190+
const res = await makeRequest(port, "/ready");
191+
const body = JSON.parse(res.body);
192+
193+
expect(body.checks.service_pool.size).toBe(3);
194+
expect(body.checks.service_pool.maxSize).toBe(50);
195+
});
196+
197+
it("should include lighthouse API latency", async () => {
198+
vi.spyOn(server, "checkLighthouseConnectivity").mockResolvedValue({
199+
status: "up",
200+
latency_ms: 45,
201+
});
202+
203+
const res = await makeRequest(port, "/ready");
204+
const body = JSON.parse(res.body);
205+
206+
expect(body.checks.lighthouse_api.latency_ms).toBe(45);
207+
});
208+
209+
it("should return 503 when SDK check fails", async () => {
210+
vi.spyOn(server, "checkLighthouseConnectivity").mockResolvedValue({
211+
status: "up",
212+
latency_ms: 10,
213+
});
214+
215+
const mockService = deps.lighthouseService as any;
216+
mockService.getStorageStats.mockImplementation(() => {
217+
throw new Error("SDK not initialized");
218+
});
219+
220+
const res = await makeRequest(port, "/ready");
221+
expect(res.statusCode).toBe(503);
222+
223+
const body = JSON.parse(res.body);
224+
expect(body.status).toBe("not_ready");
225+
expect(body.checks.sdk.status).toBe("down");
226+
});
227+
228+
it("should return 503 when Lighthouse API is unreachable", async () => {
229+
vi.spyOn(server, "checkLighthouseConnectivity").mockResolvedValue({
230+
status: "down",
231+
latency_ms: 0,
232+
});
233+
234+
const res = await makeRequest(port, "/ready");
235+
expect(res.statusCode).toBe(503);
236+
237+
const body = JSON.parse(res.body);
238+
expect(body.status).toBe("not_ready");
239+
expect(body.checks.lighthouse_api.status).toBe("down");
240+
});
241+
242+
it("should cache connectivity check results", async () => {
243+
const connectivitySpy = vi
244+
.spyOn(server, "checkLighthouseConnectivity")
245+
.mockResolvedValue({ status: "up", latency_ms: 30 });
246+
247+
await makeRequest(port, "/ready");
248+
await makeRequest(port, "/ready");
249+
250+
// The spy is called for each /ready request since it's the top-level method.
251+
// The internal caching is within checkLighthouseConnectivity itself.
252+
expect(connectivitySpy).toHaveBeenCalledTimes(2);
253+
});
254+
});
255+
256+
describe("error handling", () => {
257+
it("should return 404 for unknown paths", async () => {
258+
const res = await makeRequest(port, "/unknown");
259+
expect(res.statusCode).toBe(404);
260+
261+
const body = JSON.parse(res.body);
262+
expect(body.error).toBe("Not found");
263+
});
264+
265+
it("should return 404 for root path", async () => {
266+
const res = await makeRequest(port, "/");
267+
expect(res.statusCode).toBe(404);
268+
});
269+
});
270+
271+
describe("lifecycle", () => {
272+
it("should report the assigned port", () => {
273+
expect(port).toBeGreaterThan(0);
274+
});
275+
276+
it("should stop cleanly", async () => {
277+
await server.stop();
278+
// Attempting a request after stop should fail
279+
await expect(makeRequest(port, "/health")).rejects.toThrow();
280+
// Prevent afterEach from double-stopping
281+
server = new HealthCheckServer(deps, healthConfig);
282+
await server.start();
283+
port = server.getPort()!;
284+
});
285+
286+
it("should return null port when not started", () => {
287+
const unstartedServer = new HealthCheckServer(deps, healthConfig);
288+
expect(unstartedServer.getPort()).toBeNull();
289+
});
290+
});
291+
});

apps/mcp-server/src/config/server-config.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import { AuthConfig, PerformanceConfig } from "../auth/types.js";
66
import { MultiTenancyConfig, OrganizationSettings, UsageQuota } from "@lighthouse-tooling/types";
7+
import { HealthCheckConfig } from "../health/types.js";
78
import * as path from "path";
89
import * as os from "os";
910

@@ -27,6 +28,7 @@ export interface ServerConfig {
2728
performance?: PerformanceConfig;
2829
multiTenancy?: MultiTenancyConfig;
2930
connectionPool?: ConnectionPoolServerConfig;
31+
healthCheck?: HealthCheckConfig;
3032
}
3133

3234
/**
@@ -88,6 +90,14 @@ export const DEFAULT_CONNECTION_POOL_CONFIG: ConnectionPoolServerConfig = {
8890
keepAlive: process.env.LIGHTHOUSE_POOL_KEEP_ALIVE !== "false",
8991
};
9092

93+
export const DEFAULT_HEALTH_CHECK_CONFIG: HealthCheckConfig = {
94+
enabled: process.env.HEALTH_CHECK_ENABLED === "true",
95+
port: parseInt(process.env.HEALTH_CHECK_PORT || "8080", 10),
96+
lighthouseApiUrl: process.env.LIGHTHOUSE_API_URL || "https://api.lighthouse.storage",
97+
connectivityCheckInterval: 30000,
98+
connectivityTimeout: 5000,
99+
};
100+
91101
export const DEFAULT_ORGANIZATION_SETTINGS: OrganizationSettings = {
92102
defaultStorageQuota: 10 * 1024 * 1024 * 1024, // 10GB
93103
defaultRateLimit: 1000, // 1000 requests per minute
@@ -147,6 +157,7 @@ export function getDefaultServerConfig(): ServerConfig {
147157
performance: DEFAULT_PERFORMANCE_CONFIG,
148158
multiTenancy: DEFAULT_MULTI_TENANCY_CONFIG,
149159
connectionPool: DEFAULT_CONNECTION_POOL_CONFIG,
160+
healthCheck: DEFAULT_HEALTH_CHECK_CONFIG,
150161
};
151162
}
152163

@@ -166,6 +177,7 @@ export const DEFAULT_SERVER_CONFIG: ServerConfig = {
166177
performance: DEFAULT_PERFORMANCE_CONFIG,
167178
multiTenancy: DEFAULT_MULTI_TENANCY_CONFIG,
168179
connectionPool: DEFAULT_CONNECTION_POOL_CONFIG,
180+
healthCheck: DEFAULT_HEALTH_CHECK_CONFIG,
169181
};
170182

171183
/**

0 commit comments

Comments
 (0)