feat: Implement load balancing and health check features for service groups, including DNS-based load balancing modes and health check configurations, enhancing service reliability and performance monitoring
Build, Test, and Push CFDM Docker Image / test (push) Successful in 4m0s
Build, Test, and Push CFDM Docker Image / build-and-push (push) Successful in 2m13s
Build, Test, and Push CFDM Docker Image / update-wiki (push) Failing after 6s
Build, Test, and Push CFDM Docker Image / create-release (push) Has been skipped
Build, Test, and Push CFDM Docker Image / test (push) Successful in 4m0s
Build, Test, and Push CFDM Docker Image / build-and-push (push) Successful in 2m13s
Build, Test, and Push CFDM Docker Image / update-wiki (push) Failing after 6s
Build, Test, and Push CFDM Docker Image / create-release (push) Has been skipped
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
import { describe, expect, it, beforeAll, afterAll } from "vitest";
|
||||
import { createServer, type Server } from "node:net";
|
||||
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 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();
|
||||
});
|
||||
});
|
||||
|
||||
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 },
|
||||
]);
|
||||
|
||||
// Запуск с closed port (port 1) — должен зафиксировать down после 2 проверок
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
selectActiveIpsByMode,
|
||||
type LbIpRow,
|
||||
type LbTargetConfig,
|
||||
} from "../src/services/service-config-service.js";
|
||||
|
||||
function row(
|
||||
ip: string,
|
||||
opts: Partial<Pick<LbIpRow, "weight" | "priority" | "health">> = {},
|
||||
): LbIpRow {
|
||||
return {
|
||||
ip,
|
||||
weight: opts.weight ?? 1,
|
||||
priority: opts.priority ?? 1,
|
||||
health: opts.health ?? "up",
|
||||
};
|
||||
}
|
||||
|
||||
describe("selectActiveIpsByMode", () => {
|
||||
it("round_robin returns all healthy ips, falls back to all if none healthy", () => {
|
||||
const config: LbTargetConfig = {
|
||||
lb_mode: "round_robin",
|
||||
health_check_enabled: true,
|
||||
};
|
||||
const rows = [
|
||||
row("1.1.1.1", { health: "up" }),
|
||||
row("2.2.2.2", { health: "down" }),
|
||||
row("3.3.3.3", { health: "up" }),
|
||||
];
|
||||
expect(selectActiveIpsByMode(config, rows).sort()).toEqual([
|
||||
"1.1.1.1",
|
||||
"3.3.3.3",
|
||||
]);
|
||||
});
|
||||
|
||||
it("round_robin returns all ips when none checked (unknown)", () => {
|
||||
const config: LbTargetConfig = {
|
||||
lb_mode: "round_robin",
|
||||
health_check_enabled: true,
|
||||
};
|
||||
const rows = [row("1.1.1.1", { health: "unknown" }), row("2.2.2.2")];
|
||||
expect(selectActiveIpsByMode(config, rows).sort()).toEqual([
|
||||
"1.1.1.1",
|
||||
"2.2.2.2",
|
||||
]);
|
||||
});
|
||||
|
||||
it("failover returns only min-priority healthy ips", () => {
|
||||
const config: LbTargetConfig = {
|
||||
lb_mode: "failover",
|
||||
health_check_enabled: true,
|
||||
};
|
||||
const rows = [
|
||||
row("1.1.1.1", { priority: 1, health: "up" }),
|
||||
row("2.2.2.2", { priority: 2, health: "up" }),
|
||||
row("3.3.3.3", { priority: 1, health: "down" }),
|
||||
];
|
||||
expect(selectActiveIpsByMode(config, rows)).toEqual(["1.1.1.1"]);
|
||||
});
|
||||
|
||||
it("failover falls back to min-priority ip among all when none healthy", () => {
|
||||
const config: LbTargetConfig = {
|
||||
lb_mode: "failover",
|
||||
health_check_enabled: true,
|
||||
};
|
||||
const rows = [
|
||||
row("1.1.1.1", { priority: 3, health: "down" }),
|
||||
row("2.2.2.2", { priority: 1, health: "down" }),
|
||||
row("3.3.3.3", { priority: 2, health: "down" }),
|
||||
];
|
||||
expect(selectActiveIpsByMode(config, rows)).toEqual(["2.2.2.2"]);
|
||||
});
|
||||
|
||||
it("weighted returns all healthy ips (one A per ip; weights stored for display)", () => {
|
||||
const config: LbTargetConfig = {
|
||||
lb_mode: "weighted",
|
||||
health_check_enabled: true,
|
||||
};
|
||||
const rows = [
|
||||
row("1.1.1.1", { weight: 3, health: "up" }),
|
||||
row("2.2.2.2", { weight: 1, health: "up" }),
|
||||
row("3.3.3.3", { weight: 2, health: "down" }),
|
||||
];
|
||||
expect(selectActiveIpsByMode(config, rows).sort()).toEqual([
|
||||
"1.1.1.1",
|
||||
"2.2.2.2",
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns empty array for no rows", () => {
|
||||
const config: LbTargetConfig = {
|
||||
lb_mode: "round_robin",
|
||||
health_check_enabled: true,
|
||||
};
|
||||
expect(selectActiveIpsByMode(config, [])).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user