quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 8s
quality / changes (push) Successful in 5s
quality / docker-check (push) Skipped
quality / web (push) Successful in 51s
quality / api (push) Successful in 44s
CD / quality (push) Successful in 1m44s
CD / publish (push) Successful in 1m35s
- Added new health worker URL and token fields to the AppConfig interface, allowing for Cloudflare Worker integration. - Updated health check routes to utilize the new worker configuration, enabling dynamic health checks via Cloudflare Workers. - Introduced a health log endpoint for services, providing detailed logs of health probe results. - Enhanced health check service logic to support both local and Cloudflare Worker providers, improving flexibility in health monitoring. - Updated UI components to reflect changes in health check provider settings and display relevant health information. This commit significantly improves the health check management capabilities, allowing for better integration with Cloudflare Workers and enhanced logging features.
239 lines
7.2 KiB
TypeScript
239 lines
7.2 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 connector)", () => {
|
|
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,
|
|
verify_tls: false,
|
|
provider: "local",
|
|
};
|
|
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,
|
|
verify_tls: false,
|
|
provider: "local",
|
|
};
|
|
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,
|
|
verify_tls: false,
|
|
provider: "local",
|
|
};
|
|
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()));
|
|
}
|
|
});
|
|
|
|
it("physicalProbeKey collapses group+binding on same IP for tcp", async () => {
|
|
const { physicalProbeKey } = await import("../src/services/health-check-service.js");
|
|
const group: HealthCheckTarget = {
|
|
scope: "group",
|
|
ref_id: 1,
|
|
ip: "93.115.203.183",
|
|
hostname: "gt.rkns.top",
|
|
type: "tcp",
|
|
port: 443,
|
|
path: null,
|
|
expected_status: null,
|
|
timeout_ms: 3000,
|
|
verify_tls: false,
|
|
provider: "local",
|
|
};
|
|
const binding: HealthCheckTarget = {
|
|
...group,
|
|
scope: "binding",
|
|
ref_id: 2,
|
|
hostname: "rutg.rkns.top",
|
|
};
|
|
expect(physicalProbeKey(group)).toBe(physicalProbeKey(binding));
|
|
});
|
|
});
|
|
|
|
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");
|
|
});
|
|
|
|
it("listHealthCheckTargets includes verify_tls from binding config", 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: "http",
|
|
health_check_port: 443,
|
|
health_check_verify_tls: true,
|
|
});
|
|
repos.replaceBindingIpsWithMeta(db, binding.id, [
|
|
{ ip: "10.0.0.1", weight: 1, priority: 1 },
|
|
]);
|
|
|
|
const targets = repos.listHealthCheckTargets(db);
|
|
expect(targets).toHaveLength(1);
|
|
expect(targets[0]?.verify_tls).toBe(true);
|
|
});
|
|
});
|