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
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:
@@ -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,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",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user