Added new utility functions `hostForUrl` and `buildHttpProbeUrl` to improve URL construction for health checks. Updated `httpProbe` to utilize these functions, ensuring proper handling of FQDN and IP addresses. Enhanced tests to validate the new URL building logic and ensure correct behavior with different hostnames and IPs.
186 lines
5.5 KiB
TypeScript
186 lines
5.5 KiB
TypeScript
import { describe, expect, it, beforeAll, afterAll } from "vitest";
|
|
import { createServer, type Server } from "node:net";
|
|
import { createServer as createHttpServer, type Server as HttpServer } from "node:http";
|
|
import * as healthCheckService from "../src/services/health-check-service.js";
|
|
import type { HealthCheckTarget } from "@cfdm/shared";
|
|
|
|
function startTcpServer(): Promise<{ server: Server; port: number }> {
|
|
return new Promise((resolve) => {
|
|
const server = createServer();
|
|
server.listen(0, "127.0.0.1", () => {
|
|
const address = server.address();
|
|
const port =
|
|
typeof address === "object" && address ? address.port : 0;
|
|
resolve({ server, port });
|
|
});
|
|
});
|
|
}
|
|
|
|
describe("health-check URL helpers", () => {
|
|
it("buildHttpProbeUrl uses FQDN in URL (IP pinned via DNS interceptor)", () => {
|
|
expect(
|
|
healthCheckService.buildHttpProbeUrl("gt.rkns.top", 443, "/", true),
|
|
).toBe("https://gt.rkns.top/");
|
|
expect(
|
|
healthCheckService.buildHttpProbeUrl("gt.rkns.top", 8080, "/health", false),
|
|
).toBe("http://gt.rkns.top:8080/health");
|
|
expect(
|
|
healthCheckService.buildHttpProbeUrl("2001:db8::1", 443, "/", true),
|
|
).toBe("https://[2001:db8::1]/");
|
|
});
|
|
});
|
|
|
|
describe("health-check probeTarget", () => {
|
|
let server: Server;
|
|
let port: number;
|
|
|
|
beforeAll(async () => {
|
|
const started = await startTcpServer();
|
|
server = started.server;
|
|
port = started.port;
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
});
|
|
|
|
it("tcp probe succeeds for open port", async () => {
|
|
const target: HealthCheckTarget = {
|
|
scope: "binding",
|
|
ref_id: 1,
|
|
ip: "127.0.0.1",
|
|
hostname: "test.local",
|
|
type: "tcp",
|
|
port,
|
|
path: null,
|
|
expected_status: null,
|
|
timeout_ms: 1000,
|
|
};
|
|
const result = await healthCheckService.probeTarget(target);
|
|
expect(result.ok).toBe(true);
|
|
expect(result.error).toBeNull();
|
|
expect(result.latencyMs).toBeGreaterThanOrEqual(0);
|
|
});
|
|
|
|
it("tcp probe fails for closed port", async () => {
|
|
const target: HealthCheckTarget = {
|
|
scope: "binding",
|
|
ref_id: 1,
|
|
ip: "127.0.0.1",
|
|
hostname: "test.local",
|
|
type: "tcp",
|
|
port: 1,
|
|
path: null,
|
|
expected_status: null,
|
|
timeout_ms: 500,
|
|
};
|
|
const result = await healthCheckService.probeTarget(target);
|
|
expect(result.ok).toBe(false);
|
|
expect(result.error).not.toBeNull();
|
|
});
|
|
|
|
it("http probe hits IP with Host=hostname (same IP, different FQDN)", async () => {
|
|
let seenHost: string | undefined;
|
|
const httpServer: HttpServer = createHttpServer((req, res) => {
|
|
seenHost = req.headers.host;
|
|
res.writeHead(200);
|
|
res.end("ok");
|
|
});
|
|
const httpPort = await new Promise<number>((resolve) => {
|
|
httpServer.listen(0, "127.0.0.1", () => {
|
|
const address = httpServer.address();
|
|
resolve(typeof address === "object" && address ? address.port : 0);
|
|
});
|
|
});
|
|
|
|
try {
|
|
const groupTarget: HealthCheckTarget = {
|
|
scope: "group",
|
|
ref_id: 1,
|
|
ip: "127.0.0.1",
|
|
hostname: "gt.rkns.top",
|
|
type: "http",
|
|
port: httpPort,
|
|
path: "/",
|
|
expected_status: 200,
|
|
timeout_ms: 1000,
|
|
};
|
|
const bindingTarget: HealthCheckTarget = {
|
|
...groupTarget,
|
|
scope: "binding",
|
|
hostname: "rutg.rkns.top",
|
|
};
|
|
|
|
const groupResult = await healthCheckService.probeTarget(groupTarget);
|
|
expect(groupResult.ok).toBe(true);
|
|
expect(seenHost?.startsWith("gt.rkns.top")).toBe(true);
|
|
|
|
const bindingResult = await healthCheckService.probeTarget(bindingTarget);
|
|
expect(bindingResult.ok).toBe(true);
|
|
expect(seenHost?.startsWith("rutg.rkns.top")).toBe(true);
|
|
|
|
// Same loopback IP → latencies in the same ballpark (not ~1s DNS skew)
|
|
expect(Math.abs(groupResult.latencyMs - bindingResult.latencyMs)).toBeLessThan(200);
|
|
} finally {
|
|
await new Promise<void>((resolve) => httpServer.close(() => resolve()));
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("health-check state derivation via runAllChecks", () => {
|
|
it("marks ip down after threshold failures and up after recovery", async () => {
|
|
const { createMemoryDb, repos, runMigrations } = await import("@cfdm/db");
|
|
const { db, sqlite } = createMemoryDb();
|
|
runMigrations(sqlite);
|
|
|
|
const domain = repos.createDomain(db, null, "example.com", "zone-id");
|
|
const service = repos.createService(db, "Svc", "svc");
|
|
const binding = repos.insertBinding(
|
|
db,
|
|
domain.id,
|
|
service.id,
|
|
"@",
|
|
null,
|
|
);
|
|
repos.updateBindingLbConfig(db, binding.id, {
|
|
health_check_enabled: true,
|
|
health_check_type: "tcp",
|
|
health_check_port: 1,
|
|
health_check_timeout_ms: 200,
|
|
});
|
|
repos.replaceBindingIpsWithMeta(db, binding.id, [
|
|
{ ip: "127.0.0.1", weight: 1, priority: 1 },
|
|
]);
|
|
|
|
await healthCheckService.runAllChecks(db, {
|
|
thresholds: {
|
|
degradedFailures: 1,
|
|
downFailures: 2,
|
|
latencyWarnMs: 1000,
|
|
},
|
|
});
|
|
let status = repos.getIpHealthStatusRow(
|
|
db,
|
|
"binding",
|
|
binding.id,
|
|
"127.0.0.1",
|
|
);
|
|
expect(status?.status).toBe("degraded");
|
|
|
|
await healthCheckService.runAllChecks(db, {
|
|
thresholds: {
|
|
degradedFailures: 1,
|
|
downFailures: 2,
|
|
latencyWarnMs: 1000,
|
|
},
|
|
});
|
|
status = repos.getIpHealthStatusRow(
|
|
db,
|
|
"binding",
|
|
binding.id,
|
|
"127.0.0.1",
|
|
);
|
|
expect(status?.status).toBe("down");
|
|
});
|
|
});
|