feat(api): enhance health check and DNS management
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 7s
quality / changes (push) Successful in 8s
quality / docker-check (push) Skipped
quality / web (push) Successful in 1m9s
quality / api (push) Successful in 40s
CD / quality (push) Successful in 2m0s
CD / publish (push) Successful in 2m38s
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 7s
quality / changes (push) Successful in 8s
quality / docker-check (push) Skipped
quality / web (push) Successful in 1m9s
quality / api (push) Successful in 40s
CD / quality (push) Successful in 2m0s
CD / publish (push) Successful in 2m38s
- Added batch endpoint for health status queries to reduce the number of requests. - Improved DNS record management with caching and invalidation mechanisms. - Updated health check service to handle new configurations and improve performance. - Refactored certificate service to utilize concurrency for checks, enhancing efficiency. - Removed unused dependencies and optimized package configurations. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
+13
-3
@@ -32,6 +32,7 @@ import {
|
||||
import { settingsRoutes } from "./routes/settings.js";
|
||||
import { integrationsVpsTrackerRoutes } from "./routes/integrations-vps-tracker.js";
|
||||
import { auditRoutes } from "./routes/audit.js";
|
||||
import { repos, walCheckpointTruncate } from "@cfdm/db";
|
||||
import * as certificateService from "./services/certificate-service.js";
|
||||
import {
|
||||
createHealthCheckTask,
|
||||
@@ -43,7 +44,7 @@ import {
|
||||
scheduleWeightedDnsJob,
|
||||
} from "./services/weighted-dns-scheduler.js";
|
||||
import { fireEnsureHealthWorker } from "./services/health/health-worker-deploy.js";
|
||||
import { AsyncTask, CronJob } from "toad-scheduler";
|
||||
import { AsyncTask, CronJob, ToadScheduler } from "toad-scheduler";
|
||||
|
||||
export interface BuildAppOptions {
|
||||
config?: AppConfig;
|
||||
@@ -111,11 +112,20 @@ export async function buildApp(opts: BuildAppOptions = {}) {
|
||||
}
|
||||
|
||||
if (!opts.memory) {
|
||||
await app.register(import("@fastify/schedule"));
|
||||
const scheduler = new ToadScheduler();
|
||||
app.decorate("scheduler", scheduler);
|
||||
app.addHook("onClose", async () => {
|
||||
scheduler.stop();
|
||||
});
|
||||
|
||||
const certTask = new AsyncTask(
|
||||
"certificate-check",
|
||||
async () => {
|
||||
const pruned = repos.pruneLogs(app.db);
|
||||
if (pruned > 0) {
|
||||
app.log.info({ pruned }, "log retention pruned");
|
||||
}
|
||||
walCheckpointTruncate(app.sqlite);
|
||||
const n = await certificateService.runAllChecks(app.db);
|
||||
app.log.info({ checked: n }, "certificate check completed");
|
||||
},
|
||||
@@ -124,7 +134,7 @@ export async function buildApp(opts: BuildAppOptions = {}) {
|
||||
},
|
||||
);
|
||||
|
||||
app.scheduler.addCronJob(
|
||||
scheduler.addCronJob(
|
||||
new CronJob(
|
||||
{ cronExpression: config.certCheckCron },
|
||||
certTask,
|
||||
|
||||
@@ -38,12 +38,19 @@ export class CloudflareClient {
|
||||
return this.zones.listZones();
|
||||
}
|
||||
|
||||
invalidateZonesCache(): void {
|
||||
this.zones.invalidateZonesCache();
|
||||
}
|
||||
|
||||
getZone(zoneId: string): Promise<CfZone> {
|
||||
return this.zones.getZone(zoneId);
|
||||
}
|
||||
|
||||
listDnsRecords(zoneId: string): Promise<CfDnsRecord[]> {
|
||||
return this.dns.listDnsRecords(zoneId);
|
||||
listDnsRecords(
|
||||
zoneId: string,
|
||||
cache?: Map<string, CfDnsRecord[]>,
|
||||
): Promise<CfDnsRecord[]> {
|
||||
return this.dns.listDnsRecords(zoneId, cache);
|
||||
}
|
||||
|
||||
createDnsRecord(zoneId: string, payload: CreateDnsRecordPayload): Promise<CfDnsRecord> {
|
||||
|
||||
@@ -4,9 +4,14 @@ import { CF_API_BASE, handleCfResponse, mapCloudflareFailure } from "./http.js";
|
||||
|
||||
export function createDnsAdapter(token: string) {
|
||||
return {
|
||||
async listDnsRecords(zoneId: string): Promise<CfDnsRecord[]> {
|
||||
return withRetry(async () => {
|
||||
const all: CfDnsRecord[] = [];
|
||||
async listDnsRecords(
|
||||
zoneId: string,
|
||||
cache?: Map<string, CfDnsRecord[]>,
|
||||
): Promise<CfDnsRecord[]> {
|
||||
const cached = cache?.get(zoneId);
|
||||
if (cached) return cached;
|
||||
const all = await withRetry(async () => {
|
||||
const records: CfDnsRecord[] = [];
|
||||
let page = 1;
|
||||
while (page <= 50) {
|
||||
const url = new URL(`${CF_API_BASE}/zones/${zoneId}/dns_records`);
|
||||
@@ -28,11 +33,13 @@ export function createDnsAdapter(token: string) {
|
||||
"list_dns_records",
|
||||
);
|
||||
if (batch.length === 0) break;
|
||||
all.push(...batch);
|
||||
records.push(...batch);
|
||||
page += 1;
|
||||
}
|
||||
return all;
|
||||
return records;
|
||||
});
|
||||
cache?.set(zoneId, all);
|
||||
return all;
|
||||
},
|
||||
|
||||
async createDnsRecord(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { CfDnsRecord } from "@cfdm/shared";
|
||||
import { AppError } from "../../errors.js";
|
||||
import { parseRetryAfter } from "../cf-retry.js";
|
||||
import { notifyZoneCacheInvalidated } from "./zone-cache-events.js";
|
||||
|
||||
export const CF_API_BASE = "https://api.cloudflare.com/client/v4";
|
||||
|
||||
@@ -17,6 +18,7 @@ export function mapCloudflareFailure(
|
||||
): AppError {
|
||||
const lower = message.toLowerCase();
|
||||
if (status === 401 || status === 403 || lower.includes("authentication")) {
|
||||
notifyZoneCacheInvalidated();
|
||||
if (
|
||||
operation.includes("workers") ||
|
||||
operation.includes("kv_") ||
|
||||
@@ -31,6 +33,7 @@ export function mapCloudflareFailure(
|
||||
);
|
||||
}
|
||||
if (status === 429 || lower.includes("rate limit")) {
|
||||
notifyZoneCacheInvalidated();
|
||||
return AppError.rateLimited();
|
||||
}
|
||||
if (lower.includes("zone") && (lower.includes("not found") || status === 404)) {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Tiny module-level hook registry: http.ts signals CF auth/rate-limit failures,
|
||||
* zone-service subscribes to drop its TTL cache. Kept separate to avoid a
|
||||
* circular import between http.ts and zone-service.ts.
|
||||
*/
|
||||
const subscribers = new Set<() => void>();
|
||||
|
||||
export function subscribeZoneCacheInvalidation(cb: () => void): () => void {
|
||||
subscribers.add(cb);
|
||||
return () => subscribers.delete(cb);
|
||||
}
|
||||
|
||||
export function notifyZoneCacheInvalidated(): void {
|
||||
for (const cb of subscribers) {
|
||||
try {
|
||||
cb();
|
||||
} catch {
|
||||
// subscriber cleanup must never break the CF response path
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,34 +1,69 @@
|
||||
import type { CfZone } from "@cfdm/shared";
|
||||
import { withRetry } from "../cf-retry.js";
|
||||
import { CF_API_BASE, handleCfResponse, mapCloudflareFailure } from "./http.js";
|
||||
import { subscribeZoneCacheInvalidation } from "./zone-cache-events.js";
|
||||
|
||||
const ZONE_CACHE_TTL_MS = 5 * 60_000;
|
||||
|
||||
/** TTL cache for the zones list: /ready, collectKnownZones, weighted-reconcile
|
||||
* poll it on a hot schedule; zones rarely change, so one CF API call per 5 min. */
|
||||
export function createZoneAdapter(token: string) {
|
||||
let cachedAt = 0;
|
||||
let cachedZones: CfZone[] | null = null;
|
||||
let inflight: Promise<CfZone[]> | null = null;
|
||||
|
||||
async function fetchZones(): Promise<CfZone[]> {
|
||||
const all: CfZone[] = [];
|
||||
let page = 1;
|
||||
while (true) {
|
||||
const url = new URL(`${CF_API_BASE}/zones`);
|
||||
url.searchParams.set("per_page", "50");
|
||||
url.searchParams.set("page", String(page));
|
||||
const response = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
if (response.status >= 500 || response.status === 429) {
|
||||
throw mapCloudflareFailure("list_zones", response.status, String(response.status));
|
||||
}
|
||||
const batch = await handleCfResponse<CfZone[]>(response, "list_zones");
|
||||
if (batch.length === 0) break;
|
||||
all.push(...batch);
|
||||
if (batch.length < 50) break;
|
||||
page += 1;
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
function invalidate(): void {
|
||||
cachedAt = 0;
|
||||
cachedZones = null;
|
||||
}
|
||||
|
||||
subscribeZoneCacheInvalidation(invalidate);
|
||||
|
||||
return {
|
||||
async listZones(): Promise<CfZone[]> {
|
||||
return withRetry(async () => {
|
||||
const all: CfZone[] = [];
|
||||
let page = 1;
|
||||
while (true) {
|
||||
const url = new URL(`${CF_API_BASE}/zones`);
|
||||
url.searchParams.set("per_page", "50");
|
||||
url.searchParams.set("page", String(page));
|
||||
const response = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
const now = Date.now();
|
||||
if (cachedZones && now - cachedAt < ZONE_CACHE_TTL_MS) {
|
||||
return cachedZones;
|
||||
}
|
||||
if (!inflight) {
|
||||
inflight = withRetry(fetchZones)
|
||||
.then((zones) => {
|
||||
cachedAt = Date.now();
|
||||
cachedZones = zones;
|
||||
return zones;
|
||||
})
|
||||
.finally(() => {
|
||||
inflight = null;
|
||||
});
|
||||
if (response.status >= 500 || response.status === 429) {
|
||||
throw mapCloudflareFailure("list_zones", response.status, String(response.status));
|
||||
}
|
||||
const batch = await handleCfResponse<CfZone[]>(response, "list_zones");
|
||||
if (batch.length === 0) break;
|
||||
all.push(...batch);
|
||||
if (batch.length < 50) break;
|
||||
page += 1;
|
||||
}
|
||||
return all;
|
||||
});
|
||||
}
|
||||
return inflight;
|
||||
},
|
||||
|
||||
invalidateZonesCache: invalidate,
|
||||
|
||||
async getZone(zoneId: string): Promise<CfZone> {
|
||||
const response = await fetch(`${CF_API_BASE}/zones/${zoneId}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
import type { IpHealthStatus } from "@cfdm/shared";
|
||||
import { healthStatusQuerySchema } from "@cfdm/shared";
|
||||
import { getAppSettings, repos } from "@cfdm/db";
|
||||
import * as healthCheckService from "../services/health-check-service.js";
|
||||
@@ -19,6 +21,30 @@ export async function healthCheckRoutes(app: FastifyInstance) {
|
||||
);
|
||||
});
|
||||
|
||||
// Batch endpoint for lists: one request instead of N per-binding polls.
|
||||
app.get("/health-status/batch", async (request) => {
|
||||
const raw = (request.query as Record<string, unknown>) ?? {};
|
||||
const idsParam = typeof raw.ref_ids === "string" ? raw.ref_ids : "";
|
||||
const refIds = z
|
||||
.array(z.coerce.number().int().positive())
|
||||
.max(200)
|
||||
.parse(
|
||||
idsParam
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0),
|
||||
);
|
||||
const grouped = repos.listIpHealthStatusByBindingIds(
|
||||
request.server.db,
|
||||
refIds,
|
||||
);
|
||||
const items: { ref_id: number; rows: IpHealthStatus[] }[] = [];
|
||||
for (const id of refIds) {
|
||||
items.push({ ref_id: id, rows: grouped.get(id) ?? [] });
|
||||
}
|
||||
return { items };
|
||||
});
|
||||
|
||||
app.post("/health-check/run", async (request) => {
|
||||
const config = request.server.config;
|
||||
const fallbacks = healthEngineFallbacksFromConfig(config);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { connect } from "node:net";
|
||||
import { connect as tlsConnect } from "node:tls";
|
||||
import pLimit from "p-limit";
|
||||
import type { Db } from "@cfdm/db";
|
||||
import { repos } from "@cfdm/db";
|
||||
import type { Certificate, ServiceCertificateRow, Subdomain } from "@cfdm/shared";
|
||||
@@ -21,19 +22,10 @@ export interface CertificateTarget {
|
||||
hostname: string;
|
||||
}
|
||||
|
||||
function pruneStaleCertificates(db: Db): void {
|
||||
const targets = resolveCertificateTargets(db);
|
||||
repos.deleteCertificatesNotIn(
|
||||
db,
|
||||
targets.map((t) => t.hostname),
|
||||
);
|
||||
}
|
||||
|
||||
export function listCertificates(
|
||||
db: Db,
|
||||
status?: string,
|
||||
): Certificate[] {
|
||||
pruneStaleCertificates(db);
|
||||
return repos.listCertificates(db, status);
|
||||
}
|
||||
|
||||
@@ -231,15 +223,20 @@ export function resolveCertificateTargets(db: Db): CertificateTarget[] {
|
||||
|
||||
export async function runAllChecks(db: Db): Promise<number> {
|
||||
const targets = resolveCertificateTargets(db);
|
||||
for (const target of targets) {
|
||||
await checkAndStore(
|
||||
db,
|
||||
target.domainId,
|
||||
target.subdomainId,
|
||||
target.hostname,
|
||||
target.serviceId,
|
||||
);
|
||||
}
|
||||
const limit = pLimit(5);
|
||||
await Promise.all(
|
||||
targets.map((target) =>
|
||||
limit(() =>
|
||||
checkAndStore(
|
||||
db,
|
||||
target.domainId,
|
||||
target.subdomainId,
|
||||
target.hostname,
|
||||
target.serviceId,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
repos.deleteCertificatesNotIn(
|
||||
db,
|
||||
targets.map((t) => t.hostname),
|
||||
@@ -268,6 +265,5 @@ export async function runServiceChecks(
|
||||
}
|
||||
|
||||
export function statusSummary(db: Db): Array<[string, number]> {
|
||||
pruneStaleCertificates(db);
|
||||
return repos.countCertificatesByStatus(db);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { AsyncTask, CronJob } from "toad-scheduler";
|
||||
import { AsyncTask, CronJob, type ToadScheduler } from "toad-scheduler";
|
||||
import {
|
||||
getAppSettings,
|
||||
getAppSettingsSecrets,
|
||||
@@ -18,6 +18,7 @@ import { cronStaleAfterMs } from "./health/mailbox.js";
|
||||
|
||||
declare module "fastify" {
|
||||
interface FastifyInstance {
|
||||
scheduler?: ToadScheduler;
|
||||
reloadHealthCheckJob?: () => void;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,6 +168,8 @@ async function httpProbe(
|
||||
dispatcher,
|
||||
});
|
||||
const latency = Date.now() - started;
|
||||
// Consume body so the socket is released before agent teardown.
|
||||
await response.body?.cancel().catch(() => {});
|
||||
if (target.expected_status != null) {
|
||||
if (response.status !== target.expected_status) {
|
||||
return {
|
||||
@@ -192,6 +194,9 @@ async function httpProbe(
|
||||
latencyMs: Date.now() - started,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
} finally {
|
||||
// Per-probe agents must not leak sockets/fds; probes run every ~2 min × N targets.
|
||||
await dispatcher?.destroy().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Db } from "@cfdm/db";
|
||||
import { repos } from "@cfdm/db";
|
||||
import type {
|
||||
CfDnsRecord,
|
||||
DnsRecord,
|
||||
HealthCheckAggregate,
|
||||
HealthCheckProvider,
|
||||
@@ -9,6 +10,7 @@ import type {
|
||||
IpHealthState,
|
||||
LbMode,
|
||||
Service,
|
||||
ServiceBinding,
|
||||
ServiceGroup,
|
||||
ServiceGroupsResponse,
|
||||
ServiceView,
|
||||
@@ -341,101 +343,148 @@ async function collectKnownZones(
|
||||
return zones;
|
||||
}
|
||||
|
||||
async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
|
||||
const service = repos.getService(db, serviceId);
|
||||
const ipRows = repos.listServiceIpRows(db, serviceId);
|
||||
const ips = ipRows.map((row) => row.ip);
|
||||
const ip_enabled = Object.fromEntries(
|
||||
ipRows.map((row) => [row.ip, row.enabled]),
|
||||
);
|
||||
const bindings = repos.listBindingsByService(db, serviceId);
|
||||
interface BindingLbContext {
|
||||
ipMetaByBinding: Map<number, repos.BindingIpMeta[]>;
|
||||
healthByBinding: Map<number, Map<string, { status: string; latency_ms: number | null }>>;
|
||||
}
|
||||
|
||||
const domainViews = bindings.map((binding) => {
|
||||
const records = repos.listRecordsForBinding(db, binding.id);
|
||||
const statuses = records.map((r) => r.sync_status);
|
||||
const targetIpsWithMeta = repos.listBindingIpsWithMeta(db, binding.id);
|
||||
const targetIps = targetIpsWithMeta.map((entry) => entry.ip);
|
||||
const linkedCname = records.find(
|
||||
(record) => record.record_type.toUpperCase() === "CNAME",
|
||||
);
|
||||
const targetCname =
|
||||
binding.cname_target?.trim() || linkedCname?.content?.trim() || null;
|
||||
|
||||
const target_ip_weights: Record<string, number> = {};
|
||||
const target_ip_priorities: Record<string, number> = {};
|
||||
for (const entry of targetIpsWithMeta) {
|
||||
target_ip_weights[entry.ip] = entry.weight;
|
||||
target_ip_priorities[entry.ip] = entry.priority;
|
||||
}
|
||||
for (const ip of targetIps) {
|
||||
if (target_ip_weights[ip] === undefined) target_ip_weights[ip] = 1;
|
||||
if (target_ip_priorities[ip] === undefined) target_ip_priorities[ip] = 1;
|
||||
}
|
||||
|
||||
const { config, rows } = getBindingLbState(db, binding.id);
|
||||
const bindingActiveIps = targetCname
|
||||
? []
|
||||
: resolveDesiredAIps(config, rows, targetIps, Date.now(), ips);
|
||||
|
||||
return {
|
||||
binding_id: binding.id,
|
||||
domain_id: binding.domain_id,
|
||||
zone_name: binding.zone_name,
|
||||
hostname: binding.hostname,
|
||||
fqdn: fqdnToDisplay(binding.hostname, binding.zone_name),
|
||||
record_type: targetCname ? ("CNAME" as const) : ("A" as const),
|
||||
target_ips: targetCname ? [] : targetIps,
|
||||
target_ip_weights,
|
||||
target_ip_priorities,
|
||||
target_cname: targetCname,
|
||||
function bindingLbStateFromBatch(
|
||||
binding: ServiceBinding,
|
||||
ctx: BindingLbContext,
|
||||
): { config: LbTargetConfig; rows: LbIpRow[] } {
|
||||
const ipMetas = ctx.ipMetaByBinding.get(binding.id) ?? [];
|
||||
const healthByIp = ctx.healthByBinding.get(binding.id);
|
||||
const rows: LbIpRow[] = ipMetas.map((entry) => ({
|
||||
ip: entry.ip,
|
||||
weight: entry.weight,
|
||||
priority: entry.priority,
|
||||
health: (healthByIp?.get(entry.ip)?.status as IpHealthState) ?? "unknown",
|
||||
}));
|
||||
return {
|
||||
config: {
|
||||
lb_mode: binding.lb_mode,
|
||||
health_check_enabled: binding.health_check_enabled,
|
||||
health_check_type: binding.health_check_type,
|
||||
health_check_port: binding.health_check_port,
|
||||
health_check_path: binding.health_check_path,
|
||||
health_check_expected_status: binding.health_check_expected_status,
|
||||
health_check_interval_sec: binding.health_check_interval_sec,
|
||||
health_check_timeout_ms: binding.health_check_timeout_ms,
|
||||
health_check_verify_tls: binding.health_check_verify_tls,
|
||||
health_check_provider: binding.health_check_provider ?? "local",
|
||||
health_check_providers: binding.health_check_providers ?? [
|
||||
binding.health_check_provider ?? "local",
|
||||
],
|
||||
health_check_aggregate: binding.health_check_aggregate ?? "majority",
|
||||
cert_monitoring: binding.cert_monitoring ?? "auto",
|
||||
sync_status: aggregateSyncStatus(statuses),
|
||||
active_ips: bindingActiveIps,
|
||||
},
|
||||
rows,
|
||||
};
|
||||
}
|
||||
|
||||
async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
|
||||
const [view] = await buildViews(db, [serviceId]);
|
||||
return view!;
|
||||
}
|
||||
|
||||
/** Batch view builder: 6 fixed queries for N services instead of O(services×bindings×ips). */
|
||||
async function buildViews(db: Db, serviceIds: number[]): Promise<ServiceView[]> {
|
||||
const services = serviceIds.map((id) => repos.getService(db, id));
|
||||
|
||||
const ipRowsByService = repos.listServiceIpRowsByServiceIds(db, serviceIds);
|
||||
const bindingsByService = repos.listBindingsByServiceIds(db, serviceIds);
|
||||
const allBindings = [...bindingsByService.values()].flat();
|
||||
const bindingIds = allBindings.map((b) => b.id);
|
||||
|
||||
const recordsByBinding = repos.listRecordsByBindingIds(db, bindingIds);
|
||||
const ipMetaByBinding = repos.listBindingIpsWithMetaByBindingIds(db, bindingIds);
|
||||
const healthByBinding = repos.listBindingIpHealthByBindingIds(db, bindingIds);
|
||||
|
||||
const lbCtx: BindingLbContext = { ipMetaByBinding, healthByBinding };
|
||||
const now = Date.now();
|
||||
|
||||
return services.map((service) => {
|
||||
const ipRows = ipRowsByService.get(service.id) ?? [];
|
||||
const ips = ipRows.map((row) => row.ip);
|
||||
const ip_enabled = Object.fromEntries(
|
||||
ipRows.map((row) => [row.ip, row.enabled]),
|
||||
);
|
||||
const bindings = bindingsByService.get(service.id) ?? [];
|
||||
|
||||
const domainViews = bindings.map((binding) => {
|
||||
const records = recordsByBinding.get(binding.id) ?? [];
|
||||
const statuses = records.map((r) => r.sync_status);
|
||||
const targetIpsWithMeta = ipMetaByBinding.get(binding.id) ?? [];
|
||||
const targetIps = targetIpsWithMeta.map((entry) => entry.ip);
|
||||
const linkedCname = records.find(
|
||||
(record) => record.record_type.toUpperCase() === "CNAME",
|
||||
);
|
||||
const targetCname =
|
||||
binding.cname_target?.trim() || linkedCname?.content?.trim() || null;
|
||||
|
||||
const target_ip_weights: Record<string, number> = {};
|
||||
const target_ip_priorities: Record<string, number> = {};
|
||||
for (const entry of targetIpsWithMeta) {
|
||||
target_ip_weights[entry.ip] = entry.weight;
|
||||
target_ip_priorities[entry.ip] = entry.priority;
|
||||
}
|
||||
for (const ip of targetIps) {
|
||||
if (target_ip_weights[ip] === undefined) target_ip_weights[ip] = 1;
|
||||
if (target_ip_priorities[ip] === undefined) target_ip_priorities[ip] = 1;
|
||||
}
|
||||
|
||||
const { config, rows } = bindingLbStateFromBatch(binding, lbCtx);
|
||||
const bindingActiveIps = targetCname
|
||||
? []
|
||||
: resolveDesiredAIps(config, rows, targetIps, now, ips);
|
||||
|
||||
return {
|
||||
binding_id: binding.id,
|
||||
domain_id: binding.domain_id,
|
||||
zone_name: binding.zone_name,
|
||||
hostname: binding.hostname,
|
||||
fqdn: fqdnToDisplay(binding.hostname, binding.zone_name),
|
||||
record_type: targetCname ? ("CNAME" as const) : ("A" as const),
|
||||
target_ips: targetCname ? [] : targetIps,
|
||||
target_ip_weights,
|
||||
target_ip_priorities,
|
||||
target_cname: targetCname,
|
||||
lb_mode: binding.lb_mode,
|
||||
health_check_enabled: binding.health_check_enabled,
|
||||
health_check_type: binding.health_check_type,
|
||||
health_check_port: binding.health_check_port,
|
||||
health_check_path: binding.health_check_path,
|
||||
health_check_expected_status: binding.health_check_expected_status,
|
||||
health_check_interval_sec: binding.health_check_interval_sec,
|
||||
health_check_timeout_ms: binding.health_check_timeout_ms,
|
||||
health_check_verify_tls: binding.health_check_verify_tls,
|
||||
health_check_provider: binding.health_check_provider ?? "local",
|
||||
health_check_providers: binding.health_check_providers ?? [
|
||||
binding.health_check_provider ?? "local",
|
||||
],
|
||||
health_check_aggregate: binding.health_check_aggregate ?? "majority",
|
||||
cert_monitoring: binding.cert_monitoring ?? "auto",
|
||||
sync_status: aggregateSyncStatus(statuses),
|
||||
active_ips: bindingActiveIps,
|
||||
};
|
||||
});
|
||||
|
||||
const activeIps = new Set<string>();
|
||||
for (const domain of domainViews) {
|
||||
for (const ip of domain.active_ips) {
|
||||
activeIps.add(ip);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: service.id,
|
||||
name: service.name,
|
||||
slug: service.slug,
|
||||
service_group_id: service.service_group_id ?? null,
|
||||
subdomain: service.subdomain ?? "",
|
||||
enabled: Boolean(service.enabled),
|
||||
computed_fqdn: null,
|
||||
lb_weight: service.lb_weight,
|
||||
lb_priority: service.lb_priority,
|
||||
created_at: service.created_at,
|
||||
updated_at: service.updated_at,
|
||||
ips,
|
||||
ip_enabled,
|
||||
domains: domainViews,
|
||||
health_status: "unknown",
|
||||
health_latency_ms: null,
|
||||
ip_health: [],
|
||||
lb_mode: bindings[0]?.lb_mode ?? "round_robin",
|
||||
active_ips: [...activeIps],
|
||||
};
|
||||
});
|
||||
|
||||
const activeIps = new Set<string>();
|
||||
for (const domain of domainViews) {
|
||||
for (const ip of domain.active_ips) {
|
||||
activeIps.add(ip);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: service.id,
|
||||
name: service.name,
|
||||
slug: service.slug,
|
||||
service_group_id: service.service_group_id ?? null,
|
||||
subdomain: service.subdomain ?? "",
|
||||
enabled: Boolean(service.enabled),
|
||||
computed_fqdn: null,
|
||||
lb_weight: service.lb_weight,
|
||||
lb_priority: service.lb_priority,
|
||||
created_at: service.created_at,
|
||||
updated_at: service.updated_at,
|
||||
ips,
|
||||
ip_enabled,
|
||||
domains: domainViews,
|
||||
health_status: "unknown",
|
||||
health_latency_ms: null,
|
||||
ip_health: [],
|
||||
lb_mode: bindings[0]?.lb_mode ?? "round_robin",
|
||||
active_ips: [...activeIps],
|
||||
};
|
||||
}
|
||||
|
||||
const HEALTH_RANK: Record<string, number> = {
|
||||
@@ -566,9 +615,8 @@ function attachServiceHealth(
|
||||
}
|
||||
|
||||
export async function listViews(db: Db): Promise<ServiceView[]> {
|
||||
const views = await Promise.all(
|
||||
repos.listServices(db).map((s) => buildView(db, s.id)),
|
||||
);
|
||||
const ids = repos.listServices(db).map((s) => s.id);
|
||||
const views = await buildViews(db, ids);
|
||||
return attachServiceHealth(db, views);
|
||||
}
|
||||
|
||||
@@ -580,25 +628,23 @@ export async function getView(db: Db, id: number): Promise<ServiceView> {
|
||||
|
||||
export async function listGroupViews(db: Db): Promise<ServiceGroupsResponse> {
|
||||
const groups = repos.listServiceGroups(db);
|
||||
const groupViewsRaw = await Promise.all(
|
||||
groups.map(async (group) => {
|
||||
const services = repos.listServicesByGroup(db, group.id);
|
||||
const serviceViews = await Promise.all(
|
||||
services.map((s) => buildView(db, s.id)),
|
||||
);
|
||||
return { ...group, services: serviceViews };
|
||||
}),
|
||||
);
|
||||
|
||||
const servicesByGroup = repos.listServicesByGroupIds(db, groups.map((g) => g.id));
|
||||
const ungroupedServices = repos.listUngroupedServices(db);
|
||||
const ungroupedRaw = await Promise.all(
|
||||
ungroupedServices.map((s) => buildView(db, s.id)),
|
||||
);
|
||||
|
||||
const allServiceViews = [
|
||||
...groupViewsRaw.flatMap((g) => g.services),
|
||||
...ungroupedRaw,
|
||||
const allIds = [
|
||||
...[...servicesByGroup.values()].flat().map((s) => s.id),
|
||||
...ungroupedServices.map((s) => s.id),
|
||||
];
|
||||
const allServiceViews = await buildViews(db, allIds);
|
||||
const viewsById = new Map(allServiceViews.map((v) => [v.id, v]));
|
||||
|
||||
const groupViewsRaw = groups.map((group) => ({
|
||||
...group,
|
||||
services: (servicesByGroup.get(group.id) ?? [])
|
||||
.map((s) => viewsById.get(s.id))
|
||||
.filter((v): v is ServiceView => v !== undefined),
|
||||
}));
|
||||
|
||||
const withHealth = attachServiceHealth(db, allServiceViews);
|
||||
const healthById = new Map(withHealth.map((v) => [v.id, v]));
|
||||
|
||||
@@ -632,16 +678,21 @@ export async function listGroupViews(db: Db): Promise<ServiceGroupsResponse> {
|
||||
};
|
||||
});
|
||||
|
||||
const ungrouped = ungroupedRaw.map(
|
||||
(s) =>
|
||||
healthById.get(s.id) ?? {
|
||||
...s,
|
||||
health_status: "unknown" as const,
|
||||
health_latency_ms: null,
|
||||
ip_health: [],
|
||||
ip_enabled: {},
|
||||
},
|
||||
);
|
||||
const ungrouped = ungroupedServices.map((s) => {
|
||||
const view = viewsById.get(s.id);
|
||||
return (
|
||||
healthById.get(s.id) ??
|
||||
(view
|
||||
? {
|
||||
...view,
|
||||
health_status: "unknown" as const,
|
||||
health_latency_ms: null,
|
||||
ip_health: [],
|
||||
ip_enabled: {},
|
||||
}
|
||||
: view!)
|
||||
);
|
||||
});
|
||||
|
||||
return { groups: groupViews, ungrouped };
|
||||
}
|
||||
@@ -661,6 +712,7 @@ async function syncBindingDns(
|
||||
hostname: string,
|
||||
desiredIps: string[],
|
||||
cnameTarget: string | null,
|
||||
listingCache?: ZoneDnsListingCache,
|
||||
): Promise<void> {
|
||||
const domain = repos.getDomain(db, domainId);
|
||||
const zoneName = domain.zone_name;
|
||||
@@ -674,6 +726,8 @@ async function syncBindingDns(
|
||||
zoneName,
|
||||
hostname,
|
||||
"CNAME",
|
||||
undefined,
|
||||
listingCache,
|
||||
);
|
||||
if (existingCname) {
|
||||
effectiveCname = existingCname.content;
|
||||
@@ -690,6 +744,7 @@ async function syncBindingDns(
|
||||
domainId,
|
||||
hostname,
|
||||
effectiveCname,
|
||||
listingCache,
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -704,6 +759,7 @@ async function syncBindingDns(
|
||||
hostname,
|
||||
desiredIps,
|
||||
ttlForBinding(binding.lb_mode, configuredIps),
|
||||
listingCache,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -714,6 +770,7 @@ async function syncBindingCnameDns(
|
||||
domainId: number,
|
||||
hostname: string,
|
||||
cnameTarget: string,
|
||||
listingCache?: ZoneDnsListingCache,
|
||||
): Promise<void> {
|
||||
const domain = repos.getDomain(db, domainId);
|
||||
const zoneName = domain.zone_name;
|
||||
@@ -755,6 +812,7 @@ async function syncBindingCnameDns(
|
||||
hostname,
|
||||
"CNAME",
|
||||
normalized,
|
||||
listingCache,
|
||||
);
|
||||
if (adopted) {
|
||||
repos.linkBindingRecord(db, bindingId, adopted.id);
|
||||
@@ -792,6 +850,7 @@ async function syncBindingADns(
|
||||
hostname: string,
|
||||
desiredIps: string[],
|
||||
ttl: number,
|
||||
listingCache?: ZoneDnsListingCache,
|
||||
): Promise<void> {
|
||||
const domain = repos.getDomain(db, domainId);
|
||||
const zoneName = domain.zone_name;
|
||||
@@ -849,6 +908,7 @@ async function syncBindingADns(
|
||||
hostname,
|
||||
"A",
|
||||
ip,
|
||||
listingCache,
|
||||
);
|
||||
if (adopted) {
|
||||
repos.linkBindingRecord(db, bindingId, adopted.id);
|
||||
@@ -991,6 +1051,9 @@ function findLocalDnsRecord(
|
||||
);
|
||||
}
|
||||
|
||||
/** Per-reconcile DNS listing cache: one zone = one CF API listing per pass. */
|
||||
export type ZoneDnsListingCache = Map<string, CfDnsRecord[]>;
|
||||
|
||||
async function findOrImportDnsRecord(
|
||||
db: Db,
|
||||
cf: CloudflareClient,
|
||||
@@ -999,6 +1062,7 @@ async function findOrImportDnsRecord(
|
||||
hostname: string,
|
||||
recordType: "A" | "CNAME",
|
||||
content?: string,
|
||||
listingCache?: ZoneDnsListingCache,
|
||||
): Promise<DnsRecord | null> {
|
||||
const local = findLocalDnsRecord(
|
||||
db,
|
||||
@@ -1011,7 +1075,7 @@ async function findOrImportDnsRecord(
|
||||
if (local) return local;
|
||||
|
||||
const domain = repos.getDomain(db, domainId);
|
||||
const remote = await cf.listDnsRecords(domain.cf_zone_id);
|
||||
const remote = await cf.listDnsRecords(domain.cf_zone_id, listingCache);
|
||||
for (const cfRec of remote) {
|
||||
if (cfRec.type.toUpperCase() !== recordType) continue;
|
||||
if (content != null) {
|
||||
@@ -1052,6 +1116,7 @@ async function findOrImportDnsARecord(
|
||||
zoneName: string,
|
||||
hostname: string,
|
||||
content: string,
|
||||
listingCache?: ZoneDnsListingCache,
|
||||
): Promise<DnsRecord | null> {
|
||||
return findOrImportDnsRecord(
|
||||
db,
|
||||
@@ -1061,6 +1126,7 @@ async function findOrImportDnsARecord(
|
||||
hostname,
|
||||
"A",
|
||||
content,
|
||||
listingCache,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1084,6 +1150,7 @@ async function syncServiceBindingsToDns(
|
||||
throw AppError.validation("добавьте IP-адреса в пул сервиса");
|
||||
}
|
||||
|
||||
const listingCache: ZoneDnsListingCache = new Map();
|
||||
for (const binding of bindings) {
|
||||
const cnameTarget = binding.cname_target?.trim() || null;
|
||||
if (cnameTarget) {
|
||||
@@ -1095,6 +1162,7 @@ async function syncServiceBindingsToDns(
|
||||
binding.hostname,
|
||||
[],
|
||||
cnameTarget,
|
||||
listingCache,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
@@ -1117,6 +1185,7 @@ async function syncServiceBindingsToDns(
|
||||
binding.hostname,
|
||||
desiredIps,
|
||||
null,
|
||||
listingCache,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1148,6 +1217,7 @@ async function syncGroupDomainDnsRecords(
|
||||
hostname: string,
|
||||
desiredIps: string[],
|
||||
ttl: number = AUTO_DNS_TTL,
|
||||
listingCache?: ZoneDnsListingCache,
|
||||
): Promise<void> {
|
||||
const domain = repos.getDomain(db, domainId);
|
||||
const zoneName = domain.zone_name;
|
||||
@@ -1185,6 +1255,7 @@ async function syncGroupDomainDnsRecords(
|
||||
zoneName,
|
||||
hostname,
|
||||
ip,
|
||||
listingCache,
|
||||
);
|
||||
if (adopted) {
|
||||
repos.linkGroupDnsRecord(db, groupId, adopted.id);
|
||||
@@ -1245,6 +1316,7 @@ async function syncGroupDomainDns(
|
||||
db: Db,
|
||||
cf: CloudflareClient,
|
||||
groupId: number,
|
||||
listingCache?: ZoneDnsListingCache,
|
||||
): Promise<void> {
|
||||
const group = repos.getServiceGroup(db, groupId);
|
||||
if (!group.enabled) {
|
||||
@@ -1267,6 +1339,7 @@ async function syncGroupDomainDns(
|
||||
hostname,
|
||||
desiredIps,
|
||||
ttlForBinding(group.lb_mode, fallbackIps),
|
||||
listingCache,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1792,7 +1865,10 @@ export async function reconcileWeightedDns(
|
||||
db: Db,
|
||||
cf: CloudflareClient,
|
||||
): Promise<number> {
|
||||
// Cheap predicate first: skip the full binding walk when nothing is weighted.
|
||||
if (!repos.hasWeightedBindings(db)) return 0;
|
||||
let n = 0;
|
||||
const listingCache: ZoneDnsListingCache = new Map();
|
||||
for (const binding of repos.listAllBindings(db)) {
|
||||
if (binding.lb_mode !== "weighted") continue;
|
||||
if (binding.cname_target?.trim()) continue;
|
||||
@@ -1818,6 +1894,7 @@ export async function reconcileWeightedDns(
|
||||
latest.hostname,
|
||||
desiredIps,
|
||||
null,
|
||||
listingCache,
|
||||
);
|
||||
n += 1;
|
||||
});
|
||||
@@ -1829,7 +1906,7 @@ export async function reconcileWeightedDns(
|
||||
if (group.lb_mode !== "weighted") continue;
|
||||
if (!group.enabled || !group.domain?.trim()) continue;
|
||||
try {
|
||||
await syncGroupDomainDns(db, cf, group.id);
|
||||
await syncGroupDomainDns(db, cf, group.id, listingCache);
|
||||
n += 1;
|
||||
} catch {
|
||||
continue;
|
||||
|
||||
Reference in New Issue
Block a user