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
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:
@@ -5,7 +5,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx watch src/server.ts",
|
"dev": "tsx watch src/server.ts",
|
||||||
"build": "tsup src/server.ts --format esm --dts",
|
"build": "tsup --config tsup.config.ts",
|
||||||
"start": "node dist/server.js",
|
"start": "node dist/server.js",
|
||||||
"test": "vitest run"
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -35,8 +35,10 @@ import { auditRoutes } from "./routes/audit.js";
|
|||||||
import * as certificateService from "./services/certificate-service.js";
|
import * as certificateService from "./services/certificate-service.js";
|
||||||
import {
|
import {
|
||||||
createHealthCheckTask,
|
createHealthCheckTask,
|
||||||
|
healthEngineFallbacksFromConfig,
|
||||||
scheduleHealthCheckJob,
|
scheduleHealthCheckJob,
|
||||||
} from "./services/health-check-scheduler.js";
|
} from "./services/health-check-scheduler.js";
|
||||||
|
import { fireEnsureHealthWorker } from "./services/health/health-worker-deploy.js";
|
||||||
import { AsyncTask, CronJob } from "toad-scheduler";
|
import { AsyncTask, CronJob } from "toad-scheduler";
|
||||||
|
|
||||||
export interface BuildAppOptions {
|
export interface BuildAppOptions {
|
||||||
@@ -131,6 +133,14 @@ export async function buildApp(opts: BuildAppOptions = {}) {
|
|||||||
app.decorate("reloadHealthCheckJob", () => {
|
app.decorate("reloadHealthCheckJob", () => {
|
||||||
scheduleHealthCheckJob(app, config, healthTask);
|
scheduleHealthCheckJob(app, config, healthTask);
|
||||||
});
|
});
|
||||||
|
if (config.cloudflareApiToken) {
|
||||||
|
fireEnsureHealthWorker(
|
||||||
|
app.db,
|
||||||
|
app.cf,
|
||||||
|
healthEngineFallbacksFromConfig(config),
|
||||||
|
app.log,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import type {
|
|||||||
} from "@cfdm/shared";
|
} from "@cfdm/shared";
|
||||||
import { createDnsAdapter } from "./cloudflare/dns-service.js";
|
import { createDnsAdapter } from "./cloudflare/dns-service.js";
|
||||||
import { createHealthCheckAdapter, type CfHealthCheckPayload } from "./cloudflare/healthcheck-service.js";
|
import { createHealthCheckAdapter, type CfHealthCheckPayload } from "./cloudflare/healthcheck-service.js";
|
||||||
|
import { createKvAdapter } from "./cloudflare/kv-service.js";
|
||||||
|
import { createWorkersAdapter } from "./cloudflare/workers-service.js";
|
||||||
import { createZoneAdapter } from "./cloudflare/zone-service.js";
|
import { createZoneAdapter } from "./cloudflare/zone-service.js";
|
||||||
|
|
||||||
export type { CfHealthCheckPayload };
|
export type { CfHealthCheckPayload };
|
||||||
@@ -15,11 +17,21 @@ export class CloudflareClient {
|
|||||||
private readonly zones;
|
private readonly zones;
|
||||||
private readonly dns;
|
private readonly dns;
|
||||||
private readonly healthchecks;
|
private readonly healthchecks;
|
||||||
|
private readonly kv;
|
||||||
|
private readonly workers;
|
||||||
|
private readonly token;
|
||||||
|
|
||||||
constructor(token: string) {
|
constructor(token: string) {
|
||||||
|
this.token = token.trim();
|
||||||
this.zones = createZoneAdapter(token);
|
this.zones = createZoneAdapter(token);
|
||||||
this.dns = createDnsAdapter(token);
|
this.dns = createDnsAdapter(token);
|
||||||
this.healthchecks = createHealthCheckAdapter(token);
|
this.healthchecks = createHealthCheckAdapter(token);
|
||||||
|
this.kv = createKvAdapter(token);
|
||||||
|
this.workers = createWorkersAdapter(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
get isConfigured(): boolean {
|
||||||
|
return this.token.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
listZones(): Promise<CfZone[]> {
|
listZones(): Promise<CfZone[]> {
|
||||||
@@ -81,4 +93,45 @@ export class CloudflareClient {
|
|||||||
deleteHealthCheck(zoneId: string, id: string): Promise<void> {
|
deleteHealthCheck(zoneId: string, id: string): Promise<void> {
|
||||||
return this.healthchecks.deleteHealthCheck(zoneId, id);
|
return this.healthchecks.deleteHealthCheck(zoneId, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
listAccounts() {
|
||||||
|
return this.workers.listAccounts();
|
||||||
|
}
|
||||||
|
|
||||||
|
listKvNamespaces(accountId: string) {
|
||||||
|
return this.kv.listNamespaces(accountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
createKvNamespace(accountId: string, title: string) {
|
||||||
|
return this.kv.createNamespace(accountId, title);
|
||||||
|
}
|
||||||
|
|
||||||
|
kvGet(accountId: string, namespaceId: string, key: string) {
|
||||||
|
return this.kv.getValue(accountId, namespaceId, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
kvPut(accountId: string, namespaceId: string, key: string, value: string) {
|
||||||
|
return this.kv.putValue(accountId, namespaceId, key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
putWorkerScript(opts: {
|
||||||
|
accountId: string;
|
||||||
|
scriptName: string;
|
||||||
|
source: string;
|
||||||
|
kvNamespaceId: string;
|
||||||
|
}) {
|
||||||
|
return this.workers.putScript(opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
putWorkerSchedules(accountId: string, scriptName: string, crons: string[]) {
|
||||||
|
return this.workers.putSchedules(accountId, scriptName, crons);
|
||||||
|
}
|
||||||
|
|
||||||
|
enableWorkersDev(accountId: string, scriptName: string) {
|
||||||
|
return this.workers.enableWorkersDev(accountId, scriptName);
|
||||||
|
}
|
||||||
|
|
||||||
|
getWorkersSubdomain(accountId: string) {
|
||||||
|
return this.workers.getWorkersSubdomain(accountId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,15 @@ export function mapCloudflareFailure(
|
|||||||
): AppError {
|
): AppError {
|
||||||
const lower = message.toLowerCase();
|
const lower = message.toLowerCase();
|
||||||
if (status === 401 || status === 403 || lower.includes("authentication")) {
|
if (status === 401 || status === 403 || lower.includes("authentication")) {
|
||||||
|
if (
|
||||||
|
operation.includes("workers") ||
|
||||||
|
operation.includes("kv_") ||
|
||||||
|
operation.includes("accounts")
|
||||||
|
) {
|
||||||
|
return AppError.cloudflareAuthFailed(
|
||||||
|
"Токену нужны права Account: Workers Scripts Write и Workers KV Storage Write. Zone DNS недостаточно.",
|
||||||
|
);
|
||||||
|
}
|
||||||
return AppError.cloudflareAuthFailed(
|
return AppError.cloudflareAuthFailed(
|
||||||
"Cloudflare отклонил токен. Проверьте CLOUDFLARE_API_TOKEN.",
|
"Cloudflare отклонил токен. Проверьте CLOUDFLARE_API_TOKEN.",
|
||||||
);
|
);
|
||||||
@@ -67,6 +76,40 @@ export async function handleCfResponse<T>(
|
|||||||
return body.result;
|
return body.result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** KV PUT / schedules often return `{ success: true }` without `result`. */
|
||||||
|
export async function handleCfSuccess(
|
||||||
|
response: Response,
|
||||||
|
operation: string,
|
||||||
|
): Promise<void> {
|
||||||
|
if (response.status === 429) {
|
||||||
|
const wait = parseRetryAfter(response.headers) ?? 5000;
|
||||||
|
throw AppError.rateLimited(
|
||||||
|
`Cloudflare временно ограничил запросы. Повторите через ${Math.ceil(wait / 1000)} с.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const text = await response.text();
|
||||||
|
if (!text) {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw mapCloudflareFailure(operation, response.status, String(response.status));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let body: CfResponse<unknown>;
|
||||||
|
try {
|
||||||
|
body = JSON.parse(text) as CfResponse<unknown>;
|
||||||
|
} catch {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw mapCloudflareFailure(operation, response.status, text.slice(0, 180));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!body.success) {
|
||||||
|
const msg =
|
||||||
|
body.errors?.map((e) => e.message).join("; ") ?? "unknown cloudflare error";
|
||||||
|
throw mapCloudflareFailure(operation, response.status, msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function cfRequest<T>(
|
export async function cfRequest<T>(
|
||||||
token: string,
|
token: string,
|
||||||
path: string,
|
path: string,
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { CF_API_BASE, handleCfResponse, handleCfSuccess, mapCloudflareFailure } from "./http.js";
|
||||||
|
|
||||||
|
export interface CfKvNamespace {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createKvAdapter(token: string) {
|
||||||
|
return {
|
||||||
|
async listNamespaces(accountId: string): Promise<CfKvNamespace[]> {
|
||||||
|
const all: CfKvNamespace[] = [];
|
||||||
|
let page = 1;
|
||||||
|
while (true) {
|
||||||
|
const url = new URL(
|
||||||
|
`${CF_API_BASE}/accounts/${accountId}/storage/kv/namespaces`,
|
||||||
|
);
|
||||||
|
url.searchParams.set("per_page", "100");
|
||||||
|
url.searchParams.set("page", String(page));
|
||||||
|
const response = await fetch(url.toString(), {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
signal: AbortSignal.timeout(30_000),
|
||||||
|
});
|
||||||
|
if (response.status >= 500 || response.status === 429) {
|
||||||
|
throw mapCloudflareFailure("kv_list", response.status, String(response.status));
|
||||||
|
}
|
||||||
|
const batch = await handleCfResponse<CfKvNamespace[]>(response, "kv_list");
|
||||||
|
all.push(...batch);
|
||||||
|
if (batch.length < 100) break;
|
||||||
|
page += 1;
|
||||||
|
}
|
||||||
|
return all;
|
||||||
|
},
|
||||||
|
|
||||||
|
async createNamespace(accountId: string, title: string): Promise<CfKvNamespace> {
|
||||||
|
const response = await fetch(
|
||||||
|
`${CF_API_BASE}/accounts/${accountId}/storage/kv/namespaces`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ title }),
|
||||||
|
signal: AbortSignal.timeout(30_000),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (response.status >= 500 || response.status === 429) {
|
||||||
|
throw mapCloudflareFailure("kv_create", response.status, String(response.status));
|
||||||
|
}
|
||||||
|
return handleCfResponse<CfKvNamespace>(response, "kv_create");
|
||||||
|
},
|
||||||
|
|
||||||
|
async getValue(
|
||||||
|
accountId: string,
|
||||||
|
namespaceId: string,
|
||||||
|
key: string,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const response = await fetch(
|
||||||
|
`${CF_API_BASE}/accounts/${accountId}/storage/kv/namespaces/${namespaceId}/values/${encodeURIComponent(key)}`,
|
||||||
|
{
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
signal: AbortSignal.timeout(30_000),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (response.status === 404) return null;
|
||||||
|
if (response.status >= 500 || response.status === 429) {
|
||||||
|
throw mapCloudflareFailure("kv_get", response.status, String(response.status));
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text().catch(() => "");
|
||||||
|
throw mapCloudflareFailure("kv_get", response.status, text.slice(0, 180));
|
||||||
|
}
|
||||||
|
return response.text();
|
||||||
|
},
|
||||||
|
|
||||||
|
async putValue(
|
||||||
|
accountId: string,
|
||||||
|
namespaceId: string,
|
||||||
|
key: string,
|
||||||
|
value: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const response = await fetch(
|
||||||
|
`${CF_API_BASE}/accounts/${accountId}/storage/kv/namespaces/${namespaceId}/values/${encodeURIComponent(key)}`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"Content-Type": "text/plain",
|
||||||
|
},
|
||||||
|
body: value,
|
||||||
|
signal: AbortSignal.timeout(30_000),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (response.status >= 500 || response.status === 429) {
|
||||||
|
throw mapCloudflareFailure("kv_put", response.status, String(response.status));
|
||||||
|
}
|
||||||
|
await handleCfSuccess(response, "kv_put");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import {
|
||||||
|
CF_API_BASE,
|
||||||
|
handleCfResponse,
|
||||||
|
handleCfSuccess,
|
||||||
|
mapCloudflareFailure,
|
||||||
|
} from "./http.js";
|
||||||
|
|
||||||
|
export interface CfAccount {
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CfWorkersSubdomain {
|
||||||
|
subdomain?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createWorkersAdapter(token: string) {
|
||||||
|
return {
|
||||||
|
async listAccounts(): Promise<CfAccount[]> {
|
||||||
|
const response = await fetch(`${CF_API_BASE}/accounts?per_page=50`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
signal: AbortSignal.timeout(30_000),
|
||||||
|
});
|
||||||
|
if (response.status >= 500 || response.status === 429) {
|
||||||
|
throw mapCloudflareFailure("list_accounts", response.status, String(response.status));
|
||||||
|
}
|
||||||
|
return handleCfResponse<CfAccount[]>(response, "list_accounts");
|
||||||
|
},
|
||||||
|
|
||||||
|
async putScript(opts: {
|
||||||
|
accountId: string;
|
||||||
|
scriptName: string;
|
||||||
|
source: string;
|
||||||
|
kvNamespaceId: string;
|
||||||
|
filename?: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
const filename = opts.filename ?? "index.mjs";
|
||||||
|
const metadata = {
|
||||||
|
main_module: filename,
|
||||||
|
compatibility_date: "2025-04-01",
|
||||||
|
bindings: [
|
||||||
|
{
|
||||||
|
type: "kv_namespace",
|
||||||
|
name: "HEALTH_KV",
|
||||||
|
namespace_id: opts.kvNamespaceId,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const form = new FormData();
|
||||||
|
form.append(
|
||||||
|
"metadata",
|
||||||
|
new Blob([JSON.stringify(metadata)], { type: "application/json" }),
|
||||||
|
);
|
||||||
|
form.append(
|
||||||
|
filename,
|
||||||
|
new Blob([opts.source], { type: "application/javascript+module" }),
|
||||||
|
filename,
|
||||||
|
);
|
||||||
|
const response = await fetch(
|
||||||
|
`${CF_API_BASE}/accounts/${opts.accountId}/workers/scripts/${opts.scriptName}`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
body: form,
|
||||||
|
signal: AbortSignal.timeout(60_000),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (response.status >= 500 || response.status === 429) {
|
||||||
|
throw mapCloudflareFailure("workers_put_script", response.status, String(response.status));
|
||||||
|
}
|
||||||
|
await handleCfSuccess(response, "workers_put_script");
|
||||||
|
},
|
||||||
|
|
||||||
|
async putSchedules(
|
||||||
|
accountId: string,
|
||||||
|
scriptName: string,
|
||||||
|
crons: string[],
|
||||||
|
): Promise<void> {
|
||||||
|
const response = await fetch(
|
||||||
|
`${CF_API_BASE}/accounts/${accountId}/workers/scripts/${scriptName}/schedules`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(crons.map((cron) => ({ cron }))),
|
||||||
|
signal: AbortSignal.timeout(30_000),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (response.status >= 500 || response.status === 429) {
|
||||||
|
throw mapCloudflareFailure("workers_put_schedules", response.status, String(response.status));
|
||||||
|
}
|
||||||
|
await handleCfSuccess(response, "workers_put_schedules");
|
||||||
|
},
|
||||||
|
|
||||||
|
async enableWorkersDev(
|
||||||
|
accountId: string,
|
||||||
|
scriptName: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const response = await fetch(
|
||||||
|
`${CF_API_BASE}/accounts/${accountId}/workers/scripts/${scriptName}/subdomain`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ enabled: true }),
|
||||||
|
signal: AbortSignal.timeout(30_000),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (response.status === 409) return;
|
||||||
|
if (response.status >= 500 || response.status === 429) {
|
||||||
|
throw mapCloudflareFailure("workers_subdomain", response.status, String(response.status));
|
||||||
|
}
|
||||||
|
if (!response.ok && response.status !== 200 && response.status !== 201) {
|
||||||
|
await handleCfSuccess(response, "workers_subdomain");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async getWorkersSubdomain(accountId: string): Promise<string | null> {
|
||||||
|
const response = await fetch(
|
||||||
|
`${CF_API_BASE}/accounts/${accountId}/workers/subdomain`,
|
||||||
|
{
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
signal: AbortSignal.timeout(30_000),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (response.status === 404) return null;
|
||||||
|
if (response.status >= 500 || response.status === 429) {
|
||||||
|
throw mapCloudflareFailure("workers_get_subdomain", response.status, String(response.status));
|
||||||
|
}
|
||||||
|
const result = await handleCfResponse<CfWorkersSubdomain>(
|
||||||
|
response,
|
||||||
|
"workers_get_subdomain",
|
||||||
|
);
|
||||||
|
return result.subdomain?.trim() || null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -5,8 +5,9 @@ import * as healthCheckService from "../services/health-check-service.js";
|
|||||||
import * as serviceConfigService from "../services/service-config-service.js";
|
import * as serviceConfigService from "../services/service-config-service.js";
|
||||||
import {
|
import {
|
||||||
healthEngineFallbacksFromConfig,
|
healthEngineFallbacksFromConfig,
|
||||||
resolveWorkerProbeConfig,
|
|
||||||
} from "../services/health-check-scheduler.js";
|
} from "../services/health-check-scheduler.js";
|
||||||
|
import { mailboxFromSettings } from "../services/health/health-worker-deploy.js";
|
||||||
|
import { cronStaleAfterMs } from "../services/health/mailbox.js";
|
||||||
|
|
||||||
export async function healthCheckRoutes(app: FastifyInstance) {
|
export async function healthCheckRoutes(app: FastifyInstance) {
|
||||||
app.get("/health-status", async (request) => {
|
app.get("/health-status", async (request) => {
|
||||||
@@ -20,9 +21,10 @@ export async function healthCheckRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
app.post("/health-check/run", async (request) => {
|
app.post("/health-check/run", async (request) => {
|
||||||
const config = request.server.config;
|
const config = request.server.config;
|
||||||
|
const fallbacks = healthEngineFallbacksFromConfig(config);
|
||||||
const settings = getAppSettings(
|
const settings = getAppSettings(
|
||||||
request.server.db,
|
request.server.db,
|
||||||
healthEngineFallbacksFromConfig(config),
|
fallbacks,
|
||||||
);
|
);
|
||||||
const thresholds = {
|
const thresholds = {
|
||||||
degradedFailures: settings.healthDegradedFailures,
|
degradedFailures: settings.healthDegradedFailures,
|
||||||
@@ -33,7 +35,8 @@ export async function healthCheckRoutes(app: FastifyInstance) {
|
|||||||
const checked = await healthCheckService.runAllChecks(request.server.db, {
|
const checked = await healthCheckService.runAllChecks(request.server.db, {
|
||||||
thresholds,
|
thresholds,
|
||||||
probeGapMs: config.healthProbeGapMs,
|
probeGapMs: config.healthProbeGapMs,
|
||||||
worker: resolveWorkerProbeConfig(request.server.db, config),
|
mailbox: mailboxFromSettings(request.server.db, request.server.cf, fallbacks),
|
||||||
|
staleAfterMs: cronStaleAfterMs(settings.healthCheckCron),
|
||||||
onStatusChange: async (target, prev, next) => {
|
onStatusChange: async (target, prev, next) => {
|
||||||
try {
|
try {
|
||||||
const label =
|
const label =
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
assertValidHealthCron,
|
assertValidHealthCron,
|
||||||
healthEngineFallbacksFromConfig,
|
healthEngineFallbacksFromConfig,
|
||||||
} from "../services/health-check-scheduler.js";
|
} from "../services/health-check-scheduler.js";
|
||||||
|
import { ensureHealthWorker } from "../services/health/health-worker-deploy.js";
|
||||||
|
|
||||||
export async function settingsRoutes(app: FastifyInstance) {
|
export async function settingsRoutes(app: FastifyInstance) {
|
||||||
app.get("/settings", async (request) => {
|
app.get("/settings", async (request) => {
|
||||||
@@ -40,11 +41,29 @@ export async function settingsRoutes(app: FastifyInstance) {
|
|||||||
"ошибок до down не меньше, чем до degraded",
|
"ошибок до down не меньше, чем до degraded",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const next = updateAppSettings(request.server.db, body, fallbacks);
|
updateAppSettings(request.server.db, body, fallbacks);
|
||||||
if (body.healthCheckCron !== undefined) {
|
if (body.healthCheckCron !== undefined) {
|
||||||
request.server.reloadHealthCheckJob?.();
|
request.server.reloadHealthCheckJob?.();
|
||||||
|
const after = getAppSettings(request.server.db, fallbacks);
|
||||||
|
if (after.healthWorkerKvNamespaceId) {
|
||||||
|
try {
|
||||||
|
await ensureHealthWorker(
|
||||||
|
request.server.db,
|
||||||
|
request.server.cf,
|
||||||
|
fallbacks,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// error stored in settings
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return next;
|
return getAppSettings(request.server.db, fallbacks);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/settings/health/worker/ensure", async (request) => {
|
||||||
|
const fallbacks = healthEngineFallbacksFromConfig(request.server.config);
|
||||||
|
await ensureHealthWorker(request.server.db, request.server.cf, fallbacks);
|
||||||
|
return getAppSettings(request.server.db, fallbacks);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/settings/vps-tracker/test", async (request) => {
|
app.post("/settings/vps-tracker/test", async (request) => {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { FastifyInstance } from "fastify";
|
|||||||
import { AsyncTask, CronJob } from "toad-scheduler";
|
import { AsyncTask, CronJob } from "toad-scheduler";
|
||||||
import {
|
import {
|
||||||
getAppSettings,
|
getAppSettings,
|
||||||
getAppSettingsSecrets,
|
updateAppSettings,
|
||||||
type HealthEngineFallbacks,
|
type HealthEngineFallbacks,
|
||||||
} from "@cfdm/db";
|
} from "@cfdm/db";
|
||||||
import { repos } from "@cfdm/db";
|
import { repos } from "@cfdm/db";
|
||||||
@@ -10,6 +10,10 @@ import type { AppConfig } from "../config.js";
|
|||||||
import { AppError } from "../errors.js";
|
import { AppError } from "../errors.js";
|
||||||
import * as healthCheckService from "./health-check-service.js";
|
import * as healthCheckService from "./health-check-service.js";
|
||||||
import * as serviceConfigService from "./service-config-service.js";
|
import * as serviceConfigService from "./service-config-service.js";
|
||||||
|
import {
|
||||||
|
mailboxFromSettings,
|
||||||
|
} from "./health/health-worker-deploy.js";
|
||||||
|
import { cronStaleAfterMs } from "./health/mailbox.js";
|
||||||
|
|
||||||
declare module "fastify" {
|
declare module "fastify" {
|
||||||
interface FastifyInstance {
|
interface FastifyInstance {
|
||||||
@@ -33,18 +37,6 @@ export function healthEngineFallbacksFromConfig(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveWorkerProbeConfig(
|
|
||||||
db: import("@cfdm/db").Db,
|
|
||||||
config: AppConfig,
|
|
||||||
): { url: string; token: string } | null {
|
|
||||||
const settings = getAppSettings(db, healthEngineFallbacksFromConfig(config));
|
|
||||||
const secrets = getAppSettingsSecrets(db);
|
|
||||||
const url = settings.healthWorkerUrl.trim();
|
|
||||||
const token = (secrets.healthWorkerToken || config.healthWorkerToken).trim();
|
|
||||||
if (!url || !token) return null;
|
|
||||||
return { url, token };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function assertValidHealthCron(expr: string): void {
|
export function assertValidHealthCron(expr: string): void {
|
||||||
const cronExpression = expr.trim();
|
const cronExpression = expr.trim();
|
||||||
const parts = cronExpression.split(/\s+/).filter(Boolean);
|
const parts = cronExpression.split(/\s+/).filter(Boolean);
|
||||||
@@ -78,10 +70,12 @@ export function createHealthCheckTask(
|
|||||||
latencyWarnMs: settings.healthLatencyWarnMs,
|
latencyWarnMs: settings.healthLatencyWarnMs,
|
||||||
successRecoveries: settings.healthSuccessRecoveries,
|
successRecoveries: settings.healthSuccessRecoveries,
|
||||||
};
|
};
|
||||||
|
const mailbox = mailboxFromSettings(app.db, app.cf, fallbacks);
|
||||||
const n = await healthCheckService.runAllChecks(app.db, {
|
const n = await healthCheckService.runAllChecks(app.db, {
|
||||||
thresholds,
|
thresholds,
|
||||||
probeGapMs: config.healthProbeGapMs,
|
probeGapMs: config.healthProbeGapMs,
|
||||||
worker: resolveWorkerProbeConfig(app.db, config),
|
mailbox,
|
||||||
|
staleAfterMs: cronStaleAfterMs(settings.healthCheckCron),
|
||||||
onStatusChange: async (target, prev, next) => {
|
onStatusChange: async (target, prev, next) => {
|
||||||
try {
|
try {
|
||||||
const label =
|
const label =
|
||||||
@@ -114,6 +108,13 @@ export function createHealthCheckTask(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
if (mailbox) {
|
||||||
|
updateAppSettings(
|
||||||
|
app.db,
|
||||||
|
{ healthWorkerLastIngestAt: new Date().toISOString() },
|
||||||
|
fallbacks,
|
||||||
|
);
|
||||||
|
}
|
||||||
const monitors = await healthCheckService.runDomainMonitors(
|
const monitors = await healthCheckService.runDomainMonitors(
|
||||||
app.db,
|
app.db,
|
||||||
thresholds,
|
thresholds,
|
||||||
|
|||||||
@@ -7,10 +7,14 @@ import type { HealthCheckTarget, IpHealthState } from "@cfdm/shared";
|
|||||||
import { AppError } from "../errors.js";
|
import { AppError } from "../errors.js";
|
||||||
import { nextHealthState } from "./health/state-machine.js";
|
import { nextHealthState } from "./health/state-machine.js";
|
||||||
import { LocalHealthCheckProvider } from "./health/local.js";
|
import { LocalHealthCheckProvider } from "./health/local.js";
|
||||||
|
import { workerNotConfiguredResult } from "./health/worker.js";
|
||||||
import {
|
import {
|
||||||
CloudflareWorkerHealthCheckProvider,
|
buildTargetsDoc,
|
||||||
workerNotConfiguredResult,
|
indexResults,
|
||||||
} from "./health/worker.js";
|
isResultsStale,
|
||||||
|
originProbeKey,
|
||||||
|
type HealthMailbox,
|
||||||
|
} from "./health/mailbox.js";
|
||||||
|
|
||||||
export interface HealthCheckThresholds {
|
export interface HealthCheckThresholds {
|
||||||
degradedFailures: number;
|
degradedFailures: number;
|
||||||
@@ -267,8 +271,10 @@ export interface RunAllChecksOptions {
|
|||||||
thresholds: HealthCheckThresholds;
|
thresholds: HealthCheckThresholds;
|
||||||
/** Pause between unique physical probes (default 2000). Same IP is only probed once. */
|
/** Pause between unique physical probes (default 2000). Same IP is only probed once. */
|
||||||
probeGapMs?: number;
|
probeGapMs?: number;
|
||||||
/** Cloudflare Worker URL+token. Missing → cloudflare targets fail, never Local fallback. */
|
/** KV mailbox with Worker results. Missing → cloudflare targets fail, never Local fallback. */
|
||||||
worker?: { url: string; token: string } | null;
|
mailbox?: HealthMailbox | null;
|
||||||
|
/** Results older than this are stale (default 10 min). */
|
||||||
|
staleAfterMs?: number;
|
||||||
onStatusChange?: (
|
onStatusChange?: (
|
||||||
target: HealthCheckTarget,
|
target: HealthCheckTarget,
|
||||||
prevState: IpHealthState | null,
|
prevState: IpHealthState | null,
|
||||||
@@ -286,33 +292,82 @@ function sleep(ms: number): Promise<void> {
|
|||||||
*/
|
*/
|
||||||
export function physicalProbeKey(target: HealthCheckTarget): string {
|
export function physicalProbeKey(target: HealthCheckTarget): string {
|
||||||
const kind = target.provider === "cloudflare" ? "cloudflare" : "local";
|
const kind = target.provider === "cloudflare" ? "cloudflare" : "local";
|
||||||
const port = target.port ?? (target.type === "http" ? 80 : 80);
|
return `${kind}|${originProbeKey(target)}`;
|
||||||
const ip = String(target.ip || "").trim().toLowerCase();
|
|
||||||
if (target.type === "http") {
|
|
||||||
const path = (target.path?.trim() || "/") || "/";
|
|
||||||
const expected = target.expected_status ?? "";
|
|
||||||
return `${kind}|http|${ip}|${port}|${path}|${expected}`;
|
|
||||||
}
|
|
||||||
if (target.type === "tcp") return `${kind}|tcp|${ip}|${port}`;
|
|
||||||
if (target.type === "ping") {
|
|
||||||
return `${kind}|ping|${String(target.hostname || target.ip || "").trim().toLowerCase()}`;
|
|
||||||
}
|
|
||||||
if (target.type === "dns") {
|
|
||||||
return `${kind}|dns|${String(target.hostname || target.ip || "").trim().toLowerCase()}`;
|
|
||||||
}
|
|
||||||
return `${kind}|${target.type}|${ip}|${port}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function executeProbe(
|
function applyProbeResult(
|
||||||
|
db: Db,
|
||||||
target: HealthCheckTarget,
|
target: HealthCheckTarget,
|
||||||
local: LocalHealthCheckProvider,
|
result: ProbeResult,
|
||||||
worker: CloudflareWorkerHealthCheckProvider | null,
|
options: RunAllChecksOptions,
|
||||||
): Promise<ProbeResult> {
|
): void {
|
||||||
if (target.provider === "cloudflare") {
|
const prev = repos.getIpHealthStatusRow(
|
||||||
if (!worker) return workerNotConfiguredResult();
|
db,
|
||||||
return worker.probe(target);
|
target.scope,
|
||||||
|
target.ref_id,
|
||||||
|
target.ip,
|
||||||
|
);
|
||||||
|
const { state, failures, successes, node } = deriveState(
|
||||||
|
result.ok,
|
||||||
|
result.latencyMs,
|
||||||
|
prev
|
||||||
|
? {
|
||||||
|
consecutive_failures: prev.consecutive_failures,
|
||||||
|
consecutive_successes: prev.consecutive_successes,
|
||||||
|
status: prev.status,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
options.thresholds,
|
||||||
|
);
|
||||||
|
const prevState: IpHealthState | null = prev
|
||||||
|
? (prev.status as IpHealthState)
|
||||||
|
: null;
|
||||||
|
const provider = target.provider === "cloudflare" ? "cloudflare" : "local";
|
||||||
|
repos.upsertIpHealthStatus(
|
||||||
|
db,
|
||||||
|
target.scope,
|
||||||
|
target.ref_id,
|
||||||
|
target.ip,
|
||||||
|
state,
|
||||||
|
result.latencyMs,
|
||||||
|
failures,
|
||||||
|
result.error,
|
||||||
|
successes,
|
||||||
|
{ colo: result.colo ?? null, provider },
|
||||||
|
);
|
||||||
|
repos.insertHealthProbeLog(db, {
|
||||||
|
scope: target.scope,
|
||||||
|
refId: target.ref_id,
|
||||||
|
ip: target.ip,
|
||||||
|
provider,
|
||||||
|
status: state,
|
||||||
|
ok: result.ok,
|
||||||
|
latencyMs: result.latencyMs,
|
||||||
|
colo: result.colo ?? null,
|
||||||
|
error: result.error,
|
||||||
|
});
|
||||||
|
const matchedNode = repos.findNodeByIp(db, target.ip);
|
||||||
|
if (matchedNode && matchedNode.enabled) {
|
||||||
|
repos.updateNode(db, matchedNode.id, {
|
||||||
|
health_status: node,
|
||||||
|
consecutive_failures: failures,
|
||||||
|
consecutive_successes: successes,
|
||||||
|
last_check_at: new Date().toISOString().replace("T", " ").slice(0, 19),
|
||||||
|
last_failure_reason: result.error,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return local.probe(target);
|
if (prevState !== state) {
|
||||||
|
options.onStatusChange?.(target, prevState, state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function staleWorkerResult(colo: string | null): ProbeResult {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
latencyMs: 0,
|
||||||
|
error: "Cloudflare Worker: результаты устарели или KV пуст",
|
||||||
|
colo,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runAllChecks(
|
export async function runAllChecks(
|
||||||
@@ -322,10 +377,7 @@ export async function runAllChecks(
|
|||||||
const targets = repos.listHealthCheckTargets(db);
|
const targets = repos.listHealthCheckTargets(db);
|
||||||
const gapMs = Math.max(0, options.probeGapMs ?? 2000);
|
const gapMs = Math.max(0, options.probeGapMs ?? 2000);
|
||||||
const local = new LocalHealthCheckProvider();
|
const local = new LocalHealthCheckProvider();
|
||||||
const worker =
|
const staleAfterMs = options.staleAfterMs ?? 10 * 60_000;
|
||||||
options.worker?.url && options.worker.token
|
|
||||||
? new CloudflareWorkerHealthCheckProvider(options.worker)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const byPhysical = new Map<string, HealthCheckTarget[]>();
|
const byPhysical = new Map<string, HealthCheckTarget[]>();
|
||||||
for (const target of targets) {
|
for (const target of targets) {
|
||||||
@@ -335,80 +387,69 @@ export async function runAllChecks(
|
|||||||
else byPhysical.set(key, [target]);
|
else byPhysical.set(key, [target]);
|
||||||
}
|
}
|
||||||
|
|
||||||
let probeIndex = 0;
|
const localGroups: HealthCheckTarget[][] = [];
|
||||||
|
const cloudflareGroups: HealthCheckTarget[][] = [];
|
||||||
for (const group of byPhysical.values()) {
|
for (const group of byPhysical.values()) {
|
||||||
|
if (group[0]?.provider === "cloudflare") cloudflareGroups.push(group);
|
||||||
|
else localGroups.push(group);
|
||||||
|
}
|
||||||
|
|
||||||
|
let probeIndex = 0;
|
||||||
|
for (const group of localGroups) {
|
||||||
if (probeIndex > 0 && gapMs > 0) {
|
if (probeIndex > 0 && gapMs > 0) {
|
||||||
await sleep(gapMs);
|
await sleep(gapMs);
|
||||||
}
|
}
|
||||||
probeIndex += 1;
|
probeIndex += 1;
|
||||||
|
|
||||||
// Prefer binding hostname for SNI when several scopes share one IP.
|
|
||||||
const representative =
|
const representative =
|
||||||
group.find((t) => t.scope === "binding") ?? group[0]!;
|
group.find((t) => t.scope === "binding") ?? group[0]!;
|
||||||
const result = await executeProbe(representative, local, worker);
|
const result = await local.probe(representative);
|
||||||
|
|
||||||
for (const target of group) {
|
for (const target of group) {
|
||||||
const prev = repos.getIpHealthStatusRow(
|
applyProbeResult(db, target, result, options);
|
||||||
db,
|
}
|
||||||
target.scope,
|
}
|
||||||
target.ref_id,
|
|
||||||
target.ip,
|
if (cloudflareGroups.length > 0) {
|
||||||
);
|
const mailbox = options.mailbox ?? null;
|
||||||
const { state, failures, successes, node } = deriveState(
|
const resultsDoc = mailbox ? await mailbox.getResults() : null;
|
||||||
result.ok,
|
const byKey = indexResults(resultsDoc);
|
||||||
result.latencyMs,
|
const stale = !mailbox || isResultsStale(resultsDoc, staleAfterMs);
|
||||||
prev
|
const colo = resultsDoc?.colo ?? null;
|
||||||
? {
|
|
||||||
consecutive_failures: prev.consecutive_failures,
|
if (mailbox) {
|
||||||
consecutive_successes: prev.consecutive_successes,
|
try {
|
||||||
status: prev.status,
|
const next = buildTargetsDoc(targets);
|
||||||
}
|
const current = await mailbox.getTargets();
|
||||||
: null,
|
if (current?.fingerprint !== next.fingerprint) {
|
||||||
options.thresholds,
|
await mailbox.putTargets(next);
|
||||||
);
|
}
|
||||||
const prevState: IpHealthState | null = prev
|
} catch {
|
||||||
? (prev.status as IpHealthState)
|
// ingest still proceeds
|
||||||
: null;
|
|
||||||
const provider = target.provider === "cloudflare" ? "cloudflare" : "local";
|
|
||||||
repos.upsertIpHealthStatus(
|
|
||||||
db,
|
|
||||||
target.scope,
|
|
||||||
target.ref_id,
|
|
||||||
target.ip,
|
|
||||||
state,
|
|
||||||
result.latencyMs,
|
|
||||||
failures,
|
|
||||||
result.error,
|
|
||||||
successes,
|
|
||||||
{ colo: result.colo ?? null, provider },
|
|
||||||
);
|
|
||||||
repos.insertHealthProbeLog(db, {
|
|
||||||
scope: target.scope,
|
|
||||||
refId: target.ref_id,
|
|
||||||
ip: target.ip,
|
|
||||||
provider,
|
|
||||||
status: state,
|
|
||||||
ok: result.ok,
|
|
||||||
latencyMs: result.latencyMs,
|
|
||||||
colo: result.colo ?? null,
|
|
||||||
error: result.error,
|
|
||||||
});
|
|
||||||
const matchedNode = repos.findNodeByIp(db, target.ip);
|
|
||||||
if (matchedNode && matchedNode.enabled) {
|
|
||||||
repos.updateNode(db, matchedNode.id, {
|
|
||||||
health_status: node,
|
|
||||||
consecutive_failures: failures,
|
|
||||||
consecutive_successes: successes,
|
|
||||||
last_check_at: new Date().toISOString().replace("T", " ").slice(0, 19),
|
|
||||||
last_failure_reason: result.error,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
if (prevState !== state) {
|
}
|
||||||
options.onStatusChange?.(target, prevState, state);
|
|
||||||
|
for (const group of cloudflareGroups) {
|
||||||
|
const representative =
|
||||||
|
group.find((t) => t.scope === "binding") ?? group[0]!;
|
||||||
|
const item = byKey.get(originProbeKey(representative));
|
||||||
|
let result: ProbeResult;
|
||||||
|
if (!mailbox) {
|
||||||
|
result = workerNotConfiguredResult();
|
||||||
|
} else if (stale || !item) {
|
||||||
|
result = staleWorkerResult(colo);
|
||||||
|
} else {
|
||||||
|
result = {
|
||||||
|
ok: item.ok,
|
||||||
|
latencyMs: item.latencyMs,
|
||||||
|
error: item.error,
|
||||||
|
colo,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
for (const target of group) {
|
||||||
|
applyProbeResult(db, target, result, options);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Orphan rows (old IPs / hostname keys) still feed MAX latency on group badge.
|
|
||||||
repos.pruneStaleIpHealthStatus(db, targets);
|
repos.pruneStaleIpHealthStatus(db, targets);
|
||||||
return targets.length;
|
return targets.length;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
export function loadHealthProbeWorkerSource(): string {
|
||||||
|
const dir = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const candidates = [
|
||||||
|
join(dir, "health-probe-worker.mjs"),
|
||||||
|
join(process.cwd(), "dist/health-probe-worker.mjs"),
|
||||||
|
join(process.cwd(), "health-probe-worker.mjs"),
|
||||||
|
join(dir, "../../../../../workers/health-probe/src/index.mjs"),
|
||||||
|
join(process.cwd(), "../../workers/health-probe/src/index.mjs"),
|
||||||
|
join(process.cwd(), "workers/health-probe/src/index.mjs"),
|
||||||
|
];
|
||||||
|
for (const path of candidates) {
|
||||||
|
if (existsSync(path)) {
|
||||||
|
return readFileSync(path, "utf8");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error("не найден исходник Worker health-probe");
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import { getAppSettings, repos, updateAppSettings, type HealthEngineFallbacks } from "@cfdm/db";
|
||||||
|
import type { Db } from "@cfdm/db";
|
||||||
|
import { HEALTH_PROBE_KV_TITLE, HEALTH_PROBE_SCRIPT_NAME } from "@cfdm/shared";
|
||||||
|
import type { CloudflareClient } from "../../lib/cf-client.js";
|
||||||
|
import { AppError } from "../../errors.js";
|
||||||
|
import { loadHealthProbeWorkerSource } from "./health-probe-script.js";
|
||||||
|
import {
|
||||||
|
buildTargetsDoc,
|
||||||
|
createCloudflareKvMailbox,
|
||||||
|
toCloudflareCron,
|
||||||
|
type HealthMailbox,
|
||||||
|
} from "./mailbox.js";
|
||||||
|
|
||||||
|
export const DEFAULT_HEALTH_FALLBACKS: HealthEngineFallbacks = {
|
||||||
|
healthCheckCron: "0 */2 * * * *",
|
||||||
|
healthDegradedFailures: 1,
|
||||||
|
healthDownFailures: 2,
|
||||||
|
healthLatencyWarnMs: 1000,
|
||||||
|
healthSuccessRecoveries: 2,
|
||||||
|
healthWorkerUrl: "",
|
||||||
|
healthWorkerTokenSet: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function resolveAccountId(
|
||||||
|
cf: CloudflareClient,
|
||||||
|
db: Db,
|
||||||
|
cached?: string | null,
|
||||||
|
): Promise<string> {
|
||||||
|
const trimmed = cached?.trim();
|
||||||
|
if (trimmed) return trimmed;
|
||||||
|
const domains = repos.listDomains(db);
|
||||||
|
for (const domain of domains) {
|
||||||
|
if (!domain.cf_zone_id) continue;
|
||||||
|
try {
|
||||||
|
const zone = await cf.getZone(domain.cf_zone_id);
|
||||||
|
const id = zone.account?.id?.trim();
|
||||||
|
if (id) return id;
|
||||||
|
} catch {
|
||||||
|
// try next zone / accounts list
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const accounts = await cf.listAccounts();
|
||||||
|
const id = accounts[0]?.id?.trim();
|
||||||
|
if (!id) {
|
||||||
|
throw AppError.cloudflare(
|
||||||
|
"Не удалось определить Cloudflare account_id. Добавьте зону или расширьте права токена (Account Settings Read).",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureKvNamespace(
|
||||||
|
cf: CloudflareClient,
|
||||||
|
accountId: string,
|
||||||
|
existingId?: string | null,
|
||||||
|
): Promise<string> {
|
||||||
|
if (existingId?.trim()) return existingId.trim();
|
||||||
|
const listed = await cf.listKvNamespaces(accountId);
|
||||||
|
const found = listed.find((ns) => ns.title === HEALTH_PROBE_KV_TITLE);
|
||||||
|
if (found?.id) return found.id;
|
||||||
|
const created = await cf.createKvNamespace(accountId, HEALTH_PROBE_KV_TITLE);
|
||||||
|
if (!created.id) {
|
||||||
|
throw AppError.cloudflare("Cloudflare не вернул id KV namespace");
|
||||||
|
}
|
||||||
|
return created.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureHealthWorker(
|
||||||
|
db: Db,
|
||||||
|
cf: CloudflareClient,
|
||||||
|
fallbacks: HealthEngineFallbacks,
|
||||||
|
): Promise<{ url: string; kvNamespaceId: string; accountId: string }> {
|
||||||
|
const settings = getAppSettings(db, fallbacks);
|
||||||
|
try {
|
||||||
|
const accountId = await resolveAccountId(cf, db, settings.healthWorkerAccountId);
|
||||||
|
const kvNamespaceId = await ensureKvNamespace(
|
||||||
|
cf,
|
||||||
|
accountId,
|
||||||
|
settings.healthWorkerKvNamespaceId,
|
||||||
|
);
|
||||||
|
const source = loadHealthProbeWorkerSource();
|
||||||
|
await cf.putWorkerScript({
|
||||||
|
accountId,
|
||||||
|
scriptName: HEALTH_PROBE_SCRIPT_NAME,
|
||||||
|
source,
|
||||||
|
kvNamespaceId,
|
||||||
|
});
|
||||||
|
await cf.putWorkerSchedules(accountId, HEALTH_PROBE_SCRIPT_NAME, [
|
||||||
|
toCloudflareCron(settings.healthCheckCron),
|
||||||
|
]);
|
||||||
|
try {
|
||||||
|
await cf.enableWorkersDev(accountId, HEALTH_PROBE_SCRIPT_NAME);
|
||||||
|
} catch {
|
||||||
|
// workers.dev may already be on
|
||||||
|
}
|
||||||
|
const subdomain = await cf.getWorkersSubdomain(accountId);
|
||||||
|
const url = subdomain
|
||||||
|
? `https://${HEALTH_PROBE_SCRIPT_NAME}.${subdomain}.workers.dev`
|
||||||
|
: settings.healthWorkerUrl || `https://${HEALTH_PROBE_SCRIPT_NAME}.workers.dev`;
|
||||||
|
|
||||||
|
updateAppSettings(
|
||||||
|
db,
|
||||||
|
{
|
||||||
|
healthWorkerAccountId: accountId,
|
||||||
|
healthWorkerKvNamespaceId: kvNamespaceId,
|
||||||
|
healthWorkerUrl: url,
|
||||||
|
healthWorkerError: null,
|
||||||
|
healthWorkerDeployedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
fallbacks,
|
||||||
|
);
|
||||||
|
|
||||||
|
await syncCloudflareTargetsToKv(db, cf, fallbacks);
|
||||||
|
return { url, kvNamespaceId, accountId };
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
updateAppSettings(db, { healthWorkerError: message }, fallbacks);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function maybeEnsureHealthWorker(
|
||||||
|
db: Db,
|
||||||
|
cf: CloudflareClient,
|
||||||
|
fallbacks: HealthEngineFallbacks,
|
||||||
|
): Promise<void> {
|
||||||
|
const hasCloudflare = repos
|
||||||
|
.listHealthCheckTargets(db)
|
||||||
|
.some((target) => target.provider === "cloudflare");
|
||||||
|
if (!hasCloudflare) return;
|
||||||
|
const settings = getAppSettings(db, fallbacks);
|
||||||
|
if (settings.healthWorkerKvNamespaceId.trim() && !settings.healthWorkerError) {
|
||||||
|
await syncCloudflareTargetsToKv(db, cf, fallbacks);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await ensureHealthWorker(db, cf, fallbacks);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mailboxFromSettings(
|
||||||
|
db: Db,
|
||||||
|
cf: CloudflareClient,
|
||||||
|
fallbacks: HealthEngineFallbacks,
|
||||||
|
): HealthMailbox | null {
|
||||||
|
const settings = getAppSettings(db, fallbacks);
|
||||||
|
const accountId = settings.healthWorkerAccountId.trim();
|
||||||
|
const ns = settings.healthWorkerKvNamespaceId.trim();
|
||||||
|
if (!accountId || !ns) return null;
|
||||||
|
return createCloudflareKvMailbox(cf, accountId, ns);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncCloudflareTargetsToKv(
|
||||||
|
db: Db,
|
||||||
|
cf: CloudflareClient,
|
||||||
|
fallbacks: HealthEngineFallbacks,
|
||||||
|
mailbox?: HealthMailbox | null,
|
||||||
|
): Promise<void> {
|
||||||
|
const box = mailbox ?? mailboxFromSettings(db, cf, fallbacks);
|
||||||
|
if (!box) return;
|
||||||
|
const next = buildTargetsDoc(repos.listHealthCheckTargets(db));
|
||||||
|
const current = await box.getTargets();
|
||||||
|
if (current?.fingerprint === next.fingerprint) return;
|
||||||
|
await box.putTargets(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fireEnsureHealthWorker(
|
||||||
|
db: Db,
|
||||||
|
cf: CloudflareClient,
|
||||||
|
fallbacks: HealthEngineFallbacks,
|
||||||
|
log?: { warn: (obj: unknown, msg: string) => void },
|
||||||
|
): void {
|
||||||
|
if (process.env.VITEST) return;
|
||||||
|
if (!cf.isConfigured) return;
|
||||||
|
const hasCloudflare = repos
|
||||||
|
.listHealthCheckTargets(db)
|
||||||
|
.some((target) => target.provider === "cloudflare");
|
||||||
|
if (!hasCloudflare) {
|
||||||
|
void syncCloudflareTargetsToKv(db, cf, fallbacks).catch((err) => {
|
||||||
|
log?.warn({ err }, "health worker KV sync failed");
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void maybeEnsureHealthWorker(db, cf, fallbacks).catch((err) => {
|
||||||
|
log?.warn({ err }, "health worker ensure failed");
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import type {
|
||||||
|
HealthCheckTarget,
|
||||||
|
HealthProbeResultItem,
|
||||||
|
HealthProbeResultsDoc,
|
||||||
|
HealthProbeTargetItem,
|
||||||
|
HealthProbeTargetsDoc,
|
||||||
|
} from "@cfdm/shared";
|
||||||
|
import { HEALTH_KV_RESULTS_KEY, HEALTH_KV_TARGETS_KEY } from "@cfdm/shared";
|
||||||
|
import type { CloudflareClient } from "../../lib/cf-client.js";
|
||||||
|
|
||||||
|
export interface HealthMailbox {
|
||||||
|
getTargets(): Promise<HealthProbeTargetsDoc | null>;
|
||||||
|
putTargets(doc: HealthProbeTargetsDoc): Promise<void>;
|
||||||
|
getResults(): Promise<HealthProbeResultsDoc | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCloudflareKvMailbox(
|
||||||
|
cf: CloudflareClient,
|
||||||
|
accountId: string,
|
||||||
|
namespaceId: string,
|
||||||
|
): HealthMailbox {
|
||||||
|
return {
|
||||||
|
async getTargets() {
|
||||||
|
return readJson<HealthProbeTargetsDoc>(cf, accountId, namespaceId, HEALTH_KV_TARGETS_KEY);
|
||||||
|
},
|
||||||
|
async putTargets(doc) {
|
||||||
|
await cf.kvPut(accountId, namespaceId, HEALTH_KV_TARGETS_KEY, JSON.stringify(doc));
|
||||||
|
},
|
||||||
|
async getResults() {
|
||||||
|
return readJson<HealthProbeResultsDoc>(cf, accountId, namespaceId, HEALTH_KV_RESULTS_KEY);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readJson<T>(
|
||||||
|
cf: CloudflareClient,
|
||||||
|
accountId: string,
|
||||||
|
namespaceId: string,
|
||||||
|
key: string,
|
||||||
|
): Promise<T | null> {
|
||||||
|
const raw = await cf.kvGet(accountId, namespaceId, key);
|
||||||
|
if (!raw) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as T;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function originProbeKey(target: HealthCheckTarget): string {
|
||||||
|
const port = target.port ?? (target.type === "http" ? 80 : 80);
|
||||||
|
const ip = String(target.ip || "").trim().toLowerCase();
|
||||||
|
if (target.type === "http") {
|
||||||
|
const path = (target.path?.trim() || "/") || "/";
|
||||||
|
const expected = target.expected_status ?? "";
|
||||||
|
return `http|${ip}|${port}|${path}|${expected}`;
|
||||||
|
}
|
||||||
|
if (target.type === "tcp") return `tcp|${ip}|${port}`;
|
||||||
|
if (target.type === "ping") {
|
||||||
|
return `ping|${String(target.hostname || target.ip || "").trim().toLowerCase()}`;
|
||||||
|
}
|
||||||
|
if (target.type === "dns") {
|
||||||
|
return `dns|${String(target.hostname || target.ip || "").trim().toLowerCase()}`;
|
||||||
|
}
|
||||||
|
return `${target.type}|${ip}|${port}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cloudflareMailboxTargets(
|
||||||
|
targets: HealthCheckTarget[],
|
||||||
|
): HealthProbeTargetItem[] {
|
||||||
|
const unique = new Map<string, HealthProbeTargetItem>();
|
||||||
|
for (const target of targets) {
|
||||||
|
if (target.provider !== "cloudflare") continue;
|
||||||
|
if (target.type !== "tcp" && target.type !== "http") continue;
|
||||||
|
const key = originProbeKey(target);
|
||||||
|
if (unique.has(key)) continue;
|
||||||
|
unique.set(key, {
|
||||||
|
key,
|
||||||
|
ip: target.ip,
|
||||||
|
hostname: target.hostname || target.ip,
|
||||||
|
type: target.type,
|
||||||
|
port: target.port ?? (target.type === "http" ? 80 : 80),
|
||||||
|
path: target.path ?? "/",
|
||||||
|
expectedStatus: target.expected_status,
|
||||||
|
timeoutMs: target.timeout_ms ?? 3000,
|
||||||
|
verifyTls: Boolean(target.verify_tls),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return [...unique.values()].sort((a, b) => a.key.localeCompare(b.key));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fingerprintTargets(items: HealthProbeTargetItem[]): string {
|
||||||
|
return items
|
||||||
|
.map(
|
||||||
|
(item) =>
|
||||||
|
`${item.key}|${item.hostname}|${item.timeoutMs ?? ""}|${item.verifyTls ? "1" : "0"}`,
|
||||||
|
)
|
||||||
|
.join(";");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildTargetsDoc(targets: HealthCheckTarget[]): HealthProbeTargetsDoc {
|
||||||
|
const items = cloudflareMailboxTargets(targets);
|
||||||
|
return {
|
||||||
|
fingerprint: fingerprintTargets(items),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
items,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function indexResults(
|
||||||
|
doc: HealthProbeResultsDoc | null,
|
||||||
|
): Map<string, HealthProbeResultItem> {
|
||||||
|
const map = new Map<string, HealthProbeResultItem>();
|
||||||
|
if (!doc?.items) return map;
|
||||||
|
for (const item of doc.items) {
|
||||||
|
map.set(item.key, item);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isResultsStale(doc: HealthProbeResultsDoc | null, staleAfterMs: number): boolean {
|
||||||
|
if (!doc?.probedAt) return true;
|
||||||
|
const ts = Date.parse(doc.probedAt);
|
||||||
|
if (!Number.isFinite(ts)) return true;
|
||||||
|
return Date.now() - ts > staleAfterMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop seconds from toad 6-field cron for Cloudflare Workers (5-field). */
|
||||||
|
export function toCloudflareCron(expr: string): string {
|
||||||
|
const parts = expr.trim().split(/\s+/).filter(Boolean);
|
||||||
|
if (parts.length === 6) return parts.slice(1).join(" ");
|
||||||
|
if (parts.length === 5) return parts.join(" ");
|
||||||
|
throw new Error("некорректное cron-выражение");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cronStaleAfterMs(expr: string): number {
|
||||||
|
const cf = toCloudflareCron(expr);
|
||||||
|
const minute = cf.split(/\s+/)[0] ?? "*";
|
||||||
|
if (minute.startsWith("*/")) {
|
||||||
|
const n = Number(minute.slice(2));
|
||||||
|
if (Number.isFinite(n) && n > 0) return Math.max(n * 2, 5) * 60_000;
|
||||||
|
}
|
||||||
|
if (minute === "*") return 10 * 60_000;
|
||||||
|
return 10 * 60_000;
|
||||||
|
}
|
||||||
@@ -1,93 +1,13 @@
|
|||||||
import type { HealthCheckTarget } from "@cfdm/shared";
|
export function workerNotConfiguredResult(): {
|
||||||
import type { ProbeResult } from "../health-check-service.js";
|
ok: false;
|
||||||
import type { HealthCheckProvider } from "./provider.js";
|
latencyMs: number;
|
||||||
|
error: string;
|
||||||
export interface WorkerProbeConfig {
|
colo: null;
|
||||||
url: string;
|
} {
|
||||||
token: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const WORKER_NOT_CONFIGURED = "Cloudflare Worker не настроен (URL и токен)";
|
|
||||||
|
|
||||||
export class CloudflareWorkerHealthCheckProvider implements HealthCheckProvider {
|
|
||||||
readonly kind = "cloudflare" as const;
|
|
||||||
|
|
||||||
constructor(private readonly config: WorkerProbeConfig) {}
|
|
||||||
|
|
||||||
async probe(target: HealthCheckTarget): Promise<ProbeResult> {
|
|
||||||
const base = this.config.url.replace(/\/$/, "");
|
|
||||||
if (!base || !this.config.token) {
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
latencyMs: 0,
|
|
||||||
error: WORKER_NOT_CONFIGURED,
|
|
||||||
colo: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const timeoutMs = Math.max(100, target.timeout_ms ?? 3000);
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timer = setTimeout(() => controller.abort(), timeoutMs + 2500);
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${base}/probe`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${this.config.token}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
type: target.type === "http" ? "http" : "tcp",
|
|
||||||
ip: target.ip,
|
|
||||||
hostname: target.hostname,
|
|
||||||
port: target.port ?? (target.type === "http" ? 80 : 80),
|
|
||||||
path: target.path ?? "/",
|
|
||||||
expected_status: target.expected_status,
|
|
||||||
timeout_ms: timeoutMs,
|
|
||||||
verify_tls: Boolean(target.verify_tls),
|
|
||||||
method: "GET",
|
|
||||||
}),
|
|
||||||
signal: controller.signal,
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const text = await res.text().catch(() => "");
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
latencyMs: 0,
|
|
||||||
error: `Worker HTTP ${res.status}${text ? `: ${text.slice(0, 180)}` : ""}`,
|
|
||||||
colo: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const body = (await res.json()) as {
|
|
||||||
ok?: boolean;
|
|
||||||
latencyMs?: number;
|
|
||||||
error?: string | null;
|
|
||||||
colo?: string | null;
|
|
||||||
};
|
|
||||||
const ok = Boolean(body.ok);
|
|
||||||
return {
|
|
||||||
ok,
|
|
||||||
latencyMs: typeof body.latencyMs === "number" ? body.latencyMs : 0,
|
|
||||||
error: ok ? null : (body.error ?? "probe failed"),
|
|
||||||
colo: body.colo ?? null,
|
|
||||||
};
|
|
||||||
} catch (err) {
|
|
||||||
const message =
|
|
||||||
err instanceof Error
|
|
||||||
? err.name === "AbortError"
|
|
||||||
? "Worker timeout"
|
|
||||||
: err.message
|
|
||||||
: "Worker probe failed";
|
|
||||||
return { ok: false, latencyMs: 0, error: message, colo: null };
|
|
||||||
} finally {
|
|
||||||
clearTimeout(timer);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function workerNotConfiguredResult(): ProbeResult {
|
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
latencyMs: 0,
|
latencyMs: 0,
|
||||||
error: WORKER_NOT_CONFIGURED,
|
error: "Cloudflare Worker не настроен (нет KV mailbox)",
|
||||||
colo: null,
|
colo: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { isValidIpv4 } from "../lib/validators.js";
|
|||||||
import * as dnsService from "./dns-service.js";
|
import * as dnsService from "./dns-service.js";
|
||||||
import * as domainService from "./domain-service.js";
|
import * as domainService from "./domain-service.js";
|
||||||
import { syncServiceToVpsTracker } from "./vps-tracker-sync.js";
|
import { syncServiceToVpsTracker } from "./vps-tracker-sync.js";
|
||||||
|
import { fireEnsureHealthWorker, DEFAULT_HEALTH_FALLBACKS } from "./health/health-worker-deploy.js";
|
||||||
import {
|
import {
|
||||||
selectActiveIpsByMode,
|
selectActiveIpsByMode,
|
||||||
withBindingLock,
|
withBindingLock,
|
||||||
@@ -1250,6 +1251,8 @@ export async function updateConfig(
|
|||||||
|
|
||||||
void syncServiceToVpsTracker(db, id, removedBindingIds);
|
void syncServiceToVpsTracker(db, id, removedBindingIds);
|
||||||
|
|
||||||
|
fireEnsureHealthWorker(db, cf, DEFAULT_HEALTH_FALLBACKS);
|
||||||
|
|
||||||
const [view] = attachServiceHealth(db, [await buildView(db, id)]);
|
const [view] = attachServiceHealth(db, [await buildView(db, id)]);
|
||||||
return view!;
|
return view!;
|
||||||
}
|
}
|
||||||
@@ -1261,7 +1264,7 @@ export async function createGroup(
|
|||||||
): Promise<ServiceGroup> {
|
): Promise<ServiceGroup> {
|
||||||
const groupType = body.type?.trim() || "custom";
|
const groupType = body.type?.trim() || "custom";
|
||||||
const domain = await normalizeGroupDomain(db, cf, body.domain);
|
const domain = await normalizeGroupDomain(db, cf, body.domain);
|
||||||
return repos.createServiceGroup(
|
const group = repos.createServiceGroup(
|
||||||
db,
|
db,
|
||||||
body.name,
|
body.name,
|
||||||
groupType,
|
groupType,
|
||||||
@@ -1280,6 +1283,8 @@ export async function createGroup(
|
|||||||
health_check_provider: body.health_check_provider,
|
health_check_provider: body.health_check_provider,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
fireEnsureHealthWorker(db, cf, DEFAULT_HEALTH_FALLBACKS);
|
||||||
|
return group;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateGroup(
|
export async function updateGroup(
|
||||||
@@ -1322,6 +1327,7 @@ export async function updateGroup(
|
|||||||
group = repos.getServiceGroup(db, id);
|
group = repos.getServiceGroup(db, id);
|
||||||
}
|
}
|
||||||
await syncEnabledServicesInGroup(db, cf, id);
|
await syncEnabledServicesInGroup(db, cf, id);
|
||||||
|
fireEnsureHealthWorker(db, cf, DEFAULT_HEALTH_FALLBACKS);
|
||||||
return group;
|
return group;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import { createServer, type Server as HttpServer } from "node:http";
|
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { buildApp } from "../src/app.js";
|
import { buildApp } from "../src/app.js";
|
||||||
import { loadConfig } from "../src/config.js";
|
import { loadConfig } from "../src/config.js";
|
||||||
import { repos, type Db } from "@cfdm/db";
|
import { repos, type Db } from "@cfdm/db";
|
||||||
import * as healthCheckService from "../src/services/health-check-service.js";
|
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>>) {
|
async function authHeaders(app: Awaited<ReturnType<typeof buildApp>>) {
|
||||||
const res = await app.inject({
|
const res = await app.inject({
|
||||||
@@ -16,37 +18,6 @@ async function authHeaders(app: Awaited<ReturnType<typeof buildApp>>) {
|
|||||||
return { authorization: `Bearer ${token}` };
|
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(
|
async function seedBinding(
|
||||||
db: Db,
|
db: Db,
|
||||||
opts: { provider: "local" | "cloudflare"; ip: string },
|
opts: { provider: "local" | "cloudflare"; ip: string },
|
||||||
@@ -76,7 +47,51 @@ const thresholds = {
|
|||||||
successRecoveries: 2,
|
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 () => {
|
it("lists only local providers when no cloudflare bindings", async () => {
|
||||||
const app = await buildApp({
|
const app = await buildApp({
|
||||||
config: { ...loadConfig(), staticDir: null },
|
config: { ...loadConfig(), staticDir: null },
|
||||||
@@ -93,7 +108,7 @@ describe("health-check XOR worker", () => {
|
|||||||
await app.close();
|
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({
|
const app = await buildApp({
|
||||||
config: { ...loadConfig(), staticDir: null },
|
config: { ...loadConfig(), staticDir: null },
|
||||||
memory: true,
|
memory: true,
|
||||||
@@ -105,7 +120,7 @@ describe("health-check XOR worker", () => {
|
|||||||
await healthCheckService.runAllChecks(app.db, {
|
await healthCheckService.runAllChecks(app.db, {
|
||||||
thresholds,
|
thresholds,
|
||||||
probeGapMs: 0,
|
probeGapMs: 0,
|
||||||
worker: null,
|
mailbox: null,
|
||||||
});
|
});
|
||||||
const row = repos.getIpHealthStatusRow(
|
const row = repos.getIpHealthStatusRow(
|
||||||
app.db,
|
app.db,
|
||||||
@@ -118,17 +133,7 @@ describe("health-check XOR worker", () => {
|
|||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("worker mock 200 writes colo and last_checked_at", async () => {
|
it("KV results write colo and last_checked_at without HTTP /probe", 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" },
|
|
||||||
};
|
|
||||||
});
|
|
||||||
const app = await buildApp({
|
const app = await buildApp({
|
||||||
config: { ...loadConfig(), staticDir: null },
|
config: { ...loadConfig(), staticDir: null },
|
||||||
memory: true,
|
memory: true,
|
||||||
@@ -138,10 +143,34 @@ describe("health-check XOR worker", () => {
|
|||||||
provider: "cloudflare",
|
provider: "cloudflare",
|
||||||
ip: "203.0.113.10",
|
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, {
|
await healthCheckService.runAllChecks(app.db, {
|
||||||
thresholds,
|
thresholds,
|
||||||
probeGapMs: 0,
|
probeGapMs: 0,
|
||||||
worker: { url: mock.url, token: "secret" },
|
mailbox,
|
||||||
});
|
});
|
||||||
const row = repos.getIpHealthStatusRow(
|
const row = repos.getIpHealthStatusRow(
|
||||||
app.db,
|
app.db,
|
||||||
@@ -170,7 +199,6 @@ describe("health-check XOR worker", () => {
|
|||||||
};
|
};
|
||||||
const ipRow = body.ip_health.find((item) => item.ip === "203.0.113.10");
|
const ipRow = body.ip_health.find((item) => item.ip === "203.0.113.10");
|
||||||
expect(ipRow?.colo).toBe("AMS");
|
expect(ipRow?.colo).toBe("AMS");
|
||||||
expect(ipRow?.last_checked_at).toBeTruthy();
|
|
||||||
expect(ipRow?.provider).toBe("cloudflare");
|
expect(ipRow?.provider).toBe("cloudflare");
|
||||||
|
|
||||||
const logRes = await app.inject({
|
const logRes = await app.inject({
|
||||||
@@ -182,12 +210,10 @@ describe("health-check XOR worker", () => {
|
|||||||
const logBody = logRes.json() as { items: Array<{ colo: string | null }> };
|
const logBody = logRes.json() as { items: Array<{ colo: string | null }> };
|
||||||
expect(logBody.items[0]?.colo).toBe("AMS");
|
expect(logBody.items[0]?.colo).toBe("AMS");
|
||||||
|
|
||||||
await new Promise<void>((resolve) => mock.server.close(() => resolve()));
|
|
||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("worker timeout is recorded, not local probe", async () => {
|
it("stale KV results are recorded, not local probe", async () => {
|
||||||
const mock = await startWorkerMock(() => "hang");
|
|
||||||
const app = await buildApp({
|
const app = await buildApp({
|
||||||
config: { ...loadConfig(), staticDir: null },
|
config: { ...loadConfig(), staticDir: null },
|
||||||
memory: true,
|
memory: true,
|
||||||
@@ -199,7 +225,12 @@ describe("health-check XOR worker", () => {
|
|||||||
await healthCheckService.runAllChecks(app.db, {
|
await healthCheckService.runAllChecks(app.db, {
|
||||||
thresholds,
|
thresholds,
|
||||||
probeGapMs: 0,
|
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(
|
const row = repos.getIpHealthStatusRow(
|
||||||
app.db,
|
app.db,
|
||||||
@@ -207,9 +238,8 @@ describe("health-check XOR worker", () => {
|
|||||||
binding.id,
|
binding.id,
|
||||||
"203.0.113.20",
|
"203.0.113.20",
|
||||||
);
|
);
|
||||||
expect(row?.last_error).toMatch(/timeout|Worker/i);
|
expect(row?.last_error).toMatch(/устарели|KV/i);
|
||||||
expect(row?.provider).toBe("cloudflare");
|
expect(row?.provider).toBe("cloudflare");
|
||||||
await new Promise<void>((resolve) => mock.server.close(() => resolve()));
|
|
||||||
await app.close();
|
await app.close();
|
||||||
}, 15_000);
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -40,12 +40,14 @@ describe("settings health engine", () => {
|
|||||||
healthDownFailures: number;
|
healthDownFailures: number;
|
||||||
healthLatencyWarnMs: number;
|
healthLatencyWarnMs: number;
|
||||||
healthSuccessRecoveries: number;
|
healthSuccessRecoveries: number;
|
||||||
|
healthWorkerStatus?: string;
|
||||||
};
|
};
|
||||||
expect(body.healthCheckCron).toBe("*/30 * * * * *");
|
expect(body.healthCheckCron).toBe("*/30 * * * * *");
|
||||||
expect(body.healthDegradedFailures).toBe(3);
|
expect(body.healthDegradedFailures).toBe(3);
|
||||||
expect(body.healthDownFailures).toBe(4);
|
expect(body.healthDownFailures).toBe(4);
|
||||||
expect(body.healthLatencyWarnMs).toBe(1500);
|
expect(body.healthLatencyWarnMs).toBe(1500);
|
||||||
expect(body.healthSuccessRecoveries).toBe(5);
|
expect(body.healthSuccessRecoveries).toBe(5);
|
||||||
|
expect(body.healthWorkerStatus).toBe("missing");
|
||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { copyFileSync } from "node:fs";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { defineConfig } from "tsup";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
entry: ["src/server.ts"],
|
||||||
|
format: ["esm"],
|
||||||
|
dts: true,
|
||||||
|
async onSuccess() {
|
||||||
|
const root = join(dirname(fileURLToPath(import.meta.url)), "../..");
|
||||||
|
copyFileSync(
|
||||||
|
join(root, "workers/health-probe/src/index.mjs"),
|
||||||
|
join(dirname(fileURLToPath(import.meta.url)), "dist/health-probe-worker.mjs"),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -4,6 +4,7 @@ export default defineConfig({
|
|||||||
test: {
|
test: {
|
||||||
environment: "node",
|
environment: "node",
|
||||||
include: ["test/**/*.test.ts"],
|
include: ["test/**/*.test.ts"],
|
||||||
|
testTimeout: 20_000,
|
||||||
typecheck: {
|
typecheck: {
|
||||||
tsconfig: "./tsconfig.test.json",
|
tsconfig: "./tsconfig.test.json",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -211,12 +211,12 @@ export function HealthCheckConfigFields({
|
|||||||
<AlertTitle>Cloudflare Worker</AlertTitle>
|
<AlertTitle>Cloudflare Worker</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
Проба с edge Cloudflare, не продукт Health Checks API (на Free его нет).
|
Проба с edge Cloudflare, не продукт Health Checks API (на Free его нет).
|
||||||
Регионы WNAM/WEU недоступны — в результате будет colo ближайшего POP
|
Worker создаётся автоматически и сам опрашивает IP (KV mailbox).
|
||||||
(например AMS). URL и токен Worker — в{' '}
|
Статус деплоя — в{' '}
|
||||||
<Link to="/settings/health" className="text-foreground underline">
|
<Link to="/settings/health" className="text-foreground underline">
|
||||||
Настройках → Health-check
|
Настройках → Health-check
|
||||||
</Link>
|
</Link>
|
||||||
. Если Worker не задан, цель не пробируется как Local.
|
. Если Worker не создан, цель не пробируется как Local.
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ import {
|
|||||||
import { FieldGroup } from '@cfdm/ui/components/field'
|
import { FieldGroup } from '@cfdm/ui/components/field'
|
||||||
import { Input } from '@cfdm/ui/components/input'
|
import { Input } from '@cfdm/ui/components/input'
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
|
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
|
||||||
|
import { Badge } from '@/components/reui/badge'
|
||||||
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
healthCheckCron: z.string().trim().min(1, 'Укажите cron').max(64),
|
healthCheckCron: z.string().trim().min(1, 'Укажите cron').max(64),
|
||||||
@@ -35,8 +37,6 @@ const formSchema = z.object({
|
|||||||
healthDownFailures: z.number().int().min(1).max(50),
|
healthDownFailures: z.number().int().min(1).max(50),
|
||||||
healthLatencyWarnMs: z.number().int().min(50).max(60_000),
|
healthLatencyWarnMs: z.number().int().min(50).max(60_000),
|
||||||
healthSuccessRecoveries: z.number().int().min(1).max(20),
|
healthSuccessRecoveries: z.number().int().min(1).max(20),
|
||||||
healthWorkerUrl: z.string().trim().url('Некорректный URL').or(z.literal('')),
|
|
||||||
healthWorkerToken: z.string().optional(),
|
|
||||||
}).superRefine((data, ctx) => {
|
}).superRefine((data, ctx) => {
|
||||||
if (data.healthDownFailures < data.healthDegradedFailures) {
|
if (data.healthDownFailures < data.healthDegradedFailures) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
@@ -48,10 +48,16 @@ const formSchema = z.object({
|
|||||||
})
|
})
|
||||||
|
|
||||||
type FormValues = z.infer<typeof formSchema>
|
type FormValues = z.infer<typeof formSchema>
|
||||||
|
type HealthWorkerStatus = 'missing' | 'ready' | 'error'
|
||||||
|
|
||||||
type SettingsResponse = FormValues & {
|
type SettingsResponse = FormValues & {
|
||||||
id: string
|
id: string
|
||||||
healthWorkerTokenSet?: boolean
|
healthWorkerUrl?: string
|
||||||
|
healthWorkerStatus?: HealthWorkerStatus
|
||||||
|
healthWorkerError?: string | null
|
||||||
|
healthWorkerDeployedAt?: string | null
|
||||||
|
healthWorkerLastIngestAt?: string | null
|
||||||
|
healthWorkerKvNamespaceId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/settings/health')({
|
export const Route = createFileRoute('/_auth/settings/health')({
|
||||||
@@ -94,6 +100,28 @@ function CompactNumberInput({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function statusBadge(status: HealthWorkerStatus | undefined) {
|
||||||
|
if (status === 'ready') {
|
||||||
|
return (
|
||||||
|
<Badge variant="success-light" size="sm">
|
||||||
|
Готов
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (status === 'error') {
|
||||||
|
return (
|
||||||
|
<Badge variant="destructive-light" size="sm">
|
||||||
|
Ошибка
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" size="sm">
|
||||||
|
Не создан
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function HealthSettingsPage() {
|
function HealthSettingsPage() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
@@ -109,8 +137,6 @@ function HealthSettingsPage() {
|
|||||||
healthDownFailures: 2,
|
healthDownFailures: 2,
|
||||||
healthLatencyWarnMs: 1000,
|
healthLatencyWarnMs: 1000,
|
||||||
healthSuccessRecoveries: 2,
|
healthSuccessRecoveries: 2,
|
||||||
healthWorkerUrl: '',
|
|
||||||
healthWorkerToken: '',
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -122,26 +148,18 @@ function HealthSettingsPage() {
|
|||||||
healthDownFailures: data.healthDownFailures,
|
healthDownFailures: data.healthDownFailures,
|
||||||
healthLatencyWarnMs: data.healthLatencyWarnMs,
|
healthLatencyWarnMs: data.healthLatencyWarnMs,
|
||||||
healthSuccessRecoveries: data.healthSuccessRecoveries,
|
healthSuccessRecoveries: data.healthSuccessRecoveries,
|
||||||
healthWorkerUrl: data.healthWorkerUrl ?? '',
|
|
||||||
healthWorkerToken: '',
|
|
||||||
})
|
})
|
||||||
}, [data, form])
|
}, [data, form])
|
||||||
|
|
||||||
const saveMut = useMutation({
|
const saveMut = useMutation({
|
||||||
mutationFn: (values: FormValues) => {
|
mutationFn: (values: FormValues) =>
|
||||||
const payload: Record<string, unknown> = {
|
api.patch<SettingsResponse>('/api/v1/settings', {
|
||||||
healthCheckCron: values.healthCheckCron,
|
healthCheckCron: values.healthCheckCron,
|
||||||
healthDegradedFailures: values.healthDegradedFailures,
|
healthDegradedFailures: values.healthDegradedFailures,
|
||||||
healthDownFailures: values.healthDownFailures,
|
healthDownFailures: values.healthDownFailures,
|
||||||
healthLatencyWarnMs: values.healthLatencyWarnMs,
|
healthLatencyWarnMs: values.healthLatencyWarnMs,
|
||||||
healthSuccessRecoveries: values.healthSuccessRecoveries,
|
healthSuccessRecoveries: values.healthSuccessRecoveries,
|
||||||
healthWorkerUrl: values.healthWorkerUrl,
|
}),
|
||||||
}
|
|
||||||
if (values.healthWorkerToken?.trim()) {
|
|
||||||
payload.healthWorkerToken = values.healthWorkerToken.trim()
|
|
||||||
}
|
|
||||||
return api.patch<SettingsResponse>('/api/v1/settings', payload)
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
void queryClient.invalidateQueries({ queryKey: ['app-settings'] })
|
void queryClient.invalidateQueries({ queryKey: ['app-settings'] })
|
||||||
toast.success('Настройки health-check сохранены')
|
toast.success('Настройки health-check сохранены')
|
||||||
@@ -150,6 +168,17 @@ function HealthSettingsPage() {
|
|||||||
toast.error(e instanceof Error ? e.message : 'Не удалось сохранить'),
|
toast.error(e instanceof Error ? e.message : 'Не удалось сохранить'),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const ensureMut = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
api.post<SettingsResponse>('/api/v1/settings/health/worker/ensure'),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['app-settings'] })
|
||||||
|
toast.success('Worker создан или обновлён')
|
||||||
|
},
|
||||||
|
onError: (e: unknown) =>
|
||||||
|
toast.error(e instanceof Error ? e.message : 'Не удалось создать Worker'),
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
className="flex w-full flex-col gap-4"
|
className="flex w-full flex-col gap-4"
|
||||||
@@ -172,7 +201,7 @@ function HealthSettingsPage() {
|
|||||||
<FieldGroup className="gap-0">
|
<FieldGroup className="gap-0">
|
||||||
<SettingRow
|
<SettingRow
|
||||||
title="Cron"
|
title="Cron"
|
||||||
description="Расписание проб (6 полей: сек мин час день месяц день-недели). Env: HEALTH_CHECK_CRON."
|
description="Расписание проб CFDM (6 полей). Worker на edge получает 5-польное cron без секунд. Env: HEALTH_CHECK_CRON."
|
||||||
labelFor="health-cron"
|
labelFor="health-cron"
|
||||||
stacked
|
stacked
|
||||||
>
|
>
|
||||||
@@ -267,6 +296,7 @@ function HealthSettingsPage() {
|
|||||||
description="Подряд успешных проб, чтобы выйти из Checking в Healthy. Env: HEALTH_SUCCESS_RECOVERIES."
|
description="Подряд успешных проб, чтобы выйти из Checking в Healthy. Env: HEALTH_SUCCESS_RECOVERIES."
|
||||||
labelFor="health-recoveries"
|
labelFor="health-recoveries"
|
||||||
compact
|
compact
|
||||||
|
last
|
||||||
>
|
>
|
||||||
<Controller
|
<Controller
|
||||||
control={form.control}
|
control={form.control}
|
||||||
@@ -283,55 +313,7 @@ function HealthSettingsPage() {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
|
|
||||||
<SettingRow
|
|
||||||
title="URL Worker"
|
|
||||||
description="https://cfdm-health-probe.<account>.workers.dev. Env: HEALTH_WORKER_URL."
|
|
||||||
labelFor="health-worker-url"
|
|
||||||
compact
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
id="health-worker-url"
|
|
||||||
type="url"
|
|
||||||
placeholder="https://cfdm-health-probe.workers.dev"
|
|
||||||
disabled={isLoading || saveMut.isPending}
|
|
||||||
{...form.register('healthWorkerUrl')}
|
|
||||||
/>
|
|
||||||
</SettingRow>
|
|
||||||
{form.formState.errors.healthWorkerUrl ? (
|
|
||||||
<p className="text-destructive px-5 pb-2 text-sm">
|
|
||||||
{form.formState.errors.healthWorkerUrl.message}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<SettingRow
|
|
||||||
title="Токен Worker"
|
|
||||||
description={
|
|
||||||
data?.healthWorkerTokenSet
|
|
||||||
? 'Токен задан. Оставьте пустым, чтобы не менять.'
|
|
||||||
: 'Authorization Bearer. Env: HEALTH_WORKER_TOKEN.'
|
|
||||||
}
|
|
||||||
labelFor="health-worker-token"
|
|
||||||
compact
|
|
||||||
last
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
id="health-worker-token"
|
|
||||||
type="password"
|
|
||||||
autoComplete="new-password"
|
|
||||||
placeholder={data?.healthWorkerTokenSet ? '••••••••' : 'секрет'}
|
|
||||||
disabled={isLoading || saveMut.isPending}
|
|
||||||
{...form.register('healthWorkerToken')}
|
|
||||||
/>
|
|
||||||
</SettingRow>
|
|
||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
<Alert>
|
|
||||||
<AlertTitle>Cloudflare Worker, не Health Checks API</AlertTitle>
|
|
||||||
<AlertDescription>
|
|
||||||
На Free-плане продукта Health Checks нет. CFDM вызывает Worker с edge;
|
|
||||||
cron остаётся здесь. Лимит Free Workers ≈ 100k запросов/сутки (cron × число IP).
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
<FrameFooter className="flex flex-row justify-end">
|
<FrameFooter className="flex flex-row justify-end">
|
||||||
<LoadingButton
|
<LoadingButton
|
||||||
type="submit"
|
type="submit"
|
||||||
@@ -343,6 +325,96 @@ function HealthSettingsPage() {
|
|||||||
</FrameFooter>
|
</FrameFooter>
|
||||||
</FramePanel>
|
</FramePanel>
|
||||||
</Frame>
|
</Frame>
|
||||||
|
|
||||||
|
<Frame dense spacing="sm" className="w-full">
|
||||||
|
<FrameHeader>
|
||||||
|
<FrameTitle className="flex items-center gap-2">
|
||||||
|
Cloudflare Worker
|
||||||
|
{statusBadge(data?.healthWorkerStatus)}
|
||||||
|
</FrameTitle>
|
||||||
|
<FrameDescription>
|
||||||
|
Worker сам опрашивает IP/порты с edge. CFDM создаёт скрипт через API
|
||||||
|
и забирает результаты из KV. Preview:{' '}
|
||||||
|
<a
|
||||||
|
href="https://reui.io/preview/base/settings-16"
|
||||||
|
className="underline"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
settings-16
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</FrameDescription>
|
||||||
|
</FrameHeader>
|
||||||
|
<FramePanel className="flex flex-col gap-3 p-4">
|
||||||
|
<Alert variant={data?.healthWorkerStatus === 'error' ? 'destructive' : 'info'}>
|
||||||
|
<AlertTitle>Не Health Checks API</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
На Free-плане продукта Health Checks нет. Нужен Account-токен с
|
||||||
|
Workers Scripts Write и Workers KV Storage Write — Zone DNS
|
||||||
|
недостаточно. Лимиты Free: 5 cron на аккаунт, KV 1000 writes/сутки
|
||||||
|
(интервал ≥ 2 мин), до 48 целей за тик.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
{data?.healthWorkerError ? (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertTitle>Ошибка деплоя</AlertTitle>
|
||||||
|
<AlertDescription>{data.healthWorkerError}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
<FieldGroup className="gap-0">
|
||||||
|
<SettingRow title="Скрипт" compact>
|
||||||
|
<span className="font-mono text-sm">cfdm-health-probe</span>
|
||||||
|
</SettingRow>
|
||||||
|
<SettingRow
|
||||||
|
title="KV namespace"
|
||||||
|
description="id mailbox targets/results"
|
||||||
|
compact
|
||||||
|
>
|
||||||
|
<span className="font-mono text-sm break-all">
|
||||||
|
{data?.healthWorkerKvNamespaceId || '—'}
|
||||||
|
</span>
|
||||||
|
</SettingRow>
|
||||||
|
<SettingRow
|
||||||
|
title="URL"
|
||||||
|
description="workers.dev после автодеплоя"
|
||||||
|
compact
|
||||||
|
>
|
||||||
|
<span className="font-mono text-sm break-all">
|
||||||
|
{data?.healthWorkerUrl || '—'}
|
||||||
|
</span>
|
||||||
|
</SettingRow>
|
||||||
|
<SettingRow
|
||||||
|
title="Последний деплой"
|
||||||
|
compact
|
||||||
|
>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{data?.healthWorkerDeployedAt || '—'}
|
||||||
|
</span>
|
||||||
|
</SettingRow>
|
||||||
|
<SettingRow
|
||||||
|
title="Последний ingest"
|
||||||
|
description="colo пишется в журнал проб"
|
||||||
|
compact
|
||||||
|
last
|
||||||
|
>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{data?.healthWorkerLastIngestAt || '—'}
|
||||||
|
</span>
|
||||||
|
</SettingRow>
|
||||||
|
</FieldGroup>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={ensureMut.isPending}
|
||||||
|
onClick={() => ensureMut.mutate()}
|
||||||
|
>
|
||||||
|
{ensureMut.isPending ? 'Создаём…' : 'Создать / обновить Worker'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
</form>
|
</form>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ COPY apps/api apps/api
|
|||||||
COPY packages/ui packages/ui
|
COPY packages/ui packages/ui
|
||||||
COPY packages/shared packages/shared
|
COPY packages/shared packages/shared
|
||||||
COPY packages/db packages/db
|
COPY packages/db packages/db
|
||||||
|
COPY workers/health-probe workers/health-probe
|
||||||
RUN --mount=type=cache,target=/root/.local/share/pnpm/store,sharing=locked \
|
RUN --mount=type=cache,target=/root/.local/share/pnpm/store,sharing=locked \
|
||||||
pnpm turbo build --filter=web --filter=@cfdm/api \
|
pnpm turbo build --filter=web --filter=@cfdm/api \
|
||||||
&& pnpm --filter @cfdm/api deploy --prod /out \
|
&& pnpm --filter @cfdm/api deploy --prod /out \
|
||||||
|
|||||||
+15
-13
@@ -14,7 +14,7 @@ Manage Cloudflare zones, DNS records, domain groups, and TLS certificate expiry
|
|||||||
|
|
||||||
| Variable | Description |
|
| Variable | Description |
|
||||||
|----------|-------------|
|
|----------|-------------|
|
||||||
| `CLOUDFLARE_API_TOKEN` | API token with Zone.DNS permissions |
|
| `CLOUDFLARE_API_TOKEN` | API token: Zone.DNS **и** для Worker — Account Workers Scripts Write + Workers KV Storage Write |
|
||||||
| `DATABASE_URL` | SQLite path (`sqlite:/data/app.db`) |
|
| `DATABASE_URL` | SQLite path (`sqlite:/data/app.db`) |
|
||||||
| `JWT_SECRET` | JWT signing secret |
|
| `JWT_SECRET` | JWT signing secret |
|
||||||
| `ADMIN_USERNAME` | Admin username |
|
| `ADMIN_USERNAME` | Admin username |
|
||||||
@@ -25,8 +25,6 @@ Manage Cloudflare zones, DNS records, domain groups, and TLS certificate expiry
|
|||||||
| `HEALTH_DOWN_FAILURES` | Ошибок подряд до `down` (default `2`). То же в UI. |
|
| `HEALTH_DOWN_FAILURES` | Ошибок подряд до `down` (default `2`). То же в UI. |
|
||||||
| `HEALTH_SUCCESS_RECOVERIES` | Успехов подряд для recovery `CHECKING → HEALTHY` (default `2`). То же в UI. |
|
| `HEALTH_SUCCESS_RECOVERIES` | Успехов подряд для recovery `CHECKING → HEALTHY` (default `2`). То же в UI. |
|
||||||
| `HEALTH_LATENCY_WARN_MS` | Латентность-порог для `degraded` (default `1000`). То же в UI. |
|
| `HEALTH_LATENCY_WARN_MS` | Латентность-порог для `degraded` (default `1000`). То же в UI. |
|
||||||
| `HEALTH_WORKER_URL` | URL Worker health-probe (fallback). Переопределяется в **Настройки → Health-check**. |
|
|
||||||
| `HEALTH_WORKER_TOKEN` | Bearer-токен Worker (fallback). В GET `/settings` не отдаётся целиком. |
|
|
||||||
|
|
||||||
## Load balancing & health checks
|
## Load balancing & health checks
|
||||||
|
|
||||||
@@ -58,22 +56,26 @@ health-check работают на двух уровнях:
|
|||||||
|
|
||||||
| | Local | Cloudflare Worker |
|
| | Local | Cloudflare Worker |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Кто пробирует | процесс API CFDM | Worker на edge Cloudflare |
|
| Кто пробирует | процесс API CFDM | Worker на edge (Cron Trigger) |
|
||||||
| Планировщик | глобальный cron CFDM | тот же cron вызывает Worker |
|
| Планировщик | глобальный cron CFDM | cron Worker + ingest KV в CFDM |
|
||||||
| Пороги Slow/Down | Настройки → Health-check | те же |
|
| Пороги Slow/Down | Настройки → Health-check | те же |
|
||||||
| Результат | SQLite `ip_health_status` | та же SQLite + `colo` |
|
| Результат | SQLite `ip_health_status` | та же SQLite + `colo` из KV |
|
||||||
| Регионы Health Checks | нет | нет (на Free продукта нет) |
|
| Регионы Health Checks | нет | нет (на Free продукта нет) |
|
||||||
|
|
||||||
**Cloudflare в CFDM — это Worker**, не [Health Checks API](https://developers.cloudflare.com/api/resources/healthchecks).
|
**Cloudflare в CFDM — это Worker**, не [Health Checks API](https://developers.cloudflare.com/api/resources/healthchecks).
|
||||||
Продукт Health Checks на Free-плане недоступен и **не используется**. Worker
|
Продукт Health Checks на Free-плане недоступен и **не используется**.
|
||||||
stateless: конфиг и журнал (`health_probe_log`) живут в SQLite CFDM.
|
|
||||||
|
|
||||||
Деплой Worker: [`workers/health-probe/README.md`](../workers/health-probe/README.md)
|
Worker **сам** опрашивает IP/порты/протоколы (TCP/HTTP, паттерн [UptimeFlare](https://github.com/lyc8503/UptimeFlare): `sockets.opened`, p-limit 5).
|
||||||
(`wrangler deploy`). URL и токен — **Настройки → Health-check**. Если Worker не
|
CFDM создаёт скрипт через Workers Scripts API, кладёт список целей в KV и читает результаты.
|
||||||
задан, cloudflare-цели **не** пробируются как Local.
|
Публичный URL API не нужен. Если Worker/KV не готовы, cloudflare-цели **не** пробируются как Local.
|
||||||
|
|
||||||
Reconcile DNS запускается cron-задачей `health-check`. Free Workers ≈ 100k
|
Кнопка **Создать / обновить Worker** — **Настройки → Health-check**. Токен:
|
||||||
запросов/сутки; cron раз в 2 мин × число IP должен влезать.
|
Account `Workers Scripts Write` + `Workers KV Storage Write`. Zone DNS недостаточно.
|
||||||
|
|
||||||
|
Free: 5 Cron Triggers на аккаунт; KV 1000 writes/сутки (интервал ≥ 2 мин);
|
||||||
|
≤ 48 целей за тик. Исходник: [`workers/health-probe/`](../workers/health-probe/).
|
||||||
|
|
||||||
|
Reconcile DNS запускается cron-задачей `health-check` после ingest KV.
|
||||||
|
|
||||||
## Docker
|
## Docker
|
||||||
|
|
||||||
|
|||||||
Vendored
+202
-1
@@ -1,7 +1,7 @@
|
|||||||
import * as drizzle_orm_sqlite_core from 'drizzle-orm/sqlite-core';
|
import * as drizzle_orm_sqlite_core from 'drizzle-orm/sqlite-core';
|
||||||
import Database from 'better-sqlite3';
|
import Database from 'better-sqlite3';
|
||||||
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
||||||
import { AuditSourceApp, AuditSeverity, AuditTargetType, AuditLogEntry, LbMode, HealthCheckType, HealthCheckProvider, IpHealthState, HealthCheckScope, ServiceBinding, Domain, Group, OriginHealthCheck, ServiceNode, Service, ServiceGroup, Subdomain, DnsRecord, ServiceBindingView, Certificate, GroupWithStats, IpHealthStatus, SyncJob, DomainListItem, HealthCheckTarget } from '@cfdm/shared';
|
import { AuditSourceApp, AuditSeverity, AuditTargetType, AuditLogEntry, HealthWorkerStatus, LbMode, HealthCheckType, HealthCheckProvider, IpHealthState, HealthCheckScope, ServiceBinding, Domain, Group, OriginHealthCheck, ServiceNode, Service, ServiceGroup, Subdomain, DnsRecord, ServiceBindingView, Certificate, GroupWithStats, IpHealthStatus, SyncJob, DomainListItem, HealthCheckTarget } from '@cfdm/shared';
|
||||||
|
|
||||||
declare const groups: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{
|
declare const groups: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{
|
||||||
name: "groups";
|
name: "groups";
|
||||||
@@ -3365,6 +3365,101 @@ declare const appSettings: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{
|
|||||||
}, {}, {
|
}, {}, {
|
||||||
length: number | undefined;
|
length: number | undefined;
|
||||||
}>;
|
}>;
|
||||||
|
health_worker_account_id: drizzle_orm_sqlite_core.SQLiteColumn<{
|
||||||
|
name: "health_worker_account_id";
|
||||||
|
tableName: "app_settings";
|
||||||
|
dataType: "string";
|
||||||
|
columnType: "SQLiteText";
|
||||||
|
data: string;
|
||||||
|
driverParam: string;
|
||||||
|
notNull: false;
|
||||||
|
hasDefault: false;
|
||||||
|
isPrimaryKey: false;
|
||||||
|
isAutoincrement: false;
|
||||||
|
hasRuntimeDefault: false;
|
||||||
|
enumValues: [string, ...string[]];
|
||||||
|
baseColumn: never;
|
||||||
|
identity: undefined;
|
||||||
|
generated: undefined;
|
||||||
|
}, {}, {
|
||||||
|
length: number | undefined;
|
||||||
|
}>;
|
||||||
|
health_worker_kv_namespace_id: drizzle_orm_sqlite_core.SQLiteColumn<{
|
||||||
|
name: "health_worker_kv_namespace_id";
|
||||||
|
tableName: "app_settings";
|
||||||
|
dataType: "string";
|
||||||
|
columnType: "SQLiteText";
|
||||||
|
data: string;
|
||||||
|
driverParam: string;
|
||||||
|
notNull: false;
|
||||||
|
hasDefault: false;
|
||||||
|
isPrimaryKey: false;
|
||||||
|
isAutoincrement: false;
|
||||||
|
hasRuntimeDefault: false;
|
||||||
|
enumValues: [string, ...string[]];
|
||||||
|
baseColumn: never;
|
||||||
|
identity: undefined;
|
||||||
|
generated: undefined;
|
||||||
|
}, {}, {
|
||||||
|
length: number | undefined;
|
||||||
|
}>;
|
||||||
|
health_worker_error: drizzle_orm_sqlite_core.SQLiteColumn<{
|
||||||
|
name: "health_worker_error";
|
||||||
|
tableName: "app_settings";
|
||||||
|
dataType: "string";
|
||||||
|
columnType: "SQLiteText";
|
||||||
|
data: string;
|
||||||
|
driverParam: string;
|
||||||
|
notNull: false;
|
||||||
|
hasDefault: false;
|
||||||
|
isPrimaryKey: false;
|
||||||
|
isAutoincrement: false;
|
||||||
|
hasRuntimeDefault: false;
|
||||||
|
enumValues: [string, ...string[]];
|
||||||
|
baseColumn: never;
|
||||||
|
identity: undefined;
|
||||||
|
generated: undefined;
|
||||||
|
}, {}, {
|
||||||
|
length: number | undefined;
|
||||||
|
}>;
|
||||||
|
health_worker_deployed_at: drizzle_orm_sqlite_core.SQLiteColumn<{
|
||||||
|
name: "health_worker_deployed_at";
|
||||||
|
tableName: "app_settings";
|
||||||
|
dataType: "string";
|
||||||
|
columnType: "SQLiteText";
|
||||||
|
data: string;
|
||||||
|
driverParam: string;
|
||||||
|
notNull: false;
|
||||||
|
hasDefault: false;
|
||||||
|
isPrimaryKey: false;
|
||||||
|
isAutoincrement: false;
|
||||||
|
hasRuntimeDefault: false;
|
||||||
|
enumValues: [string, ...string[]];
|
||||||
|
baseColumn: never;
|
||||||
|
identity: undefined;
|
||||||
|
generated: undefined;
|
||||||
|
}, {}, {
|
||||||
|
length: number | undefined;
|
||||||
|
}>;
|
||||||
|
health_worker_last_ingest_at: drizzle_orm_sqlite_core.SQLiteColumn<{
|
||||||
|
name: "health_worker_last_ingest_at";
|
||||||
|
tableName: "app_settings";
|
||||||
|
dataType: "string";
|
||||||
|
columnType: "SQLiteText";
|
||||||
|
data: string;
|
||||||
|
driverParam: string;
|
||||||
|
notNull: false;
|
||||||
|
hasDefault: false;
|
||||||
|
isPrimaryKey: false;
|
||||||
|
isAutoincrement: false;
|
||||||
|
hasRuntimeDefault: false;
|
||||||
|
enumValues: [string, ...string[]];
|
||||||
|
baseColumn: never;
|
||||||
|
identity: undefined;
|
||||||
|
generated: undefined;
|
||||||
|
}, {}, {
|
||||||
|
length: number | undefined;
|
||||||
|
}>;
|
||||||
created_at: drizzle_orm_sqlite_core.SQLiteColumn<{
|
created_at: drizzle_orm_sqlite_core.SQLiteColumn<{
|
||||||
name: "created_at";
|
name: "created_at";
|
||||||
tableName: "app_settings";
|
tableName: "app_settings";
|
||||||
@@ -7822,6 +7917,101 @@ declare const schema: {
|
|||||||
}, {}, {
|
}, {}, {
|
||||||
length: number | undefined;
|
length: number | undefined;
|
||||||
}>;
|
}>;
|
||||||
|
health_worker_account_id: drizzle_orm_sqlite_core.SQLiteColumn<{
|
||||||
|
name: "health_worker_account_id";
|
||||||
|
tableName: "app_settings";
|
||||||
|
dataType: "string";
|
||||||
|
columnType: "SQLiteText";
|
||||||
|
data: string;
|
||||||
|
driverParam: string;
|
||||||
|
notNull: false;
|
||||||
|
hasDefault: false;
|
||||||
|
isPrimaryKey: false;
|
||||||
|
isAutoincrement: false;
|
||||||
|
hasRuntimeDefault: false;
|
||||||
|
enumValues: [string, ...string[]];
|
||||||
|
baseColumn: never;
|
||||||
|
identity: undefined;
|
||||||
|
generated: undefined;
|
||||||
|
}, {}, {
|
||||||
|
length: number | undefined;
|
||||||
|
}>;
|
||||||
|
health_worker_kv_namespace_id: drizzle_orm_sqlite_core.SQLiteColumn<{
|
||||||
|
name: "health_worker_kv_namespace_id";
|
||||||
|
tableName: "app_settings";
|
||||||
|
dataType: "string";
|
||||||
|
columnType: "SQLiteText";
|
||||||
|
data: string;
|
||||||
|
driverParam: string;
|
||||||
|
notNull: false;
|
||||||
|
hasDefault: false;
|
||||||
|
isPrimaryKey: false;
|
||||||
|
isAutoincrement: false;
|
||||||
|
hasRuntimeDefault: false;
|
||||||
|
enumValues: [string, ...string[]];
|
||||||
|
baseColumn: never;
|
||||||
|
identity: undefined;
|
||||||
|
generated: undefined;
|
||||||
|
}, {}, {
|
||||||
|
length: number | undefined;
|
||||||
|
}>;
|
||||||
|
health_worker_error: drizzle_orm_sqlite_core.SQLiteColumn<{
|
||||||
|
name: "health_worker_error";
|
||||||
|
tableName: "app_settings";
|
||||||
|
dataType: "string";
|
||||||
|
columnType: "SQLiteText";
|
||||||
|
data: string;
|
||||||
|
driverParam: string;
|
||||||
|
notNull: false;
|
||||||
|
hasDefault: false;
|
||||||
|
isPrimaryKey: false;
|
||||||
|
isAutoincrement: false;
|
||||||
|
hasRuntimeDefault: false;
|
||||||
|
enumValues: [string, ...string[]];
|
||||||
|
baseColumn: never;
|
||||||
|
identity: undefined;
|
||||||
|
generated: undefined;
|
||||||
|
}, {}, {
|
||||||
|
length: number | undefined;
|
||||||
|
}>;
|
||||||
|
health_worker_deployed_at: drizzle_orm_sqlite_core.SQLiteColumn<{
|
||||||
|
name: "health_worker_deployed_at";
|
||||||
|
tableName: "app_settings";
|
||||||
|
dataType: "string";
|
||||||
|
columnType: "SQLiteText";
|
||||||
|
data: string;
|
||||||
|
driverParam: string;
|
||||||
|
notNull: false;
|
||||||
|
hasDefault: false;
|
||||||
|
isPrimaryKey: false;
|
||||||
|
isAutoincrement: false;
|
||||||
|
hasRuntimeDefault: false;
|
||||||
|
enumValues: [string, ...string[]];
|
||||||
|
baseColumn: never;
|
||||||
|
identity: undefined;
|
||||||
|
generated: undefined;
|
||||||
|
}, {}, {
|
||||||
|
length: number | undefined;
|
||||||
|
}>;
|
||||||
|
health_worker_last_ingest_at: drizzle_orm_sqlite_core.SQLiteColumn<{
|
||||||
|
name: "health_worker_last_ingest_at";
|
||||||
|
tableName: "app_settings";
|
||||||
|
dataType: "string";
|
||||||
|
columnType: "SQLiteText";
|
||||||
|
data: string;
|
||||||
|
driverParam: string;
|
||||||
|
notNull: false;
|
||||||
|
hasDefault: false;
|
||||||
|
isPrimaryKey: false;
|
||||||
|
isAutoincrement: false;
|
||||||
|
hasRuntimeDefault: false;
|
||||||
|
enumValues: [string, ...string[]];
|
||||||
|
baseColumn: never;
|
||||||
|
identity: undefined;
|
||||||
|
generated: undefined;
|
||||||
|
}, {}, {
|
||||||
|
length: number | undefined;
|
||||||
|
}>;
|
||||||
created_at: drizzle_orm_sqlite_core.SQLiteColumn<{
|
created_at: drizzle_orm_sqlite_core.SQLiteColumn<{
|
||||||
name: "created_at";
|
name: "created_at";
|
||||||
tableName: "app_settings";
|
tableName: "app_settings";
|
||||||
@@ -8980,6 +9170,12 @@ type AppSettingsDto = {
|
|||||||
showQuickActions: boolean;
|
showQuickActions: boolean;
|
||||||
healthWorkerUrl: string;
|
healthWorkerUrl: string;
|
||||||
healthWorkerTokenSet: boolean;
|
healthWorkerTokenSet: boolean;
|
||||||
|
healthWorkerStatus: HealthWorkerStatus;
|
||||||
|
healthWorkerAccountId: string;
|
||||||
|
healthWorkerKvNamespaceId: string;
|
||||||
|
healthWorkerError: string | null;
|
||||||
|
healthWorkerDeployedAt: string | null;
|
||||||
|
healthWorkerLastIngestAt: string | null;
|
||||||
} & HealthEngineSettings;
|
} & HealthEngineSettings;
|
||||||
type AppSettingsPatch = {
|
type AppSettingsPatch = {
|
||||||
vpsTrackerUrl?: string;
|
vpsTrackerUrl?: string;
|
||||||
@@ -8993,6 +9189,11 @@ type AppSettingsPatch = {
|
|||||||
healthSuccessRecoveries?: number;
|
healthSuccessRecoveries?: number;
|
||||||
healthWorkerUrl?: string;
|
healthWorkerUrl?: string;
|
||||||
healthWorkerToken?: string;
|
healthWorkerToken?: string;
|
||||||
|
healthWorkerAccountId?: string | null;
|
||||||
|
healthWorkerKvNamespaceId?: string | null;
|
||||||
|
healthWorkerError?: string | null;
|
||||||
|
healthWorkerDeployedAt?: string | null;
|
||||||
|
healthWorkerLastIngestAt?: string | null;
|
||||||
};
|
};
|
||||||
type HealthEngineFallbacks = HealthEngineSettings & {
|
type HealthEngineFallbacks = HealthEngineSettings & {
|
||||||
healthWorkerUrl: string;
|
healthWorkerUrl: string;
|
||||||
|
|||||||
Vendored
+24
-2
@@ -281,6 +281,11 @@ var appSettings = sqliteTable("app_settings", {
|
|||||||
health_success_recoveries: integer("health_success_recoveries"),
|
health_success_recoveries: integer("health_success_recoveries"),
|
||||||
health_worker_url: text("health_worker_url"),
|
health_worker_url: text("health_worker_url"),
|
||||||
health_worker_token: text("health_worker_token"),
|
health_worker_token: text("health_worker_token"),
|
||||||
|
health_worker_account_id: text("health_worker_account_id"),
|
||||||
|
health_worker_kv_namespace_id: text("health_worker_kv_namespace_id"),
|
||||||
|
health_worker_error: text("health_worker_error"),
|
||||||
|
health_worker_deployed_at: text("health_worker_deployed_at"),
|
||||||
|
health_worker_last_ingest_at: text("health_worker_last_ingest_at"),
|
||||||
created_at: text("created_at").notNull().default(sql`datetime('now')`),
|
created_at: text("created_at").notNull().default(sql`datetime('now')`),
|
||||||
updated_at: text("updated_at").notNull().default(sql`datetime('now')`)
|
updated_at: text("updated_at").notNull().default(sql`datetime('now')`)
|
||||||
});
|
});
|
||||||
@@ -522,6 +527,13 @@ var SETTINGS_ID = "settings-main";
|
|||||||
function coalesceInt(value, fallback) {
|
function coalesceInt(value, fallback) {
|
||||||
return value == null || Number.isNaN(value) || value < 1 ? fallback : value;
|
return value == null || Number.isNaN(value) || value < 1 ? fallback : value;
|
||||||
}
|
}
|
||||||
|
function workerStatus(row, envUrl) {
|
||||||
|
if (row.health_worker_error?.trim()) return "error";
|
||||||
|
const url = row.health_worker_url?.trim() || envUrl;
|
||||||
|
const kv = row.health_worker_kv_namespace_id?.trim();
|
||||||
|
if (kv && url) return "ready";
|
||||||
|
return "missing";
|
||||||
|
}
|
||||||
function toDto(row, fallbacks) {
|
function toDto(row, fallbacks) {
|
||||||
const env = fallbacks ?? {
|
const env = fallbacks ?? {
|
||||||
healthCheckCron: "0 */2 * * * *",
|
healthCheckCron: "0 */2 * * * *",
|
||||||
@@ -559,7 +571,13 @@ function toDto(row, fallbacks) {
|
|||||||
env.healthSuccessRecoveries
|
env.healthSuccessRecoveries
|
||||||
),
|
),
|
||||||
healthWorkerUrl: row.health_worker_url?.trim() || env.healthWorkerUrl,
|
healthWorkerUrl: row.health_worker_url?.trim() || env.healthWorkerUrl,
|
||||||
healthWorkerTokenSet: Boolean(row.health_worker_token?.trim()) || env.healthWorkerTokenSet
|
healthWorkerTokenSet: Boolean(row.health_worker_token?.trim()) || env.healthWorkerTokenSet,
|
||||||
|
healthWorkerAccountId: row.health_worker_account_id?.trim() ?? "",
|
||||||
|
healthWorkerKvNamespaceId: row.health_worker_kv_namespace_id?.trim() ?? "",
|
||||||
|
healthWorkerError: row.health_worker_error?.trim() || null,
|
||||||
|
healthWorkerDeployedAt: row.health_worker_deployed_at ?? null,
|
||||||
|
healthWorkerLastIngestAt: row.health_worker_last_ingest_at ?? null,
|
||||||
|
healthWorkerStatus: workerStatus(row, env.healthWorkerUrl)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
function getAppSettings(db, fallbacks) {
|
function getAppSettings(db, fallbacks) {
|
||||||
@@ -590,7 +608,6 @@ function updateAppSettings(db, patch, fallbacks) {
|
|||||||
}
|
}
|
||||||
const current = db.select().from(appSettings).where(eq2(appSettings.id, SETTINGS_ID)).get();
|
const current = db.select().from(appSettings).where(eq2(appSettings.id, SETTINGS_ID)).get();
|
||||||
db.update(appSettings).set({
|
db.update(appSettings).set({
|
||||||
// app_switcher_json: deprecated — source of truth is auth-portal
|
|
||||||
vps_tracker_url: patch.vpsTrackerUrl !== void 0 ? patch.vpsTrackerUrl : current.vps_tracker_url,
|
vps_tracker_url: patch.vpsTrackerUrl !== void 0 ? patch.vpsTrackerUrl : current.vps_tracker_url,
|
||||||
vps_tracker_integration_token: patch.vpsTrackerIntegrationToken !== void 0 && patch.vpsTrackerIntegrationToken.trim() !== "" ? patch.vpsTrackerIntegrationToken : current.vps_tracker_integration_token,
|
vps_tracker_integration_token: patch.vpsTrackerIntegrationToken !== void 0 && patch.vpsTrackerIntegrationToken.trim() !== "" ? patch.vpsTrackerIntegrationToken : current.vps_tracker_integration_token,
|
||||||
vps_tracker_sync_enabled: patch.vpsTrackerSyncEnabled !== void 0 ? patch.vpsTrackerSyncEnabled : current.vps_tracker_sync_enabled,
|
vps_tracker_sync_enabled: patch.vpsTrackerSyncEnabled !== void 0 ? patch.vpsTrackerSyncEnabled : current.vps_tracker_sync_enabled,
|
||||||
@@ -602,6 +619,11 @@ function updateAppSettings(db, patch, fallbacks) {
|
|||||||
health_success_recoveries: patch.healthSuccessRecoveries !== void 0 ? patch.healthSuccessRecoveries : current.health_success_recoveries,
|
health_success_recoveries: patch.healthSuccessRecoveries !== void 0 ? patch.healthSuccessRecoveries : current.health_success_recoveries,
|
||||||
health_worker_url: patch.healthWorkerUrl !== void 0 ? patch.healthWorkerUrl.trim() || null : current.health_worker_url,
|
health_worker_url: patch.healthWorkerUrl !== void 0 ? patch.healthWorkerUrl.trim() || null : current.health_worker_url,
|
||||||
health_worker_token: patch.healthWorkerToken !== void 0 && patch.healthWorkerToken.trim() !== "" ? patch.healthWorkerToken : current.health_worker_token,
|
health_worker_token: patch.healthWorkerToken !== void 0 && patch.healthWorkerToken.trim() !== "" ? patch.healthWorkerToken : current.health_worker_token,
|
||||||
|
health_worker_account_id: patch.healthWorkerAccountId !== void 0 ? patch.healthWorkerAccountId?.trim() || null : current.health_worker_account_id,
|
||||||
|
health_worker_kv_namespace_id: patch.healthWorkerKvNamespaceId !== void 0 ? patch.healthWorkerKvNamespaceId?.trim() || null : current.health_worker_kv_namespace_id,
|
||||||
|
health_worker_error: patch.healthWorkerError !== void 0 ? patch.healthWorkerError?.trim() || null : current.health_worker_error,
|
||||||
|
health_worker_deployed_at: patch.healthWorkerDeployedAt !== void 0 ? patch.healthWorkerDeployedAt : current.health_worker_deployed_at,
|
||||||
|
health_worker_last_ingest_at: patch.healthWorkerLastIngestAt !== void 0 ? patch.healthWorkerLastIngestAt : current.health_worker_last_ingest_at,
|
||||||
updated_at: (/* @__PURE__ */ new Date()).toISOString()
|
updated_at: (/* @__PURE__ */ new Date()).toISOString()
|
||||||
}).where(eq2(appSettings.id, SETTINGS_ID)).run();
|
}).where(eq2(appSettings.id, SETTINGS_ID)).run();
|
||||||
return getAppSettings(db, fallbacks);
|
return getAppSettings(db, fallbacks);
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
ALTER TABLE app_settings ADD COLUMN health_worker_account_id TEXT;
|
||||||
|
ALTER TABLE app_settings ADD COLUMN health_worker_kv_namespace_id TEXT;
|
||||||
|
ALTER TABLE app_settings ADD COLUMN health_worker_error TEXT;
|
||||||
|
ALTER TABLE app_settings ADD COLUMN health_worker_deployed_at TEXT;
|
||||||
|
ALTER TABLE app_settings ADD COLUMN health_worker_last_ingest_at TEXT;
|
||||||
@@ -393,6 +393,11 @@ export const appSettings = sqliteTable("app_settings", {
|
|||||||
health_success_recoveries: integer("health_success_recoveries"),
|
health_success_recoveries: integer("health_success_recoveries"),
|
||||||
health_worker_url: text("health_worker_url"),
|
health_worker_url: text("health_worker_url"),
|
||||||
health_worker_token: text("health_worker_token"),
|
health_worker_token: text("health_worker_token"),
|
||||||
|
health_worker_account_id: text("health_worker_account_id"),
|
||||||
|
health_worker_kv_namespace_id: text("health_worker_kv_namespace_id"),
|
||||||
|
health_worker_error: text("health_worker_error"),
|
||||||
|
health_worker_deployed_at: text("health_worker_deployed_at"),
|
||||||
|
health_worker_last_ingest_at: text("health_worker_last_ingest_at"),
|
||||||
created_at: text("created_at")
|
created_at: text("created_at")
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`datetime('now')`),
|
.default(sql`datetime('now')`),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
|
import type { HealthWorkerStatus } from "@cfdm/shared";
|
||||||
import type { Db } from "./client.js";
|
import type { Db } from "./client.js";
|
||||||
import { appSettings } from "./schema.js";
|
import { appSettings } from "./schema.js";
|
||||||
|
|
||||||
@@ -21,6 +22,12 @@ export type AppSettingsDto = {
|
|||||||
showQuickActions: boolean;
|
showQuickActions: boolean;
|
||||||
healthWorkerUrl: string;
|
healthWorkerUrl: string;
|
||||||
healthWorkerTokenSet: boolean;
|
healthWorkerTokenSet: boolean;
|
||||||
|
healthWorkerStatus: HealthWorkerStatus;
|
||||||
|
healthWorkerAccountId: string;
|
||||||
|
healthWorkerKvNamespaceId: string;
|
||||||
|
healthWorkerError: string | null;
|
||||||
|
healthWorkerDeployedAt: string | null;
|
||||||
|
healthWorkerLastIngestAt: string | null;
|
||||||
} & HealthEngineSettings;
|
} & HealthEngineSettings;
|
||||||
|
|
||||||
export type AppSettingsPatch = {
|
export type AppSettingsPatch = {
|
||||||
@@ -35,6 +42,11 @@ export type AppSettingsPatch = {
|
|||||||
healthSuccessRecoveries?: number;
|
healthSuccessRecoveries?: number;
|
||||||
healthWorkerUrl?: string;
|
healthWorkerUrl?: string;
|
||||||
healthWorkerToken?: string;
|
healthWorkerToken?: string;
|
||||||
|
healthWorkerAccountId?: string | null;
|
||||||
|
healthWorkerKvNamespaceId?: string | null;
|
||||||
|
healthWorkerError?: string | null;
|
||||||
|
healthWorkerDeployedAt?: string | null;
|
||||||
|
healthWorkerLastIngestAt?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type HealthEngineFallbacks = HealthEngineSettings & {
|
export type HealthEngineFallbacks = HealthEngineSettings & {
|
||||||
@@ -46,6 +58,17 @@ function coalesceInt(value: number | null | undefined, fallback: number): number
|
|||||||
return value == null || Number.isNaN(value) || value < 1 ? fallback : value;
|
return value == null || Number.isNaN(value) || value < 1 ? fallback : value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function workerStatus(
|
||||||
|
row: typeof appSettings.$inferSelect,
|
||||||
|
envUrl: string,
|
||||||
|
): HealthWorkerStatus {
|
||||||
|
if (row.health_worker_error?.trim()) return "error";
|
||||||
|
const url = row.health_worker_url?.trim() || envUrl;
|
||||||
|
const kv = row.health_worker_kv_namespace_id?.trim();
|
||||||
|
if (kv && url) return "ready";
|
||||||
|
return "missing";
|
||||||
|
}
|
||||||
|
|
||||||
function toDto(
|
function toDto(
|
||||||
row: typeof appSettings.$inferSelect,
|
row: typeof appSettings.$inferSelect,
|
||||||
fallbacks?: HealthEngineFallbacks,
|
fallbacks?: HealthEngineFallbacks,
|
||||||
@@ -89,6 +112,12 @@ function toDto(
|
|||||||
healthWorkerUrl: row.health_worker_url?.trim() || env.healthWorkerUrl,
|
healthWorkerUrl: row.health_worker_url?.trim() || env.healthWorkerUrl,
|
||||||
healthWorkerTokenSet:
|
healthWorkerTokenSet:
|
||||||
Boolean(row.health_worker_token?.trim()) || env.healthWorkerTokenSet,
|
Boolean(row.health_worker_token?.trim()) || env.healthWorkerTokenSet,
|
||||||
|
healthWorkerAccountId: row.health_worker_account_id?.trim() ?? "",
|
||||||
|
healthWorkerKvNamespaceId: row.health_worker_kv_namespace_id?.trim() ?? "",
|
||||||
|
healthWorkerError: row.health_worker_error?.trim() || null,
|
||||||
|
healthWorkerDeployedAt: row.health_worker_deployed_at ?? null,
|
||||||
|
healthWorkerLastIngestAt: row.health_worker_last_ingest_at ?? null,
|
||||||
|
healthWorkerStatus: workerStatus(row, env.healthWorkerUrl),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,7 +183,6 @@ export function updateAppSettings(
|
|||||||
|
|
||||||
db.update(appSettings)
|
db.update(appSettings)
|
||||||
.set({
|
.set({
|
||||||
// app_switcher_json: deprecated — source of truth is auth-portal
|
|
||||||
vps_tracker_url:
|
vps_tracker_url:
|
||||||
patch.vpsTrackerUrl !== undefined
|
patch.vpsTrackerUrl !== undefined
|
||||||
? patch.vpsTrackerUrl
|
? patch.vpsTrackerUrl
|
||||||
@@ -201,6 +229,26 @@ export function updateAppSettings(
|
|||||||
patch.healthWorkerToken.trim() !== ""
|
patch.healthWorkerToken.trim() !== ""
|
||||||
? patch.healthWorkerToken
|
? patch.healthWorkerToken
|
||||||
: current.health_worker_token,
|
: current.health_worker_token,
|
||||||
|
health_worker_account_id:
|
||||||
|
patch.healthWorkerAccountId !== undefined
|
||||||
|
? patch.healthWorkerAccountId?.trim() || null
|
||||||
|
: current.health_worker_account_id,
|
||||||
|
health_worker_kv_namespace_id:
|
||||||
|
patch.healthWorkerKvNamespaceId !== undefined
|
||||||
|
? patch.healthWorkerKvNamespaceId?.trim() || null
|
||||||
|
: current.health_worker_kv_namespace_id,
|
||||||
|
health_worker_error:
|
||||||
|
patch.healthWorkerError !== undefined
|
||||||
|
? patch.healthWorkerError?.trim() || null
|
||||||
|
: current.health_worker_error,
|
||||||
|
health_worker_deployed_at:
|
||||||
|
patch.healthWorkerDeployedAt !== undefined
|
||||||
|
? patch.healthWorkerDeployedAt
|
||||||
|
: current.health_worker_deployed_at,
|
||||||
|
health_worker_last_ingest_at:
|
||||||
|
patch.healthWorkerLastIngestAt !== undefined
|
||||||
|
? patch.healthWorkerLastIngestAt
|
||||||
|
: current.health_worker_last_ingest_at,
|
||||||
updated_at: new Date().toISOString(),
|
updated_at: new Date().toISOString(),
|
||||||
})
|
})
|
||||||
.where(eq(appSettings.id, SETTINGS_ID))
|
.where(eq(appSettings.id, SETTINGS_ID))
|
||||||
|
|||||||
Vendored
+42
-1
@@ -151,6 +151,10 @@ interface CfZone {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
status: string;
|
status: string;
|
||||||
|
account?: {
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
interface CfDnsRecord {
|
interface CfDnsRecord {
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -1936,6 +1940,43 @@ declare const vpsTrackerEventSchema: z.ZodObject<{
|
|||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
type VpsTrackerEvent = z.infer<typeof vpsTrackerEventSchema>;
|
type VpsTrackerEvent = z.infer<typeof vpsTrackerEventSchema>;
|
||||||
|
|
||||||
|
declare const HEALTH_PROBE_SCRIPT_NAME = "cfdm-health-probe";
|
||||||
|
declare const HEALTH_PROBE_KV_TITLE = "cfdm-health-probe";
|
||||||
|
declare const HEALTH_KV_TARGETS_KEY = "targets";
|
||||||
|
declare const HEALTH_KV_RESULTS_KEY = "results";
|
||||||
|
declare const HEALTH_KV_CURSOR_KEY = "cursor";
|
||||||
|
declare const HEALTH_PROBE_BATCH = 48;
|
||||||
|
declare const HEALTH_PROBE_CONCURRENCY = 5;
|
||||||
|
type HealthWorkerStatus = "missing" | "ready" | "error";
|
||||||
|
interface HealthProbeTargetItem {
|
||||||
|
key: string;
|
||||||
|
ip: string;
|
||||||
|
hostname: string;
|
||||||
|
type: "tcp" | "http";
|
||||||
|
port: number;
|
||||||
|
path?: string;
|
||||||
|
expectedStatus?: number | null;
|
||||||
|
timeoutMs?: number;
|
||||||
|
verifyTls?: boolean;
|
||||||
|
}
|
||||||
|
interface HealthProbeTargetsDoc {
|
||||||
|
fingerprint: string;
|
||||||
|
updatedAt: string;
|
||||||
|
items: HealthProbeTargetItem[];
|
||||||
|
}
|
||||||
|
interface HealthProbeResultItem {
|
||||||
|
key: string;
|
||||||
|
ok: boolean;
|
||||||
|
latencyMs: number;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
interface HealthProbeResultsDoc {
|
||||||
|
probedAt: string;
|
||||||
|
colo: string | null;
|
||||||
|
fingerprint?: string;
|
||||||
|
items: HealthProbeResultItem[];
|
||||||
|
}
|
||||||
|
|
||||||
declare const AUDIT_SEVERITIES: readonly ["info", "warning", "critical"];
|
declare const AUDIT_SEVERITIES: readonly ["info", "warning", "critical"];
|
||||||
type AuditSeverity = (typeof AUDIT_SEVERITIES)[number];
|
type AuditSeverity = (typeof AUDIT_SEVERITIES)[number];
|
||||||
declare const auditSeveritySchema: z.ZodEnum<{
|
declare const auditSeveritySchema: z.ZodEnum<{
|
||||||
@@ -2044,4 +2085,4 @@ declare const ingestAuditEventSchema: z.ZodObject<{
|
|||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
type IngestAuditEvent = z.infer<typeof ingestAuditEventSchema>;
|
type IngestAuditEvent = z.infer<typeof ingestAuditEventSchema>;
|
||||||
|
|
||||||
export { AUDIT_SEVERITIES, AUDIT_SOURCE_APPS, AUDIT_TARGET_TYPES, type AppSettingsPatch, type AppSwitcherConfig, type AppSwitcherEntry, type AuditListQuery, type AuditLogEntry, type AuditSeverity, type AuditSourceApp, type AuditTargetType, type BulkUpdateDomainsInput, CERT_ERROR, CERT_EXPIRED, CERT_MONITORING_VALUES, CERT_MONITOR_AUTO, CERT_MONITOR_REQUIRED, CERT_MONITOR_SKIPPED, CERT_OK, CERT_UNKNOWN, CERT_WARNING, type CertMonitoring, type Certificate, type CfDnsRecord, type CfHealthCheck, type CfZone, type CfdmBindingSyncItem, type ChangeDomainInput, type ChangeIpInput, type CreateDnsRecordInput, type CreateDnsRecordPayload, type CreateDomainInput, type CreateDomainMonitorInput, type CreateGroupInput, type CreateOriginHealthCheckInput, type CreateServiceBindingInput, type CreateServiceGroupInput, type CreateServiceInput, type CreateServiceNodeInput, type CreateServiceWithConfigInput, type CreateSubdomainInput, type DnsRecord, type Domain, type DomainEnvironment, type DomainListItem, type DomainMonitor, type DomainMonitorResult, type DomainMonitorType, type Group, type GroupWithStats, type HealthCheckConfig, type HealthCheckProvider, type HealthCheckScope, type HealthCheckTarget, type HealthCheckType, type HealthProbeLog, type HealthStatusQuery, type IngestAuditEvent, type IpHealthState, type IpHealthStatus, type JwtClaims, type LbMode, type LoginInput, type LoginRequest, type LoginResponse, type NodeHealthState, type NotificationLog, type OriginHealthCheck, type OriginHealthCheckRecord, type ParsedFqdn, type PatchDnsRecordPayload, type ReorderServicesInput, SYNC_CONFLICT, SYNC_ERROR, SYNC_PENDING_DELETE, SYNC_PENDING_PUSH, SYNC_SYNCED, type Service, type ServiceBinding, type ServiceBindingView, type ServiceDomainBinding, type ServiceDomainBindingView, type ServiceGroup, type ServiceGroupView, type ServiceGroupsResponse, type ServiceIpHealth, type ServiceNode, type ServiceNodeRecord, type ServiceOverview, type ServiceView, type Subdomain, type SubdomainRecord, type SyncJob, type ToggleEnabledInput, type ToggleServiceIpInput, type UpdateDomainInput, type UpdateServiceConfigInput, type UpdateServiceGroupInput, type UpdateServiceNodeInput, type UpdateSubdomainInput, ValidationError, type VpsTrackerEvent, appSettingsPatchSchema, appSwitcherConfigSchema, appSwitcherEntrySchema, appSwitcherIconSchema, auditListQuerySchema, auditLogEntrySchema, auditSeveritySchema, auditSourceAppSchema, auditTargetTypeSchema, bindingToFqdn, bulkUpdateDomainsSchema, certMonitoringSchema, certStatusFromExpiry, certificateSchema, cfdmBindingSyncItemSchema, cfdmSyncBindingsBodySchema, changeDomainSchema, changeIpSchema, createDnsRecordSchema, createDomainMonitorSchema, createDomainSchema, createGroupSchema, createOriginHealthCheckSchema, createServiceBindingSchema, createServiceGroupSchema, createServiceNodeSchema, createServiceSchema, createServiceWithConfigSchema, createSubdomainSchema, dnsNameToSubdomainLabel, dnsRecordNamesMatch, dnsRecordSchema, domainEnvironmentSchema, domainListItemSchema, domainMonitorResultSchema, domainMonitorSchema, domainMonitorTypeSchema, domainSchema, fqdnToDisplay, groupSchema, groupWithStatsSchema, healthCheckConfigSchema, healthCheckProviderSchema, healthCheckScopeSchema, healthCheckTypeSchema, healthProbeLogSchema, healthStatusQuerySchema, ingestAuditEventSchema, ipHealthStateSchema, ipHealthStatusSchema, isIpLiteral, isValidIpv4, lbModeSchema, loginSchema, nodeHealthStateSchema, normalizeDnsRecordName, notificationLogSchema, originHealthCheckSchema, parseFqdn, reorderServicesSchema, serviceBindingSchema, serviceDomainBindingSchema, serviceGroupSchema, serviceGroupTypeSchema, serviceGroupViewSchema, serviceGroupsResponseSchema, serviceIpHealthSchema, serviceNodeSchema, serviceSchema, serviceViewSchema, shouldMonitorService, subdomainLabelToFqdn, subdomainSchema, toggleEnabledSchema, toggleServiceIpSchema, updateDomainGroupSchema, updateDomainSchema, updateServiceConfigSchema, updateServiceGroupSchema, updateServiceNodeSchema, updateSubdomainSchema, validateDnsRecord, vpsTrackerEventSchema };
|
export { AUDIT_SEVERITIES, AUDIT_SOURCE_APPS, AUDIT_TARGET_TYPES, type AppSettingsPatch, type AppSwitcherConfig, type AppSwitcherEntry, type AuditListQuery, type AuditLogEntry, type AuditSeverity, type AuditSourceApp, type AuditTargetType, type BulkUpdateDomainsInput, CERT_ERROR, CERT_EXPIRED, CERT_MONITORING_VALUES, CERT_MONITOR_AUTO, CERT_MONITOR_REQUIRED, CERT_MONITOR_SKIPPED, CERT_OK, CERT_UNKNOWN, CERT_WARNING, type CertMonitoring, type Certificate, type CfDnsRecord, type CfHealthCheck, type CfZone, type CfdmBindingSyncItem, type ChangeDomainInput, type ChangeIpInput, type CreateDnsRecordInput, type CreateDnsRecordPayload, type CreateDomainInput, type CreateDomainMonitorInput, type CreateGroupInput, type CreateOriginHealthCheckInput, type CreateServiceBindingInput, type CreateServiceGroupInput, type CreateServiceInput, type CreateServiceNodeInput, type CreateServiceWithConfigInput, type CreateSubdomainInput, type DnsRecord, type Domain, type DomainEnvironment, type DomainListItem, type DomainMonitor, type DomainMonitorResult, type DomainMonitorType, type Group, type GroupWithStats, HEALTH_KV_CURSOR_KEY, HEALTH_KV_RESULTS_KEY, HEALTH_KV_TARGETS_KEY, HEALTH_PROBE_BATCH, HEALTH_PROBE_CONCURRENCY, HEALTH_PROBE_KV_TITLE, HEALTH_PROBE_SCRIPT_NAME, type HealthCheckConfig, type HealthCheckProvider, type HealthCheckScope, type HealthCheckTarget, type HealthCheckType, type HealthProbeLog, type HealthProbeResultItem, type HealthProbeResultsDoc, type HealthProbeTargetItem, type HealthProbeTargetsDoc, type HealthStatusQuery, type HealthWorkerStatus, type IngestAuditEvent, type IpHealthState, type IpHealthStatus, type JwtClaims, type LbMode, type LoginInput, type LoginRequest, type LoginResponse, type NodeHealthState, type NotificationLog, type OriginHealthCheck, type OriginHealthCheckRecord, type ParsedFqdn, type PatchDnsRecordPayload, type ReorderServicesInput, SYNC_CONFLICT, SYNC_ERROR, SYNC_PENDING_DELETE, SYNC_PENDING_PUSH, SYNC_SYNCED, type Service, type ServiceBinding, type ServiceBindingView, type ServiceDomainBinding, type ServiceDomainBindingView, type ServiceGroup, type ServiceGroupView, type ServiceGroupsResponse, type ServiceIpHealth, type ServiceNode, type ServiceNodeRecord, type ServiceOverview, type ServiceView, type Subdomain, type SubdomainRecord, type SyncJob, type ToggleEnabledInput, type ToggleServiceIpInput, type UpdateDomainInput, type UpdateServiceConfigInput, type UpdateServiceGroupInput, type UpdateServiceNodeInput, type UpdateSubdomainInput, ValidationError, type VpsTrackerEvent, appSettingsPatchSchema, appSwitcherConfigSchema, appSwitcherEntrySchema, appSwitcherIconSchema, auditListQuerySchema, auditLogEntrySchema, auditSeveritySchema, auditSourceAppSchema, auditTargetTypeSchema, bindingToFqdn, bulkUpdateDomainsSchema, certMonitoringSchema, certStatusFromExpiry, certificateSchema, cfdmBindingSyncItemSchema, cfdmSyncBindingsBodySchema, changeDomainSchema, changeIpSchema, createDnsRecordSchema, createDomainMonitorSchema, createDomainSchema, createGroupSchema, createOriginHealthCheckSchema, createServiceBindingSchema, createServiceGroupSchema, createServiceNodeSchema, createServiceSchema, createServiceWithConfigSchema, createSubdomainSchema, dnsNameToSubdomainLabel, dnsRecordNamesMatch, dnsRecordSchema, domainEnvironmentSchema, domainListItemSchema, domainMonitorResultSchema, domainMonitorSchema, domainMonitorTypeSchema, domainSchema, fqdnToDisplay, groupSchema, groupWithStatsSchema, healthCheckConfigSchema, healthCheckProviderSchema, healthCheckScopeSchema, healthCheckTypeSchema, healthProbeLogSchema, healthStatusQuerySchema, ingestAuditEventSchema, ipHealthStateSchema, ipHealthStatusSchema, isIpLiteral, isValidIpv4, lbModeSchema, loginSchema, nodeHealthStateSchema, normalizeDnsRecordName, notificationLogSchema, originHealthCheckSchema, parseFqdn, reorderServicesSchema, serviceBindingSchema, serviceDomainBindingSchema, serviceGroupSchema, serviceGroupTypeSchema, serviceGroupViewSchema, serviceGroupsResponseSchema, serviceIpHealthSchema, serviceNodeSchema, serviceSchema, serviceViewSchema, shouldMonitorService, subdomainLabelToFqdn, subdomainSchema, toggleEnabledSchema, toggleServiceIpSchema, updateDomainGroupSchema, updateDomainSchema, updateServiceConfigSchema, updateServiceGroupSchema, updateServiceNodeSchema, updateSubdomainSchema, validateDnsRecord, vpsTrackerEventSchema };
|
||||||
|
|||||||
Vendored
+16
@@ -749,6 +749,15 @@ var vpsTrackerEventSchema = z3.object({
|
|||||||
timestamp: z3.string().datetime().optional()
|
timestamp: z3.string().datetime().optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// src/health-probe-mailbox.ts
|
||||||
|
var HEALTH_PROBE_SCRIPT_NAME = "cfdm-health-probe";
|
||||||
|
var HEALTH_PROBE_KV_TITLE = "cfdm-health-probe";
|
||||||
|
var HEALTH_KV_TARGETS_KEY = "targets";
|
||||||
|
var HEALTH_KV_RESULTS_KEY = "results";
|
||||||
|
var HEALTH_KV_CURSOR_KEY = "cursor";
|
||||||
|
var HEALTH_PROBE_BATCH = 48;
|
||||||
|
var HEALTH_PROBE_CONCURRENCY = 5;
|
||||||
|
|
||||||
// src/audit.ts
|
// src/audit.ts
|
||||||
import { z as z4 } from "zod";
|
import { z as z4 } from "zod";
|
||||||
var AUDIT_SEVERITIES = ["info", "warning", "critical"];
|
var AUDIT_SEVERITIES = ["info", "warning", "critical"];
|
||||||
@@ -820,6 +829,13 @@ export {
|
|||||||
CERT_OK,
|
CERT_OK,
|
||||||
CERT_UNKNOWN,
|
CERT_UNKNOWN,
|
||||||
CERT_WARNING,
|
CERT_WARNING,
|
||||||
|
HEALTH_KV_CURSOR_KEY,
|
||||||
|
HEALTH_KV_RESULTS_KEY,
|
||||||
|
HEALTH_KV_TARGETS_KEY,
|
||||||
|
HEALTH_PROBE_BATCH,
|
||||||
|
HEALTH_PROBE_CONCURRENCY,
|
||||||
|
HEALTH_PROBE_KV_TITLE,
|
||||||
|
HEALTH_PROBE_SCRIPT_NAME,
|
||||||
SYNC_CONFLICT,
|
SYNC_CONFLICT,
|
||||||
SYNC_ERROR,
|
SYNC_ERROR,
|
||||||
SYNC_PENDING_DELETE,
|
SYNC_PENDING_DELETE,
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
export const HEALTH_PROBE_SCRIPT_NAME = "cfdm-health-probe";
|
||||||
|
export const HEALTH_PROBE_KV_TITLE = "cfdm-health-probe";
|
||||||
|
export const HEALTH_KV_TARGETS_KEY = "targets";
|
||||||
|
export const HEALTH_KV_RESULTS_KEY = "results";
|
||||||
|
export const HEALTH_KV_CURSOR_KEY = "cursor";
|
||||||
|
export const HEALTH_PROBE_BATCH = 48;
|
||||||
|
export const HEALTH_PROBE_CONCURRENCY = 5;
|
||||||
|
|
||||||
|
export type HealthWorkerStatus = "missing" | "ready" | "error";
|
||||||
|
|
||||||
|
export interface HealthProbeTargetItem {
|
||||||
|
key: string;
|
||||||
|
ip: string;
|
||||||
|
hostname: string;
|
||||||
|
type: "tcp" | "http";
|
||||||
|
port: number;
|
||||||
|
path?: string;
|
||||||
|
expectedStatus?: number | null;
|
||||||
|
timeoutMs?: number;
|
||||||
|
verifyTls?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HealthProbeTargetsDoc {
|
||||||
|
fingerprint: string;
|
||||||
|
updatedAt: string;
|
||||||
|
items: HealthProbeTargetItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HealthProbeResultItem {
|
||||||
|
key: string;
|
||||||
|
ok: boolean;
|
||||||
|
latencyMs: number;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HealthProbeResultsDoc {
|
||||||
|
probedAt: string;
|
||||||
|
colo: string | null;
|
||||||
|
fingerprint?: string;
|
||||||
|
items: HealthProbeResultItem[];
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ export * from "./parse-fqdn.js";
|
|||||||
export * from "./schemas.js";
|
export * from "./schemas.js";
|
||||||
export * from "./app-switcher.js";
|
export * from "./app-switcher.js";
|
||||||
export * from "./integration-vps-tracker.js";
|
export * from "./integration-vps-tracker.js";
|
||||||
|
export * from "./health-probe-mailbox.js";
|
||||||
export * from "./audit.js";
|
export * from "./audit.js";
|
||||||
export type {
|
export type {
|
||||||
CfZone,
|
CfZone,
|
||||||
|
|||||||
@@ -229,6 +229,7 @@ export interface CfZone {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
status: string;
|
status: string;
|
||||||
|
account?: { id: string; name?: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CfDnsRecord {
|
export interface CfDnsRecord {
|
||||||
|
|||||||
@@ -1,37 +1,17 @@
|
|||||||
# CFDM health-probe Worker
|
# CFDM health-probe Worker
|
||||||
|
|
||||||
Stateless edge probe for CFDM. **Not** Cloudflare Health Checks API (unavailable on Free).
|
Edge probe for CFDM. **Not** Cloudflare Health Checks API (unavailable on Free).
|
||||||
Cron stays in CFDM — this Worker has no Cron Trigger.
|
|
||||||
|
|
||||||
## Deploy
|
Production: CFDM creates this Worker via the Cloudflare API (KV mailbox + Cron Trigger).
|
||||||
|
You do not need `wrangler deploy`. Token needs **Account**: Workers Scripts Write and Workers KV Storage Write.
|
||||||
|
|
||||||
|
Local debug:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
cd workers/health-probe
|
cd workers/health-probe
|
||||||
npx wrangler login
|
npx wrangler dev
|
||||||
npx wrangler secret put PROBE_TOKEN
|
|
||||||
npx wrangler deploy
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Paste the Worker URL (`https://cfdm-health-probe.<account>.workers.dev`) and the same token into **Настройки → Health-check**.
|
Worker reads KV `targets`, probes TCP (`cloudflare:sockets` + `opened`) or HTTP (`fetch` to IP + `Host`), writes KV `results`. Batch ≤ 48, concurrency 5.
|
||||||
|
|
||||||
Free Workers ≈ 100k requests/day. CFDM cron every 2 minutes × number of IPs must fit.
|
`GET /` — liveness.
|
||||||
|
|
||||||
## API
|
|
||||||
|
|
||||||
`POST /probe` + `Authorization: Bearer <PROBE_TOKEN>`
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"type": "tcp",
|
|
||||||
"ip": "1.2.3.4",
|
|
||||||
"hostname": "app.example.com",
|
|
||||||
"port": 443,
|
|
||||||
"path": "/",
|
|
||||||
"expected_status": 200,
|
|
||||||
"timeout_ms": 3000,
|
|
||||||
"verify_tls": true,
|
|
||||||
"method": "GET"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Response: `{ "ok": true, "latencyMs": 42, "error": null, "colo": "AMS" }`.
|
|
||||||
|
|||||||
@@ -0,0 +1,228 @@
|
|||||||
|
/**
|
||||||
|
* CFDM health-probe Worker. Cron Trigger reads KV `targets`, probes TCP/HTTP
|
||||||
|
* from the edge (UptimeFlare-style), writes KV `results`. CFDM is SoT in SQLite.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const TARGETS_KEY = "targets";
|
||||||
|
const RESULTS_KEY = "results";
|
||||||
|
const CURSOR_KEY = "cursor";
|
||||||
|
const BATCH = 48;
|
||||||
|
const CONCURRENCY = 5;
|
||||||
|
const COOLDOWN_MS = 3 * 60 * 1000;
|
||||||
|
const UA = "CFDM-health-probe/1.0";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
async fetch() {
|
||||||
|
return new Response(JSON.stringify({ ok: true, service: "cfdm-health-probe" }), {
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async scheduled(_event, env) {
|
||||||
|
await probeBatch(env);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
async function probeBatch(env) {
|
||||||
|
const raw = await env.HEALTH_KV.get(TARGETS_KEY);
|
||||||
|
if (!raw) return;
|
||||||
|
let doc;
|
||||||
|
try {
|
||||||
|
doc = JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const items = Array.isArray(doc.items) ? doc.items : [];
|
||||||
|
if (items.length === 0) return;
|
||||||
|
|
||||||
|
let offset = 0;
|
||||||
|
const cursorRaw = await env.HEALTH_KV.get(CURSOR_KEY);
|
||||||
|
if (cursorRaw) {
|
||||||
|
try {
|
||||||
|
const cursor = JSON.parse(cursorRaw);
|
||||||
|
if (Number.isFinite(cursor.offset) && cursor.offset >= 0) {
|
||||||
|
offset = cursor.offset % items.length;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
offset = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const slice = items.slice(offset, offset + BATCH);
|
||||||
|
const nextOffset = offset + slice.length >= items.length ? 0 : offset + slice.length;
|
||||||
|
|
||||||
|
const colo = await readColo();
|
||||||
|
const probed = await mapPool(slice, CONCURRENCY, async (target) => {
|
||||||
|
const type = target.type === "http" ? "http" : "tcp";
|
||||||
|
const port = Number(target.port) || (type === "http" ? 80 : 80);
|
||||||
|
const timeoutMs = Math.min(Math.max(Number(target.timeoutMs) || 3000, 100), 25_000);
|
||||||
|
const hostname = String(target.hostname ?? "").trim() || target.ip;
|
||||||
|
try {
|
||||||
|
const result =
|
||||||
|
type === "http"
|
||||||
|
? await httpProbe({
|
||||||
|
ip: target.ip,
|
||||||
|
hostname,
|
||||||
|
port,
|
||||||
|
path: target.path || "/",
|
||||||
|
expectedStatus: target.expectedStatus ?? 200,
|
||||||
|
timeoutMs,
|
||||||
|
verifyTls: Boolean(target.verifyTls),
|
||||||
|
})
|
||||||
|
: await tcpProbe(target.ip, port, timeoutMs);
|
||||||
|
return { key: target.key, ...result };
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
key: target.key,
|
||||||
|
ok: false,
|
||||||
|
latencyMs: 0,
|
||||||
|
error: err instanceof Error ? err.message : "probe failed",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const fingerprint = resultFingerprint(probed);
|
||||||
|
const previousRaw = await env.HEALTH_KV.get(RESULTS_KEY);
|
||||||
|
let skipWrite = false;
|
||||||
|
if (previousRaw) {
|
||||||
|
try {
|
||||||
|
const prev = JSON.parse(previousRaw);
|
||||||
|
const age = Date.now() - Date.parse(prev.probedAt);
|
||||||
|
if (prev.fingerprint === fingerprint && Number.isFinite(age) && age < COOLDOWN_MS) {
|
||||||
|
skipWrite = true;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
skipWrite = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!skipWrite) {
|
||||||
|
const results = {
|
||||||
|
probedAt: new Date().toISOString(),
|
||||||
|
colo,
|
||||||
|
fingerprint,
|
||||||
|
items: probed,
|
||||||
|
};
|
||||||
|
await env.HEALTH_KV.put(RESULTS_KEY, JSON.stringify(results));
|
||||||
|
}
|
||||||
|
if (items.length > BATCH || offset !== 0) {
|
||||||
|
await env.HEALTH_KV.put(CURSOR_KEY, JSON.stringify({ offset: nextOffset }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resultFingerprint(items) {
|
||||||
|
return items
|
||||||
|
.map((item) => `${item.key}:${item.ok ? "1" : "0"}:${item.error ?? ""}`)
|
||||||
|
.sort()
|
||||||
|
.join("|");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mapPool(items, concurrency, fn) {
|
||||||
|
if (items.length === 0) return [];
|
||||||
|
const results = new Array(items.length);
|
||||||
|
let next = 0;
|
||||||
|
async function worker() {
|
||||||
|
while (next < items.length) {
|
||||||
|
const idx = next;
|
||||||
|
next += 1;
|
||||||
|
results[idx] = await fn(items[idx]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const n = Math.min(concurrency, items.length);
|
||||||
|
await Promise.all(Array.from({ length: n }, () => worker()));
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readColo() {
|
||||||
|
try {
|
||||||
|
const res = await fetch("https://www.cloudflare.com/cdn-cgi/trace", {
|
||||||
|
cf: { cacheTtlByStatus: { "100-599": -1 } },
|
||||||
|
});
|
||||||
|
const text = await res.text();
|
||||||
|
const line = text.split("\n").find((row) => row.startsWith("colo="));
|
||||||
|
return line ? line.slice(5).trim() || null : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function withTimeout(promise, timeoutMs, label) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => reject(new Error(`${label} timeout`)), timeoutMs);
|
||||||
|
promise.then(
|
||||||
|
(value) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve(value);
|
||||||
|
},
|
||||||
|
(err) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(err);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tcpProbe(ip, port, timeoutMs) {
|
||||||
|
const started = Date.now();
|
||||||
|
const { connect } = await import("cloudflare:sockets");
|
||||||
|
const socket = connect({ hostname: ip, port });
|
||||||
|
try {
|
||||||
|
await withTimeout(socket.opened, timeoutMs, "tcp");
|
||||||
|
return { ok: true, latencyMs: Date.now() - started, error: null };
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : "tcp failed";
|
||||||
|
return { ok: false, latencyMs: Date.now() - started, error: message };
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
socket.close();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function httpProbe(opts) {
|
||||||
|
const started = Date.now();
|
||||||
|
const useTls = opts.verifyTls || opts.port === 443;
|
||||||
|
const host = opts.ip.includes(":") ? `[${opts.ip}]` : opts.ip;
|
||||||
|
const path = opts.path.startsWith("/") ? opts.path : `/${opts.path}`;
|
||||||
|
const url = `${useTls ? "https" : "http"}://${host}:${opts.port}${path}`;
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), opts.timeoutMs);
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
Host: opts.hostname,
|
||||||
|
"User-Agent": UA,
|
||||||
|
},
|
||||||
|
signal: controller.signal,
|
||||||
|
redirect: "manual",
|
||||||
|
cf: { cacheTtlByStatus: { "100-599": -1 } },
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await res.body?.cancel();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
const latencyMs = Date.now() - started;
|
||||||
|
if (res.status !== opts.expectedStatus) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
latencyMs,
|
||||||
|
error: `HTTP ${res.status} (ожидали ${opts.expectedStatus})`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ok: true, latencyMs, error: null };
|
||||||
|
} catch (err) {
|
||||||
|
const message =
|
||||||
|
err instanceof Error
|
||||||
|
? err.name === "AbortError"
|
||||||
|
? "http timeout"
|
||||||
|
: err.message
|
||||||
|
: "http failed";
|
||||||
|
return { ok: false, latencyMs: Date.now() - started, error: message };
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,181 +0,0 @@
|
|||||||
/**
|
|
||||||
* Stateless CFDM health probe. Cron lives in CFDM API — this Worker only
|
|
||||||
* answers POST /probe. Deploy: wrangler deploy; paste URL + token into
|
|
||||||
* Настройки → Health-check.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface Env {
|
|
||||||
PROBE_TOKEN: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
type ProbeType = "tcp" | "http";
|
|
||||||
|
|
||||||
interface ProbeRequest {
|
|
||||||
type?: ProbeType;
|
|
||||||
ip?: string;
|
|
||||||
hostname?: string;
|
|
||||||
port?: number;
|
|
||||||
path?: string;
|
|
||||||
expected_status?: number | null;
|
|
||||||
timeout_ms?: number;
|
|
||||||
verify_tls?: boolean;
|
|
||||||
method?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ProbeResponse {
|
|
||||||
ok: boolean;
|
|
||||||
latencyMs: number;
|
|
||||||
error: string | null;
|
|
||||||
colo: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default {
|
|
||||||
async fetch(request: Request, env: Env): Promise<Response> {
|
|
||||||
const colo =
|
|
||||||
(request as Request & { cf?: { colo?: string } }).cf?.colo ?? null;
|
|
||||||
if (request.method !== "POST") {
|
|
||||||
return json({ ok: false, latencyMs: 0, error: "method not allowed", colo }, 405);
|
|
||||||
}
|
|
||||||
const pathname = new URL(request.url).pathname.replace(/\/$/, "") || "/";
|
|
||||||
if (pathname !== "/probe") {
|
|
||||||
return json({ ok: false, latencyMs: 0, error: "not found", colo }, 404);
|
|
||||||
}
|
|
||||||
const token = bearer(request);
|
|
||||||
if (!env.PROBE_TOKEN || token !== env.PROBE_TOKEN) {
|
|
||||||
return json({ ok: false, latencyMs: 0, error: "unauthorized", colo }, 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
let body: ProbeRequest;
|
|
||||||
try {
|
|
||||||
body = (await request.json()) as ProbeRequest;
|
|
||||||
} catch {
|
|
||||||
return json({ ok: false, latencyMs: 0, error: "invalid json", colo }, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
const ip = String(body.ip ?? "").trim();
|
|
||||||
if (!ip) {
|
|
||||||
return json({ ok: false, latencyMs: 0, error: "ip required", colo }, 400);
|
|
||||||
}
|
|
||||||
const type: ProbeType = body.type === "http" ? "http" : "tcp";
|
|
||||||
const port = Number(body.port) || (type === "http" ? 80 : 80);
|
|
||||||
const timeoutMs = Math.min(Math.max(Number(body.timeout_ms) || 3000, 100), 25_000);
|
|
||||||
const hostname = String(body.hostname ?? "").trim() || ip;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result =
|
|
||||||
type === "http"
|
|
||||||
? await httpProbe({
|
|
||||||
ip,
|
|
||||||
hostname,
|
|
||||||
port,
|
|
||||||
path: body.path || "/",
|
|
||||||
expectedStatus: body.expected_status ?? 200,
|
|
||||||
timeoutMs,
|
|
||||||
verifyTls: Boolean(body.verify_tls),
|
|
||||||
method: (body.method || "GET").toUpperCase(),
|
|
||||||
})
|
|
||||||
: await tcpProbe(ip, port, timeoutMs);
|
|
||||||
return json({ ...result, colo });
|
|
||||||
} catch (err) {
|
|
||||||
const message = err instanceof Error ? err.message : "probe failed";
|
|
||||||
return json({ ok: false, latencyMs: 0, error: message, colo });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
function bearer(request: Request): string {
|
|
||||||
const header = request.headers.get("Authorization") ?? "";
|
|
||||||
return header.startsWith("Bearer ") ? header.slice(7) : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function json(body: ProbeResponse, status = 200): Response {
|
|
||||||
return new Response(JSON.stringify(body), {
|
|
||||||
status,
|
|
||||||
headers: { "content-type": "application/json" },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const timer = setTimeout(() => reject(new Error(`${label} timeout`)), timeoutMs);
|
|
||||||
promise.then(
|
|
||||||
(value) => {
|
|
||||||
clearTimeout(timer);
|
|
||||||
resolve(value);
|
|
||||||
},
|
|
||||||
(err) => {
|
|
||||||
clearTimeout(timer);
|
|
||||||
reject(err);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function tcpProbe(
|
|
||||||
ip: string,
|
|
||||||
port: number,
|
|
||||||
timeoutMs: number,
|
|
||||||
): Promise<Omit<ProbeResponse, "colo">> {
|
|
||||||
const started = Date.now();
|
|
||||||
const { connect } = await import("cloudflare:sockets");
|
|
||||||
const socket = connect({ hostname: ip, port });
|
|
||||||
try {
|
|
||||||
await withTimeout(socket.opened, timeoutMs, "tcp");
|
|
||||||
return { ok: true, latencyMs: Date.now() - started, error: null };
|
|
||||||
} catch (err) {
|
|
||||||
const message = err instanceof Error ? err.message : "tcp failed";
|
|
||||||
return { ok: false, latencyMs: Date.now() - started, error: message };
|
|
||||||
} finally {
|
|
||||||
try {
|
|
||||||
socket.close();
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function httpProbe(opts: {
|
|
||||||
ip: string;
|
|
||||||
hostname: string;
|
|
||||||
port: number;
|
|
||||||
path: string;
|
|
||||||
expectedStatus: number;
|
|
||||||
timeoutMs: number;
|
|
||||||
verifyTls: boolean;
|
|
||||||
method: string;
|
|
||||||
}): Promise<Omit<ProbeResponse, "colo">> {
|
|
||||||
const started = Date.now();
|
|
||||||
const useTls = opts.verifyTls || opts.port === 443;
|
|
||||||
const host = opts.ip.includes(":") ? `[${opts.ip}]` : opts.ip;
|
|
||||||
const path = opts.path.startsWith("/") ? opts.path : `/${opts.path}`;
|
|
||||||
const url = `${useTls ? "https" : "http"}://${host}:${opts.port}${path}`;
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timer = setTimeout(() => controller.abort(), opts.timeoutMs);
|
|
||||||
try {
|
|
||||||
const res = await fetch(url, {
|
|
||||||
method: opts.method === "HEAD" ? "HEAD" : "GET",
|
|
||||||
headers: { Host: opts.hostname },
|
|
||||||
signal: controller.signal,
|
|
||||||
redirect: "manual",
|
|
||||||
});
|
|
||||||
const latencyMs = Date.now() - started;
|
|
||||||
if (res.status !== opts.expectedStatus) {
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
latencyMs,
|
|
||||||
error: `HTTP ${res.status} (ожидали ${opts.expectedStatus})`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return { ok: true, latencyMs, error: null };
|
|
||||||
} catch (err) {
|
|
||||||
const message =
|
|
||||||
err instanceof Error
|
|
||||||
? err.name === "AbortError"
|
|
||||||
? "http timeout"
|
|
||||||
: err.message
|
|
||||||
: "http failed";
|
|
||||||
return { ok: false, latencyMs: Date.now() - started, error: message };
|
|
||||||
} finally {
|
|
||||||
clearTimeout(timer);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
name = "cfdm-health-probe"
|
name = "cfdm-health-probe"
|
||||||
main = "src/index.ts"
|
main = "src/index.mjs"
|
||||||
compatibility_date = "2025-04-01"
|
compatibility_date = "2025-04-01"
|
||||||
|
|
||||||
# Set the shared secret: wrangler secret put PROBE_TOKEN
|
# Production Worker is created by CFDM (Workers Scripts API + KV + Cron Trigger).
|
||||||
# Then paste the Worker URL + token into CFDM → Настройки → Health-check.
|
# This file is for local `wrangler dev` only.
|
||||||
|
|
||||||
|
[[kv_namespaces]]
|
||||||
|
binding = "HEALTH_KV"
|
||||||
|
id = "00000000-0000-0000-0000-000000000000"
|
||||||
|
|||||||
Reference in New Issue
Block a user