quality / changes (push) Successful in 9s
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
CD / update-wiki (push) Successful in 6s
quality / web (push) Successful in 1m4s
quality / api (push) Successful in 54s
CD / quality (push) Successful in 2m17s
CD / publish (push) Successful in 2m21s
Worker сам ходит на origin по Cron Trigger; CFDM кладёт цели в KV и забирает результаты без POST /probe. Co-authored-by: Cursor <[email protected]>
246 lines
6.9 KiB
TypeScript
246 lines
6.9 KiB
TypeScript
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 type { HealthMailbox } from "../src/services/health/mailbox.js";
|
|
import { originProbeKey } from "../src/services/health/mailbox.js";
|
|
import type { HealthCheckTarget } from "@cfdm/shared";
|
|
|
|
async function authHeaders(app: Awaited<ReturnType<typeof buildApp>>) {
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/auth/login",
|
|
payload: { username: "admin", password: "admin" },
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const { token } = res.json() as { token: string };
|
|
return { authorization: `Bearer ${token}` };
|
|
}
|
|
|
|
async function seedBinding(
|
|
db: Db,
|
|
opts: { provider: "local" | "cloudflare"; ip: string },
|
|
) {
|
|
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: 1,
|
|
health_check_timeout_ms: 400,
|
|
health_check_provider: opts.provider,
|
|
});
|
|
return { service, binding, domain };
|
|
}
|
|
|
|
const thresholds = {
|
|
degradedFailures: 1,
|
|
downFailures: 2,
|
|
latencyWarnMs: 1000,
|
|
successRecoveries: 2,
|
|
};
|
|
|
|
function memoryMailbox(opts?: {
|
|
resultsOk?: boolean;
|
|
colo?: string;
|
|
probedAt?: string;
|
|
}): HealthMailbox {
|
|
let targets: unknown = null;
|
|
return {
|
|
async getTargets() {
|
|
return targets as never;
|
|
},
|
|
async putTargets(doc) {
|
|
targets = doc;
|
|
},
|
|
async getResults() {
|
|
if (!opts) return null;
|
|
const dummy: HealthCheckTarget = {
|
|
scope: "binding",
|
|
ref_id: 1,
|
|
ip: "203.0.113.10",
|
|
hostname: "panel.example.com",
|
|
type: "tcp",
|
|
port: 1,
|
|
path: null,
|
|
expected_status: null,
|
|
timeout_ms: 400,
|
|
verify_tls: false,
|
|
provider: "cloudflare",
|
|
};
|
|
return {
|
|
probedAt: opts.probedAt ?? new Date().toISOString(),
|
|
colo: opts.colo ?? "AMS",
|
|
items: [
|
|
{
|
|
key: originProbeKey(dummy),
|
|
ok: opts.resultsOk !== false,
|
|
latencyMs: 42,
|
|
error: opts.resultsOk === false ? "down" : null,
|
|
},
|
|
],
|
|
};
|
|
},
|
|
};
|
|
}
|
|
|
|
describe("health-check XOR worker mailbox", () => {
|
|
it("lists only local providers when no cloudflare bindings", async () => {
|
|
const app = await buildApp({
|
|
config: { ...loadConfig(), staticDir: null },
|
|
memory: true,
|
|
});
|
|
await seedBinding(app.db, {
|
|
provider: "local",
|
|
ip: "10.0.0.1",
|
|
});
|
|
const targets = repos.listHealthCheckTargets(app.db);
|
|
expect(targets.length).toBeGreaterThan(0);
|
|
expect(targets.every((t) => t.provider === "local")).toBe(true);
|
|
expect(targets.some((t) => t.provider === "cloudflare")).toBe(false);
|
|
await app.close();
|
|
});
|
|
|
|
it("cloudflare without mailbox does not fall back to local", async () => {
|
|
const app = await buildApp({
|
|
config: { ...loadConfig(), staticDir: null },
|
|
memory: true,
|
|
});
|
|
const { binding } = await seedBinding(app.db, {
|
|
provider: "cloudflare",
|
|
ip: "127.0.0.1",
|
|
});
|
|
await healthCheckService.runAllChecks(app.db, {
|
|
thresholds,
|
|
probeGapMs: 0,
|
|
mailbox: null,
|
|
});
|
|
const row = repos.getIpHealthStatusRow(
|
|
app.db,
|
|
"binding",
|
|
binding.id,
|
|
"127.0.0.1",
|
|
);
|
|
expect(row?.last_error).toMatch(/Worker не настроен/i);
|
|
expect(row?.provider).toBe("cloudflare");
|
|
await app.close();
|
|
});
|
|
|
|
it("KV results write colo and last_checked_at without HTTP /probe", async () => {
|
|
const app = await buildApp({
|
|
config: { ...loadConfig(), staticDir: null },
|
|
memory: true,
|
|
});
|
|
const headers = await authHeaders(app);
|
|
const { service, binding } = await seedBinding(app.db, {
|
|
provider: "cloudflare",
|
|
ip: "203.0.113.10",
|
|
});
|
|
const targets = repos.listHealthCheckTargets(app.db);
|
|
const cfTarget = targets.find((t) => t.ip === "203.0.113.10")!;
|
|
const mailbox: HealthMailbox = {
|
|
async getTargets() {
|
|
return null;
|
|
},
|
|
async putTargets() {
|
|
/* fingerprint sync */
|
|
},
|
|
async getResults() {
|
|
return {
|
|
probedAt: new Date().toISOString(),
|
|
colo: "AMS",
|
|
items: [
|
|
{
|
|
key: originProbeKey(cfTarget),
|
|
ok: true,
|
|
latencyMs: 42,
|
|
error: null,
|
|
},
|
|
],
|
|
};
|
|
},
|
|
};
|
|
await healthCheckService.runAllChecks(app.db, {
|
|
thresholds,
|
|
probeGapMs: 0,
|
|
mailbox,
|
|
});
|
|
const row = repos.getIpHealthStatusRow(
|
|
app.db,
|
|
"binding",
|
|
binding.id,
|
|
"203.0.113.10",
|
|
);
|
|
expect(row?.status).toBe("up");
|
|
expect(row?.colo).toBe("AMS");
|
|
expect(row?.last_checked_at).toBeTruthy();
|
|
expect(row?.provider).toBe("cloudflare");
|
|
|
|
const res = await app.inject({
|
|
method: "GET",
|
|
url: `/api/v1/services/${service.id}`,
|
|
headers,
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const body = res.json() as {
|
|
ip_health: Array<{
|
|
ip: string;
|
|
colo: string | null;
|
|
last_checked_at: string | null;
|
|
provider: string;
|
|
}>;
|
|
};
|
|
const ipRow = body.ip_health.find((item) => item.ip === "203.0.113.10");
|
|
expect(ipRow?.colo).toBe("AMS");
|
|
expect(ipRow?.provider).toBe("cloudflare");
|
|
|
|
const logRes = await app.inject({
|
|
method: "GET",
|
|
url: `/api/v1/services/${service.id}/health-log`,
|
|
headers,
|
|
});
|
|
expect(logRes.statusCode).toBe(200);
|
|
const logBody = logRes.json() as { items: Array<{ colo: string | null }> };
|
|
expect(logBody.items[0]?.colo).toBe("AMS");
|
|
|
|
await app.close();
|
|
});
|
|
|
|
it("stale KV results are recorded, not local probe", async () => {
|
|
const app = await buildApp({
|
|
config: { ...loadConfig(), staticDir: null },
|
|
memory: true,
|
|
});
|
|
const { binding } = await seedBinding(app.db, {
|
|
provider: "cloudflare",
|
|
ip: "203.0.113.20",
|
|
});
|
|
await healthCheckService.runAllChecks(app.db, {
|
|
thresholds,
|
|
probeGapMs: 0,
|
|
mailbox: memoryMailbox({
|
|
resultsOk: true,
|
|
colo: "SIN",
|
|
probedAt: new Date(Date.now() - 60 * 60_000).toISOString(),
|
|
}),
|
|
staleAfterMs: 60_000,
|
|
});
|
|
const row = repos.getIpHealthStatusRow(
|
|
app.db,
|
|
"binding",
|
|
binding.id,
|
|
"203.0.113.20",
|
|
);
|
|
expect(row?.last_error).toMatch(/устарели|KV/i);
|
|
expect(row?.provider).toBe("cloudflare");
|
|
await app.close();
|
|
});
|
|
});
|