feat: Implement load balancing and health check features for service groups, including DNS-based load balancing modes and health check configurations, enhancing service reliability and performance monitoring
Build, Test, and Push CFDM Docker Image / test (push) Successful in 4m0s
Build, Test, and Push CFDM Docker Image / build-and-push (push) Successful in 2m13s
Build, Test, and Push CFDM Docker Image / update-wiki (push) Failing after 6s
Build, Test, and Push CFDM Docker Image / create-release (push) Has been skipped

This commit is contained in:
Denozordec
2026-06-25 18:24:00 +07:00
parent e5dc483d43
commit 6a1498bf80
35 changed files with 5312 additions and 281 deletions
+44
View File
@@ -23,7 +23,10 @@ import { dnsRoutes } from "./routes/dns.js";
import { subdomainRoutes } from "./routes/subdomains.js";
import { certificateRoutes } from "./routes/certificates.js";
import { syncRoutes } from "./routes/sync.js";
import { healthCheckRoutes } from "./routes/health-check.js";
import * as certificateService from "./services/certificate-service.js";
import * as healthCheckService from "./services/health-check-service.js";
import * as serviceConfigService from "./services/service-config-service.js";
import { AsyncTask, CronJob } from "toad-scheduler";
export interface BuildAppOptions {
@@ -68,6 +71,7 @@ export async function buildApp(opts: BuildAppOptions = {}) {
await protectedApi.register(subdomainRoutes);
await protectedApi.register(certificateRoutes);
await protectedApi.register(syncRoutes);
await protectedApi.register(healthCheckRoutes);
},
{ prefix: "/api/v1" },
);
@@ -105,6 +109,46 @@ export async function buildApp(opts: BuildAppOptions = {}) {
{ preventOverrun: true },
),
);
const healthTask = new AsyncTask(
"health-check",
async () => {
const n = await healthCheckService.runAllChecks(app.db, {
thresholds: {
degradedFailures: config.healthDegradedFailures,
downFailures: config.healthDownFailures,
latencyWarnMs: config.healthLatencyWarnMs,
},
onStatusChange: async (target, _prev, _next) => {
try {
await serviceConfigService.reconcileDnsForTarget(
app.db,
app.cf,
target.scope,
target.ref_id,
);
} catch (err) {
app.log.warn(
{ err, scope: target.scope, refId: target.ref_id },
"health-check reconcile failed",
);
}
},
});
app.log.info({ checked: n }, "health check completed");
},
(err) => {
app.log.warn({ err }, "health check failed");
},
);
app.scheduler.addCronJob(
new CronJob(
{ cronExpression: config.healthCheckCron },
healthTask,
{ preventOverrun: true },
),
);
}
return app;
+10
View File
@@ -10,6 +10,10 @@ export interface AppConfig {
serverPort: number;
staticDir: string | null;
certCheckCron: string;
healthCheckCron: string;
healthDegradedFailures: number;
healthDownFailures: number;
healthLatencyWarnMs: number;
logLevel: string;
}
@@ -27,6 +31,12 @@ export function loadConfig(): AppConfig {
? resolve(process.env.STATIC_DIR)
: null,
certCheckCron: process.env.CERT_CHECK_CRON ?? "0 0 */6 * * *",
healthCheckCron: process.env.HEALTH_CHECK_CRON ?? "*/30 * * * * *",
healthDegradedFailures:
Number(process.env.HEALTH_DEGRADED_FAILURES ?? "1") || 1,
healthDownFailures: Number(process.env.HEALTH_DOWN_FAILURES ?? "2") || 2,
healthLatencyWarnMs:
Number(process.env.HEALTH_LATENCY_WARN_MS ?? "1000") || 1000,
logLevel: process.env.LOG_LEVEL ?? "info",
};
}
+39
View File
@@ -0,0 +1,39 @@
import type { FastifyInstance } from "fastify";
import { healthStatusQuerySchema } from "@cfdm/shared";
import * as healthCheckService from "../services/health-check-service.js";
import * as serviceConfigService from "../services/service-config-service.js";
export async function healthCheckRoutes(app: FastifyInstance) {
app.get("/health-status", async (request) => {
const query = healthStatusQuerySchema.parse(request.query);
return healthCheckService.listStatus(
request.server.db,
query.scope,
query.ref_id,
);
});
app.post("/health-check/run", async (request) => {
const config = request.server.config;
const checked = await healthCheckService.runAllChecks(request.server.db, {
thresholds: {
degradedFailures: config.healthDegradedFailures,
downFailures: config.healthDownFailures,
latencyWarnMs: config.healthLatencyWarnMs,
},
onStatusChange: async (target, _prev, _next) => {
try {
await serviceConfigService.reconcileDnsForTarget(
request.server.db,
request.server.cf,
target.scope,
target.ref_id,
);
} catch {
// best-effort reconcile; ошибки логируются cron-задачей
}
},
});
return { checked };
});
}
+7 -5
View File
@@ -1,16 +1,18 @@
import type { FastifyInstance } from "fastify";
import { createServiceGroupSchema, toggleEnabledSchema } from "@cfdm/shared";
import {
createServiceGroupSchema,
toggleEnabledSchema,
updateServiceGroupSchema,
} from "@cfdm/shared";
import * as serviceConfig from "../services/service-config-service.js";
export async function serviceGroupRoutes(app: FastifyInstance) {
const bodySchema = createServiceGroupSchema;
app.get("/service-groups", async (request) => {
return serviceConfig.listGroupViews(request.server.db);
});
app.post("/service-groups", async (request) => {
const body = bodySchema.parse(request.body);
const body = createServiceGroupSchema.parse(request.body);
return serviceConfig.createGroup(
request.server.db,
request.server.cf,
@@ -20,7 +22,7 @@ export async function serviceGroupRoutes(app: FastifyInstance) {
app.patch("/service-groups/:id", async (request) => {
const { id } = request.params as { id: string };
const body = bodySchema.parse(request.body);
const body = updateServiceGroupSchema.parse(request.body);
return serviceConfig.updateGroup(
request.server.db,
request.server.cf,
+3 -2
View File
@@ -1,6 +1,6 @@
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { reorderServicesSchema } from "@cfdm/shared";
import { reorderServicesSchema, updateServiceConfigSchema } from "@cfdm/shared";
import { repos } from "@cfdm/db";
import * as serviceConfig from "../services/service-config-service.js";
@@ -49,11 +49,12 @@ export async function serviceRoutes(app: FastifyInstance) {
app.patch("/services/:id", async (request) => {
const { id } = request.params as { id: string };
const body = updateServiceConfigSchema.parse(request.body);
return serviceConfig.updateConfig(
request.server.db,
request.server.cf,
Number(id),
request.body as serviceConfig.UpdateServiceConfigRequest,
body,
);
});
@@ -0,0 +1,203 @@
import { connect } from "node:net";
import type { Db } from "@cfdm/db";
import { repos } from "@cfdm/db";
import type { HealthCheckTarget, IpHealthState } from "@cfdm/shared";
import { AppError } from "../errors.js";
export interface HealthCheckThresholds {
degradedFailures: number;
downFailures: number;
latencyWarnMs: number;
}
export interface ProbeResult {
ok: boolean;
latencyMs: number;
error: string | null;
}
function tcpProbe(
ip: string,
port: number,
timeoutMs: number,
): Promise<ProbeResult> {
return new Promise((resolve) => {
const started = Date.now();
const socket = connect({ host: ip, port, timeout: timeoutMs });
let settled = false;
const finish = (result: ProbeResult) => {
if (settled) return;
settled = true;
socket.destroy();
resolve(result);
};
socket.on("connect", () =>
finish({
ok: true,
latencyMs: Date.now() - started,
error: null,
}),
);
socket.on("timeout", () =>
finish({
ok: false,
latencyMs: Date.now() - started,
error: "connection timeout",
}),
);
socket.on("error", (err) =>
finish({
ok: false,
latencyMs: Date.now() - started,
error: err.message,
}),
);
});
}
async function httpProbe(
ip: string,
target: HealthCheckTarget,
timeoutMs: number,
): Promise<ProbeResult> {
const started = Date.now();
const path = target.path?.trim() || "/";
const url = `http://${ip}${path.startsWith("/") ? path : `/${path}`}`;
const hostHeader = target.hostname || ip;
try {
const response = await fetch(url, {
method: "GET",
headers: { Host: hostHeader },
signal: AbortSignal.timeout(timeoutMs),
redirect: "manual",
});
const latency = Date.now() - started;
if (target.expected_status != null) {
if (response.status !== target.expected_status) {
return {
ok: false,
latencyMs: latency,
error: `expected ${target.expected_status}, got ${response.status}`,
};
}
return { ok: true, latencyMs: latency, error: null };
}
if (response.status >= 200 && response.status < 400) {
return { ok: true, latencyMs: latency, error: null };
}
return {
ok: false,
latencyMs: latency,
error: `unexpected status ${response.status}`,
};
} catch (err) {
return {
ok: false,
latencyMs: Date.now() - started,
error: err instanceof Error ? err.message : String(err),
};
}
}
export async function probeTarget(
target: HealthCheckTarget,
): Promise<ProbeResult> {
const port = target.port ?? (target.type === "http" ? 80 : 80);
const timeoutMs = target.timeout_ms || 3000;
if (target.type === "http") {
return httpProbe(target.ip, target, timeoutMs);
}
return tcpProbe(target.ip, port, timeoutMs);
}
function deriveState(
ok: boolean,
latencyMs: number,
prev: { consecutive_failures: number; status: string } | null,
thresholds: HealthCheckThresholds,
): { state: IpHealthState; failures: number } {
if (!ok) {
const failures = (prev?.consecutive_failures ?? 0) + 1;
if (failures >= thresholds.downFailures) {
return { state: "down", failures };
}
if (failures >= thresholds.degradedFailures) {
return { state: "degraded", failures };
}
return { state: "degraded", failures };
}
if (latencyMs > thresholds.latencyWarnMs) {
return { state: "degraded", failures: 0 };
}
return { state: "up", failures: 0 };
}
export interface RunAllChecksOptions {
thresholds: HealthCheckThresholds;
onStatusChange?: (
target: HealthCheckTarget,
prevState: IpHealthState | null,
nextState: IpHealthState,
) => void;
}
export async function runAllChecks(
db: Db,
options: RunAllChecksOptions,
): Promise<number> {
const targets = repos.listHealthCheckTargets(db);
for (const target of targets) {
const prev = repos.getIpHealthStatusRow(
db,
target.scope,
target.ref_id,
target.ip,
);
const result = await probeTarget(target);
const { state, failures } = deriveState(
result.ok,
result.latencyMs,
prev
? {
consecutive_failures: prev.consecutive_failures,
status: prev.status,
}
: null,
options.thresholds,
);
const prevState: IpHealthState | null = prev
? (prev.status as IpHealthState)
: null;
repos.upsertIpHealthStatus(
db,
target.scope,
target.ref_id,
target.ip,
state,
result.latencyMs,
failures,
result.error,
);
if (prevState !== state) {
options.onStatusChange?.(target, prevState, state);
}
}
return targets.length;
}
export function listStatus(
db: Db,
scope: "binding" | "group",
refId: number,
) {
return repos.listIpHealthStatus(db, scope, refId);
}
export function requireValidScope(scope: string): "binding" | "group" {
if (scope !== "binding" && scope !== "group") {
throw AppError.validation(`invalid scope: ${scope}`);
}
return scope;
}
+317 -10
View File
@@ -2,6 +2,10 @@ import type { Db } from "@cfdm/db";
import { repos } from "@cfdm/db";
import type {
DnsRecord,
HealthCheckScope,
HealthCheckType,
IpHealthState,
LbMode,
Service,
ServiceGroup,
ServiceGroupsResponse,
@@ -25,6 +29,16 @@ export interface ServiceDomainInput {
target_ips?: string[];
target_ip?: string;
target_cname?: string;
target_ip_weights?: Record<string, number>;
target_ip_priorities?: Record<string, number>;
lb_mode?: LbMode;
health_check_enabled?: boolean;
health_check_type?: HealthCheckType;
health_check_port?: number | null;
health_check_path?: string | null;
health_check_expected_status?: number | null;
health_check_interval_sec?: number;
health_check_timeout_ms?: number;
}
export interface ToggleRequest {
@@ -36,6 +50,29 @@ export interface ServiceGroupBody {
type?: string;
icon?: string | null;
domain?: string | null;
lb_mode?: LbMode;
health_check_enabled?: boolean;
health_check_type?: HealthCheckType;
health_check_port?: number | null;
health_check_path?: string | null;
health_check_expected_status?: number | null;
health_check_interval_sec?: number;
health_check_timeout_ms?: number;
}
export interface UpdateServiceGroupBody {
name?: string;
type?: string;
icon?: string | null;
domain?: string | null;
lb_mode?: LbMode;
health_check_enabled?: boolean;
health_check_type?: HealthCheckType;
health_check_port?: number | null;
health_check_path?: string | null;
health_check_expected_status?: number | null;
health_check_interval_sec?: number;
health_check_timeout_ms?: number;
}
export interface UpdateServiceConfigRequest {
@@ -43,6 +80,8 @@ export interface UpdateServiceConfigRequest {
slug?: string;
service_group_id?: number | null;
ips?: string[];
lb_weight?: number;
lb_priority?: number;
domains?: ServiceDomainInput[];
}
@@ -94,6 +133,133 @@ function aggregateSyncStatus(statuses: string[]): string | null {
return statuses[0] ?? null;
}
function isHealthy(state: IpHealthState): boolean {
return state === "up" || state === "unknown";
}
export interface LbTargetConfig {
lb_mode: LbMode;
health_check_enabled: boolean;
}
export interface LbIpRow {
ip: string;
weight: number;
priority: number;
health: IpHealthState;
}
export function selectActiveIpsByMode(
config: LbTargetConfig,
rows: LbIpRow[],
): string[] {
if (rows.length === 0) return [];
const healthy = rows.filter((r) => isHealthy(r.health));
const pool = healthy.length > 0 ? healthy : rows;
if (config.lb_mode === "failover") {
const sorted = [...pool].sort(
(a, b) => a.priority - b.priority || a.weight - b.weight,
);
const minPriority = sorted[0]!.priority;
const primaries = sorted.filter((r) => r.priority === minPriority);
if (healthy.length > 0) {
return primaries.map((r) => r.ip);
}
return [sorted[0]!.ip];
}
if (config.lb_mode === "weighted") {
// Cloudflare не допускает дублирования A-записей с одинаковым name+content,
// поэтому weighted на уровне DNS реализован как RR по одному A на IP.
// Веса сохраняются в БД и используются для приоритизации/отображения;
// точное weighted-распределение требует CF Load Balancer (см. README).
return pool.map((r) => r.ip);
}
return pool.map((r) => r.ip);
}
function getBindingLbState(
db: Db,
bindingId: number,
): { config: LbTargetConfig; rows: LbIpRow[] } {
const binding = repos.getBinding(db, bindingId);
const ipMetas = repos.listBindingIpsWithMeta(db, bindingId);
const rows: LbIpRow[] = ipMetas.map((entry) => {
const status = repos.getIpHealthStatusRow(db, "binding", bindingId, entry.ip);
return {
ip: entry.ip,
weight: entry.weight,
priority: entry.priority,
health: status ? (status.status as IpHealthState) : "unknown",
};
});
return {
config: {
lb_mode: binding.lb_mode,
health_check_enabled: binding.health_check_enabled,
},
rows,
};
}
function getGroupLbState(
db: Db,
groupId: number,
): { config: LbTargetConfig; rows: LbIpRow[] } {
const group = repos.getServiceGroup(db, groupId);
const services = repos.listServicesByGroup(db, groupId);
const seen = new Map<string, LbIpRow>();
for (const service of services) {
if (!service.enabled) continue;
const bindings = repos.listBindingsByService(db, service.id);
for (const binding of bindings) {
const ipMetas = repos.listBindingIpsWithMeta(db, binding.id);
for (const entry of ipMetas) {
const status = repos.getIpHealthStatusRow(db, "group", groupId, entry.ip);
const existing = seen.get(entry.ip);
const weight = entry.weight * service.lb_weight;
const priority = Math.min(entry.priority, service.lb_priority);
if (!existing) {
seen.set(entry.ip, {
ip: entry.ip,
weight,
priority,
health: status ? (status.status as IpHealthState) : "unknown",
});
} else {
existing.weight += weight;
existing.priority = Math.min(existing.priority, priority);
if (isHealthy(existing.health) && status && !isHealthy(status.status as IpHealthState)) {
existing.health = status.status as IpHealthState;
}
}
}
}
}
return {
config: {
lb_mode: group.lb_mode,
health_check_enabled: group.health_check_enabled,
},
rows: [...seen.values()],
};
}
function computeActiveIps(
db: Db,
scope: HealthCheckScope,
refId: number,
): string[] {
const state =
scope === "binding"
? getBindingLbState(db, refId)
: getGroupLbState(db, refId);
return selectActiveIpsByMode(state.config, state.rows);
}
async function collectKnownZones(
db: Db,
cf: CloudflareClient,
@@ -117,12 +283,25 @@ async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
const domainViews = bindings.map((binding) => {
const records = repos.listRecordsForBinding(db, binding.id);
const statuses = records.map((r) => r.sync_status);
const targetIps = repos.listBindingIps(db, binding.id);
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;
}
return {
binding_id: binding.id,
domain_id: binding.domain_id,
@@ -131,7 +310,17 @@ async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
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,
sync_status: aggregateSyncStatus(statuses),
};
});
@@ -140,10 +329,12 @@ async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
id: service.id,
name: service.name,
slug: service.slug,
service_group_id: service.service_group_id,
subdomain: service.subdomain,
enabled: service.enabled,
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,
@@ -637,13 +828,21 @@ async function syncServiceBindingsToDns(
continue;
}
const targetIps = repos.listBindingIps(db, binding.id);
let targetIps = repos.listBindingIps(db, binding.id);
if (targetIps.length === 0) {
throw AppError.validation(
`укажите IP или CNAME для ${fqdnToDisplay(binding.hostname, binding.zone_name)}`,
);
}
validateTargetIpsInPool(targetIps, ips);
if (binding.health_check_enabled) {
const activeIps = computeActiveIps(db, "binding", binding.id);
if (activeIps.length > 0) {
targetIps = activeIps;
}
}
await syncBindingDns(
db,
cf,
@@ -777,7 +976,9 @@ async function syncGroupDomainDns(
const knownZones = await collectKnownZones(db, cf);
const { zoneName, hostname } = parseFqdn(domainValue, knownZones);
const domainId = await resolveDomainId(db, cf, zoneName);
const desiredIps = await collectGroupDnsIps(db, groupId);
const desiredIps = group.health_check_enabled
? computeActiveIps(db, "group", groupId)
: await collectGroupDnsIps(db, groupId);
await syncGroupDomainDnsRecords(
db,
cf,
@@ -880,6 +1081,16 @@ export async function updateConfig(
repos.setServiceGroup(db, id, req.service_group_id);
}
if (req.lb_weight !== undefined || req.lb_priority !== undefined) {
const existing = repos.getService(db, id);
repos.setServiceLb(
db,
id,
req.lb_weight ?? existing.lb_weight,
req.lb_priority ?? existing.lb_priority,
);
}
const ipsUpdated = req.ips !== undefined;
const knownZones = await collectKnownZones(db, cf);
@@ -913,17 +1124,55 @@ export async function updateConfig(
repos.insertBinding(db, domainId, id, hostname, null);
keptBindingIds.push(binding.id);
repos.replaceBindingIps(db, binding.id, targetCname ? [] : targetIps);
const targetIpWeights = input.target_ip_weights ?? {};
const targetIpPriorities = input.target_ip_priorities ?? {};
const bindingIpEntries = (targetCname ? [] : targetIps).map((ip) => ({
ip,
weight: targetIpWeights[ip] ?? 1,
priority: targetIpPriorities[ip] ?? 1,
}));
repos.replaceBindingIpsWithMeta(db, binding.id, bindingIpEntries);
repos.setBindingCnameTarget(db, binding.id, targetCname);
if (
input.lb_mode !== undefined ||
input.health_check_enabled !== undefined ||
input.health_check_type !== undefined ||
input.health_check_port !== undefined ||
input.health_check_path !== undefined ||
input.health_check_expected_status !== undefined ||
input.health_check_interval_sec !== undefined ||
input.health_check_timeout_ms !== undefined
) {
repos.updateBindingLbConfig(db, binding.id, {
lb_mode: input.lb_mode,
health_check_enabled: input.health_check_enabled,
health_check_type: input.health_check_type,
health_check_port: input.health_check_port,
health_check_path: input.health_check_path,
health_check_expected_status: input.health_check_expected_status,
health_check_interval_sec: input.health_check_interval_sec,
health_check_timeout_ms: input.health_check_timeout_ms,
});
}
if (pushDns) {
let effectiveIps = targetIps;
const refreshedBinding = repos.getBinding(db, binding.id);
if (refreshedBinding.health_check_enabled) {
const activeIps = computeActiveIps(db, "binding", binding.id);
if (activeIps.length > 0) {
effectiveIps = activeIps;
}
}
await syncBindingDns(
db,
cf,
binding.id,
domainId,
hostname,
targetIps,
effectiveIps,
targetCname,
);
}
@@ -986,6 +1235,16 @@ export async function createGroup(
groupType,
body.icon ?? null,
domain,
{
lb_mode: body.lb_mode,
health_check_enabled: body.health_check_enabled,
health_check_type: body.health_check_type,
health_check_port: body.health_check_port,
health_check_path: body.health_check_path,
health_check_expected_status: body.health_check_expected_status,
health_check_interval_sec: body.health_check_interval_sec,
health_check_timeout_ms: body.health_check_timeout_ms,
},
);
}
@@ -993,10 +1252,11 @@ export async function updateGroup(
db: Db,
cf: CloudflareClient,
id: number,
body: ServiceGroupBody,
body: UpdateServiceGroupBody,
): Promise<ServiceGroup> {
const groupType = body.type?.trim() || "custom";
const previous = repos.getServiceGroup(db, id);
const name = body.name ?? previous.name;
const oldDomain = previous.domain?.trim();
if (oldDomain) {
await cleanupStaleGroupFqdnBindings(db, cf, id, oldDomain);
@@ -1006,10 +1266,20 @@ export async function updateGroup(
let group = repos.updateServiceGroup(
db,
id,
body.name,
name,
groupType,
body.icon ?? null,
domain,
{
lb_mode: body.lb_mode,
health_check_enabled: body.health_check_enabled,
health_check_type: body.health_check_type,
health_check_port: body.health_check_port,
health_check_path: body.health_check_path,
health_check_expected_status: body.health_check_expected_status,
health_check_interval_sec: body.health_check_interval_sec,
health_check_timeout_ms: body.health_check_timeout_ms,
},
);
if (!domain && group.enabled) {
repos.setServiceGroupEnabled(db, id, false);
@@ -1090,3 +1360,40 @@ export function reorderServices(
}
repos.reorderServices(db, groupId, serviceIds);
}
export async function reconcileDnsForTarget(
db: Db,
cf: CloudflareClient,
scope: HealthCheckScope,
refId: number,
): Promise<void> {
if (scope === "binding") {
const binding = repos.getBinding(db, refId);
if (!binding.health_check_enabled) return;
const service = repos.getService(db, binding.service_id);
if (!shouldPushDns(db, service)) return;
const cnameTarget = binding.cname_target?.trim() || null;
if (cnameTarget) return;
const ips = repos.listServiceIps(db, service.id);
const targetIps = repos.listBindingIps(db, binding.id);
validateTargetIpsInPool(targetIps, ips);
const activeIps = computeActiveIps(db, "binding", refId);
const desiredIps = activeIps.length > 0 ? activeIps : targetIps;
await syncBindingDns(
db,
cf,
binding.id,
binding.domain_id,
binding.hostname,
desiredIps,
null,
);
return;
}
const group = repos.getServiceGroup(db, refId);
if (!group.enabled || !group.domain?.trim() || !group.health_check_enabled) {
return;
}
await syncGroupDomainDns(db, cf, refId);
}