feat(health): деплоить probe-Worker из CFDM и опрашивать цели с edge
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]>
This commit is contained in:
Denozordec
2026-08-19 17:22:07 +07:00
co-authored by Cursor
parent 2c92e78b24
commit 4c4908558b
38 changed files with 1941 additions and 555 deletions
+160
View File
@@ -0,0 +1,160 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { buildApp } from "../src/app.js";
import { loadConfig } from "../src/config.js";
import { toCloudflareCron } from "../src/services/health/mailbox.js";
import { HEALTH_PROBE_SCRIPT_NAME } 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}` };
}
function jsonOk(result: unknown, status = 200): Response {
return new Response(JSON.stringify({ success: true, result }), {
status,
headers: { "content-type": "application/json" },
});
}
describe("health worker deploy", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("maps 6-field toad cron to 5-field Cloudflare cron", () => {
expect(toCloudflareCron("0 */2 * * * *")).toBe("*/2 * * * *");
expect(toCloudflareCron("*/5 * * * *")).toBe("*/5 * * * *");
});
it("POST ensure creates KV+script; 403 is not local fallback", async () => {
const calls: string[] = [];
vi.stubGlobal(
"fetch",
async (input: RequestInfo | URL, init?: RequestInit) => {
const url =
typeof input === "string" || input instanceof URL
? String(input)
: input.url;
const method = (
init?.method ??
(typeof Request !== "undefined" && input instanceof Request
? input.method
: "GET")
).toUpperCase();
calls.push(`${method} ${url}`);
if (
(url.includes("/accounts?") || /\/accounts$/.test(url.split("?")[0] ?? "")) &&
!url.includes("/storage/") &&
!url.includes("/workers/")
) {
return jsonOk([{ id: "acc-1", name: "Test" }]);
}
if (url.includes("/storage/kv/namespaces") && method === "GET" && !url.includes("/values/")) {
return jsonOk([]);
}
if (url.includes("/storage/kv/namespaces") && method === "POST") {
return jsonOk({ id: "kv-1", title: "cfdm-health-probe" });
}
if (
url.includes(`/workers/scripts/${HEALTH_PROBE_SCRIPT_NAME}`) &&
method === "PUT" &&
!url.includes("/schedules")
) {
return jsonOk({ id: "script-1" });
}
if (url.includes("/schedules") && method === "PUT") {
return jsonOk([{ cron: "*/2 * * * *" }]);
}
if (url.includes("/subdomain") && method === "POST") {
return jsonOk({ enabled: true });
}
if (url.includes("/workers/subdomain") && method === "GET") {
return jsonOk({ subdomain: "example" });
}
if (url.includes("/values/") && method === "GET") {
return new Response("null", { status: 404 });
}
if (url.includes("/values/") && method === "PUT") {
return jsonOk(null);
}
return jsonOk({});
},
);
const app = await buildApp({
config: { ...loadConfig(), staticDir: null, cloudflareApiToken: "cf-token" },
memory: true,
});
const headers = await authHeaders(app);
const res = await app.inject({
method: "POST",
url: "/api/v1/settings/health/worker/ensure",
headers,
});
expect(res.statusCode).toBe(200);
const body = res.json() as {
healthWorkerStatus: string;
healthWorkerUrl: string;
healthWorkerKvNamespaceId: string;
healthWorkerError: string | null;
};
expect(body.healthWorkerStatus).toBe("ready");
expect(body.healthWorkerKvNamespaceId).toBe("kv-1");
expect(body.healthWorkerUrl).toContain("cfdm-health-probe.example.workers.dev");
expect(body.healthWorkerError).toBeNull();
expect(calls.some((c) => c.includes("/workers/scripts/"))).toBe(true);
await app.close();
}, 20_000);
it("POST ensure 403 stores error, does not probe as local", async () => {
vi.stubGlobal(
"fetch",
async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes("/storage/kv/namespaces")) {
return new Response(
JSON.stringify({
success: false,
errors: [{ code: 10000, message: "Authentication error" }],
}),
{ status: 403, headers: { "content-type": "application/json" } },
);
}
if (url.includes("/accounts")) {
return jsonOk([{ id: "acc-1" }]);
}
return jsonOk({});
},
);
const app = await buildApp({
config: { ...loadConfig(), staticDir: null, cloudflareApiToken: "zone-only" },
memory: true,
});
const headers = await authHeaders(app);
const res = await app.inject({
method: "POST",
url: "/api/v1/settings/health/worker/ensure",
headers,
});
expect(res.statusCode).toBe(401);
const again = await app.inject({
method: "GET",
url: "/api/v1/settings",
headers,
});
const body = again.json() as {
healthWorkerStatus: string;
healthWorkerError: string | null;
};
expect(body.healthWorkerStatus).toBe("error");
expect(body.healthWorkerError).toMatch(/Workers Scripts Write|токен/i);
await app.close();
}, 20_000);
});
+85 -55
View File
@@ -1,9 +1,11 @@
import { createServer, type Server as HttpServer } from "node:http";
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({
@@ -16,37 +18,6 @@ async function authHeaders(app: Awaited<ReturnType<typeof buildApp>>) {
return { authorization: `Bearer ${token}` };
}
function startWorkerMock(handler: (req: {
url?: string;
headers: Record<string, string | string[] | undefined>;
body: string;
}) => { status: number; json: unknown } | "hang"): Promise<{
server: HttpServer;
url: string;
}> {
return new Promise((resolve) => {
const server = createServer((req, res) => {
const chunks: Buffer[] = [];
req.on("data", (chunk) => chunks.push(chunk as Buffer));
req.on("end", () => {
const result = handler({
url: req.url,
headers: req.headers,
body: Buffer.concat(chunks).toString("utf8"),
});
if (result === "hang") return;
res.writeHead(result.status, { "content-type": "application/json" });
res.end(JSON.stringify(result.json));
});
});
server.listen(0, "127.0.0.1", () => {
const address = server.address();
const port = typeof address === "object" && address ? address.port : 0;
resolve({ server, url: `http://127.0.0.1:${port}` });
});
});
}
async function seedBinding(
db: Db,
opts: { provider: "local" | "cloudflare"; ip: string },
@@ -76,7 +47,51 @@ const thresholds = {
successRecoveries: 2,
};
describe("health-check XOR worker", () => {
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 },
@@ -93,7 +108,7 @@ describe("health-check XOR worker", () => {
await app.close();
});
it("cloudflare without worker URL does not fall back to local", async () => {
it("cloudflare without mailbox does not fall back to local", async () => {
const app = await buildApp({
config: { ...loadConfig(), staticDir: null },
memory: true,
@@ -105,7 +120,7 @@ describe("health-check XOR worker", () => {
await healthCheckService.runAllChecks(app.db, {
thresholds,
probeGapMs: 0,
worker: null,
mailbox: null,
});
const row = repos.getIpHealthStatusRow(
app.db,
@@ -118,17 +133,7 @@ describe("health-check XOR worker", () => {
await app.close();
});
it("worker mock 200 writes colo and last_checked_at", async () => {
const mock = await startWorkerMock((req) => {
const auth = String(req.headers.authorization ?? "");
if (auth !== "Bearer secret") {
return { status: 401, json: { ok: false, error: "unauthorized" } };
}
return {
status: 200,
json: { ok: true, latencyMs: 42, error: null, colo: "AMS" },
};
});
it("KV results write colo and last_checked_at without HTTP /probe", async () => {
const app = await buildApp({
config: { ...loadConfig(), staticDir: null },
memory: true,
@@ -138,10 +143,34 @@ describe("health-check XOR worker", () => {
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,
worker: { url: mock.url, token: "secret" },
mailbox,
});
const row = repos.getIpHealthStatusRow(
app.db,
@@ -170,7 +199,6 @@ describe("health-check XOR worker", () => {
};
const ipRow = body.ip_health.find((item) => item.ip === "203.0.113.10");
expect(ipRow?.colo).toBe("AMS");
expect(ipRow?.last_checked_at).toBeTruthy();
expect(ipRow?.provider).toBe("cloudflare");
const logRes = await app.inject({
@@ -182,12 +210,10 @@ describe("health-check XOR worker", () => {
const logBody = logRes.json() as { items: Array<{ colo: string | null }> };
expect(logBody.items[0]?.colo).toBe("AMS");
await new Promise<void>((resolve) => mock.server.close(() => resolve()));
await app.close();
});
it("worker timeout is recorded, not local probe", async () => {
const mock = await startWorkerMock(() => "hang");
it("stale KV results are recorded, not local probe", async () => {
const app = await buildApp({
config: { ...loadConfig(), staticDir: null },
memory: true,
@@ -199,7 +225,12 @@ describe("health-check XOR worker", () => {
await healthCheckService.runAllChecks(app.db, {
thresholds,
probeGapMs: 0,
worker: { url: mock.url, token: "secret" },
mailbox: memoryMailbox({
resultsOk: true,
colo: "SIN",
probedAt: new Date(Date.now() - 60 * 60_000).toISOString(),
}),
staleAfterMs: 60_000,
});
const row = repos.getIpHealthStatusRow(
app.db,
@@ -207,9 +238,8 @@ describe("health-check XOR worker", () => {
binding.id,
"203.0.113.20",
);
expect(row?.last_error).toMatch(/timeout|Worker/i);
expect(row?.last_error).toMatch(/устарели|KV/i);
expect(row?.provider).toBe("cloudflare");
await new Promise<void>((resolve) => mock.server.close(() => resolve()));
await app.close();
}, 15_000);
});
});
+2
View File
@@ -40,12 +40,14 @@ describe("settings health engine", () => {
healthDownFailures: number;
healthLatencyWarnMs: number;
healthSuccessRecoveries: number;
healthWorkerStatus?: string;
};
expect(body.healthCheckCron).toBe("*/30 * * * * *");
expect(body.healthDegradedFailures).toBe(3);
expect(body.healthDownFailures).toBe(4);
expect(body.healthLatencyWarnMs).toBe(1500);
expect(body.healthSuccessRecoveries).toBe(5);
expect(body.healthWorkerStatus).toBe("missing");
await app.close();
});