feat(health): добавить Globalping и мультивыбор источников проб
CD / update-wiki (push) Successful in 8s
quality / commitlint (push) Skipped
quality / changes (push) Successful in 5s
quality / docker-check (push) Skipped
quality / web (push) Successful in 54s
quality / api (push) Successful in 46s
CD / quality (push) Successful in 1m49s
CD / publish (push) Successful in 1m40s

Несколько источников проб сразу и правило агрегации на сервисе вместо XOR Local/Cloudflare.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-08-19 18:32:18 +07:00
co-authored by Cursor
parent 4c4908558b
commit b9bea44dce
31 changed files with 2671 additions and 271 deletions
+273
View File
@@ -0,0 +1,273 @@
import { describe, expect, it } from "vitest";
import { buildApp } from "../src/app.js";
import { loadConfig } from "../src/config.js";
import { repos, type Db } from "@cfdm/db";
import * as healthCheckService from "../src/services/health-check-service.js";
import {
buildMeasurementBody,
summarizeMeasurement,
} from "../src/lib/globalping-client.js";
import { aggregateHealthOk } from "@cfdm/shared";
import type { HealthCheckTarget } from "@cfdm/shared";
const thresholds = {
degradedFailures: 1,
downFailures: 2,
latencyWarnMs: 1000,
successRecoveries: 2,
};
function tcpTarget(overrides?: Partial<HealthCheckTarget>): HealthCheckTarget {
return {
scope: "binding",
ref_id: 1,
ip: "203.0.113.10",
hostname: "panel.example.com",
type: "tcp",
port: 443,
path: null,
expected_status: null,
timeout_ms: 400,
verify_tls: false,
provider: "globalping",
providers: ["globalping"],
aggregate: "majority",
...overrides,
};
}
async function seedBinding(
db: Db,
opts: {
ip: string;
providers: Array<"local" | "cloudflare" | "globalping">;
aggregate?: "any" | "all" | "majority";
port?: number;
},
) {
const domain = repos.createDomain(db, null, "example.com", "zone-1");
const service = repos.createService(db, "Panel", "panel");
repos.setServiceEnabled(db, service.id, true);
repos.replaceServiceIps(db, service.id, [opts.ip]);
const binding = repos.insertBinding(db, domain.id, service.id, "panel", null);
repos.replaceBindingIpsWithMeta(db, binding.id, [
{ ip: opts.ip, weight: 1, priority: 1 },
]);
repos.updateBindingLbConfig(db, binding.id, {
health_check_enabled: true,
health_check_type: "tcp",
health_check_port: opts.port ?? 1,
health_check_timeout_ms: 400,
health_check_providers: opts.providers,
health_check_aggregate: opts.aggregate ?? "majority",
});
return { service, binding, domain };
}
function mockFetch(handler: (url: string, init?: RequestInit) => Response): typeof fetch {
return (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
return handler(url, init);
}) as typeof fetch;
}
describe("aggregateHealthOk", () => {
it("any / all / majority", () => {
expect(aggregateHealthOk([true, false], "any")).toBe(false);
expect(aggregateHealthOk([true, false], "all")).toBe(true);
expect(aggregateHealthOk([true, false], "majority")).toBe(true);
expect(aggregateHealthOk([false, false], "majority")).toBe(false);
expect(aggregateHealthOk([true, false, false], "majority")).toBe(false);
expect(aggregateHealthOk([true, true, false], "majority")).toBe(true);
expect(aggregateHealthOk([true], "majority")).toBe(true);
});
});
describe("globalping mapping", () => {
it("maps CFDM TCP to ping+TCP and HTTP to http+host", () => {
const tcp = buildMeasurementBody(tcpTarget(), { limit: 3, locations: "World" });
expect(tcp.type).toBe("ping");
expect(tcp.measurementOptions.protocol).toBe("TCP");
expect(tcp.measurementOptions.port).toBe(443);
expect(tcp.inProgressUpdates).toBe(false);
const http = buildMeasurementBody(
tcpTarget({ type: "http", port: 443, path: "/health", expected_status: 200 }),
{ limit: 2, locations: "EU,US" },
);
expect(http.type).toBe("http");
expect(http.locations).toEqual([{ magic: "EU" }, { magic: "US" }]);
expect(http.measurementOptions.request).toMatchObject({
host: "panel.example.com",
path: "/health",
method: "GET",
});
});
it("summarizes HTTP majority and TCP packet loss", () => {
const httpOk = summarizeMeasurement(
tcpTarget({ type: "http", expected_status: 200 }),
{
status: "finished",
results: [
{ probe: { city: "Frankfurt", country: "DE" }, result: { status: "finished", statusCode: 200, timings: { total: 40 } } },
{ probe: { city: "London", country: "GB" }, result: { status: "finished", statusCode: 200, timings: { total: 50 } } },
{ probe: { city: "Paris", country: "FR" }, result: { status: "finished", statusCode: 500, timings: { total: 20 } } },
],
},
);
expect(httpOk.ok).toBe(true);
expect(httpOk.colo).toBe("Frankfurt, DE");
const tcpFail = summarizeMeasurement(tcpTarget(), {
status: "finished",
results: [
{ result: { status: "finished", stats: { avg: 12, loss: 100 } } },
{ result: { status: "finished", stats: { avg: 11, loss: 100 } } },
],
});
expect(tcpFail.ok).toBe(false);
});
});
describe("globalping engine", () => {
it("POST 202 + GET finished writes colo from probe city", async () => {
const app = await buildApp({
config: { ...loadConfig(), staticDir: null },
memory: true,
});
const { binding } = await seedBinding(app.db, {
ip: "203.0.113.40",
providers: ["globalping"],
port: 443,
});
const fetchImpl = mockFetch((url) => {
if (url.endsWith("/v1/measurements")) {
return new Response(JSON.stringify({ id: "meas-1" }), { status: 202 });
}
return new Response(
JSON.stringify({
id: "meas-1",
status: "finished",
results: [
{
probe: { city: "Amsterdam", country: "NL" },
result: { status: "finished", stats: { avg: 18, loss: 0 } },
},
{
probe: { city: "Frankfurt", country: "DE" },
result: { status: "finished", stats: { avg: 22, loss: 0 } },
},
],
}),
{ status: 200 },
);
});
await healthCheckService.runAllChecks(app.db, {
thresholds,
probeGapMs: 0,
globalping: {
token: "gp_test",
locations: "World",
limit: 2,
pollIntervalMs: 0,
fetchImpl,
},
});
const row = repos.getIpHealthStatusRow(
app.db,
"binding",
binding.id,
"203.0.113.40",
);
expect(row?.status).toBe("up");
expect(row?.provider).toBe("globalping");
expect(row?.colo).toMatch(/Amsterdam/);
await app.close();
});
it("429 fails the source and does not fall back to local", async () => {
const app = await buildApp({
config: { ...loadConfig(), staticDir: null },
memory: true,
});
const { binding } = await seedBinding(app.db, {
ip: "127.0.0.1",
providers: ["globalping"],
port: 1,
});
const fetchImpl = mockFetch(() => new Response("rate limited", { status: 429 }));
await healthCheckService.runAllChecks(app.db, {
thresholds,
probeGapMs: 0,
globalping: {
token: "gp_test",
locations: "World",
limit: 1,
pollIntervalMs: 0,
fetchImpl,
},
});
const row = repos.getIpHealthStatusRow(
app.db,
"binding",
binding.id,
"127.0.0.1",
);
expect(row?.last_error).toMatch(/429/i);
expect(row?.provider).toBe("globalping");
await app.close();
});
it("local+globalping all keeps IP up if Globalping is ok", async () => {
const app = await buildApp({
config: { ...loadConfig(), staticDir: null },
memory: true,
});
const { binding, service } = await seedBinding(app.db, {
ip: "127.0.0.1",
providers: ["local", "globalping"],
aggregate: "all",
port: 1,
});
const fetchImpl = mockFetch((url) => {
if (url.endsWith("/v1/measurements")) {
return new Response(JSON.stringify({ id: "meas-2" }), { status: 202 });
}
return new Response(
JSON.stringify({
status: "finished",
results: [
{ probe: { city: "Vienna", country: "AT" }, result: { status: "finished", stats: { avg: 9, loss: 0 } } },
],
}),
{ status: 200 },
);
});
await healthCheckService.runAllChecks(app.db, {
thresholds,
probeGapMs: 0,
globalping: {
token: "gp_test",
locations: "World",
limit: 1,
pollIntervalMs: 0,
fetchImpl,
},
});
const row = repos.getIpHealthStatusRow(
app.db,
"binding",
binding.id,
"127.0.0.1",
);
expect(row?.status).toBe("up");
expect(row?.provider).toBe("aggregate");
const logs = repos.listHealthProbeLogForService(app.db, service.id);
expect(logs.map((row) => row.provider).sort()).toEqual([
"globalping",
"local",
]);
await app.close();
});
});
+47
View File
@@ -164,4 +164,51 @@ describe("settings health engine", () => {
expect(body.healthWorkerToken).toBeUndefined();
await app.close();
});
it("GET exposes Globalping flags without the token; PATCH persists locations/limit", async () => {
const app = await buildApp({
config: { ...loadConfig(), staticDir: null },
memory: true,
});
const headers = await authHeaders(app);
const initial = await app.inject({
method: "GET",
url: "/api/v1/settings",
headers,
});
expect(initial.statusCode).toBe(200);
const before = initial.json() as {
globalpingTokenSet?: boolean;
globalpingLocations?: string;
globalpingLimit?: number;
globalpingToken?: string;
};
expect(before.globalpingTokenSet).toBe(false);
expect(before.globalpingLocations).toBe("World");
expect(before.globalpingLimit).toBe(3);
expect(before.globalpingToken).toBeUndefined();
const patched = await app.inject({
method: "PATCH",
url: "/api/v1/settings",
headers,
payload: {
globalpingToken: "gp_secret",
globalpingLocations: "EU,US",
globalpingLimit: 5,
},
});
expect(patched.statusCode).toBe(200);
const body = patched.json() as {
globalpingTokenSet: boolean;
globalpingLocations: string;
globalpingLimit: number;
globalpingToken?: string;
};
expect(body.globalpingTokenSet).toBe(true);
expect(body.globalpingLocations).toBe("EU,US");
expect(body.globalpingLimit).toBe(5);
expect(body.globalpingToken).toBeUndefined();
await app.close();
});
});