Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b078fa0a03 | ||
|
|
75575a3243 | ||
|
|
5e2c301442 | ||
|
|
56cddedefe | ||
|
|
df5d5ef4ab | ||
|
|
de3dbe8521 | ||
|
|
7eb195c5d7 | ||
|
|
7f06466058 | ||
|
|
0d567379fa | ||
|
|
a228febc27 | ||
|
|
5cf39880f8 | ||
|
|
f1443f1db5 | ||
|
|
a9a84fabac | ||
|
|
2b150fa3a6 | ||
|
|
3d0ea33baf | ||
|
|
8a13888db7 |
@@ -38,6 +38,10 @@ import {
|
|||||||
healthEngineFallbacksFromConfig,
|
healthEngineFallbacksFromConfig,
|
||||||
scheduleHealthCheckJob,
|
scheduleHealthCheckJob,
|
||||||
} from "./services/health-check-scheduler.js";
|
} from "./services/health-check-scheduler.js";
|
||||||
|
import {
|
||||||
|
createWeightedDnsTask,
|
||||||
|
scheduleWeightedDnsJob,
|
||||||
|
} from "./services/weighted-dns-scheduler.js";
|
||||||
import { fireEnsureHealthWorker } from "./services/health/health-worker-deploy.js";
|
import { fireEnsureHealthWorker } from "./services/health/health-worker-deploy.js";
|
||||||
import { AsyncTask, CronJob } from "toad-scheduler";
|
import { AsyncTask, CronJob } from "toad-scheduler";
|
||||||
|
|
||||||
@@ -133,6 +137,7 @@ export async function buildApp(opts: BuildAppOptions = {}) {
|
|||||||
app.decorate("reloadHealthCheckJob", () => {
|
app.decorate("reloadHealthCheckJob", () => {
|
||||||
scheduleHealthCheckJob(app, config, healthTask);
|
scheduleHealthCheckJob(app, config, healthTask);
|
||||||
});
|
});
|
||||||
|
scheduleWeightedDnsJob(app, createWeightedDnsTask(app));
|
||||||
if (config.cloudflareApiToken) {
|
if (config.cloudflareApiToken) {
|
||||||
fireEnsureHealthWorker(
|
fireEnsureHealthWorker(
|
||||||
app.db,
|
app.db,
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
import type { Db } from "@cfdm/db";
|
import type { Db } from "@cfdm/db";
|
||||||
import { repos, type DnsListFilter } from "@cfdm/db";
|
import { repos, type DnsListFilter } from "@cfdm/db";
|
||||||
import type { CreateDnsRecordPayload, DnsRecord, PatchDnsRecordPayload } from "@cfdm/shared";
|
import type {
|
||||||
|
CfDnsRecord,
|
||||||
|
CreateDnsRecordPayload,
|
||||||
|
DnsRecord,
|
||||||
|
PatchDnsRecordPayload,
|
||||||
|
} from "@cfdm/shared";
|
||||||
import {
|
import {
|
||||||
SYNC_CONFLICT,
|
SYNC_CONFLICT,
|
||||||
SYNC_ERROR,
|
SYNC_ERROR,
|
||||||
SYNC_PENDING_PUSH,
|
SYNC_PENDING_PUSH,
|
||||||
SYNC_SYNCED,
|
SYNC_SYNCED,
|
||||||
|
dnsRecordNamesMatch,
|
||||||
normalizeDnsRecordName,
|
normalizeDnsRecordName,
|
||||||
} from "@cfdm/shared";
|
} from "@cfdm/shared";
|
||||||
import type { CloudflareClient } from "../lib/cf-client.js";
|
import type { CloudflareClient } from "../lib/cf-client.js";
|
||||||
@@ -64,6 +70,86 @@ function toCfPayload(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isMissingCfDnsRecord(error: unknown): boolean {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return /record does not exist|81044/i.test(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dnsContentMatches(
|
||||||
|
recordType: string,
|
||||||
|
left: string,
|
||||||
|
right: string,
|
||||||
|
): boolean {
|
||||||
|
if (recordType.toUpperCase() === "CNAME") {
|
||||||
|
return (
|
||||||
|
left.trim().replace(/\.+$/, "").toLowerCase() ===
|
||||||
|
right.trim().replace(/\.+$/, "").toLowerCase()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return left === right;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve live Cloudflare record by name + type + content (IP / CNAME target). */
|
||||||
|
function findRemoteByIdentity(
|
||||||
|
remote: readonly CfDnsRecord[],
|
||||||
|
zoneName: string,
|
||||||
|
recordType: string,
|
||||||
|
name: string,
|
||||||
|
content: string,
|
||||||
|
): CfDnsRecord | undefined {
|
||||||
|
const type = recordType.toUpperCase();
|
||||||
|
return remote.find(
|
||||||
|
(record) =>
|
||||||
|
Boolean(record.id) &&
|
||||||
|
(record.type ?? "").toUpperCase() === type &&
|
||||||
|
dnsRecordNamesMatch(record.name, name, zoneName) &&
|
||||||
|
dnsContentMatches(type, record.content, content),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function findRemoteByCfId(
|
||||||
|
remote: readonly CfDnsRecord[],
|
||||||
|
cfRecordId: string | null | undefined,
|
||||||
|
): CfDnsRecord | undefined {
|
||||||
|
if (!cfRecordId) return undefined;
|
||||||
|
return remote.find((record) => record.id === cfRecordId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markSynced(
|
||||||
|
db: Db,
|
||||||
|
domainId: number,
|
||||||
|
record: DnsRecord,
|
||||||
|
cfRec: {
|
||||||
|
id?: string | null;
|
||||||
|
type?: string;
|
||||||
|
name: string;
|
||||||
|
content: string;
|
||||||
|
ttl: number;
|
||||||
|
proxied?: boolean | null;
|
||||||
|
priority?: number | null;
|
||||||
|
},
|
||||||
|
): Promise<DnsRecord> {
|
||||||
|
repos.updateDnsFields(
|
||||||
|
db,
|
||||||
|
record.id,
|
||||||
|
cfRec.type ?? record.record_type,
|
||||||
|
cfRec.name,
|
||||||
|
cfRec.content,
|
||||||
|
cfRec.ttl,
|
||||||
|
cfRec.proxied ?? false,
|
||||||
|
cfRec.priority ?? null,
|
||||||
|
SYNC_SYNCED,
|
||||||
|
cfRec.id ?? null,
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
return repos.getDnsRecord(db, domainId, record.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Push local desired state to Cloudflare.
|
||||||
|
* Identity is name + type + content; cf_record_id is only a cache hint
|
||||||
|
* (records may be deleted/recreated outside CFDM).
|
||||||
|
*/
|
||||||
async function pushRecord(
|
async function pushRecord(
|
||||||
db: Db,
|
db: Db,
|
||||||
cf: CloudflareClient,
|
cf: CloudflareClient,
|
||||||
@@ -71,6 +157,7 @@ async function pushRecord(
|
|||||||
cfZoneId: string,
|
cfZoneId: string,
|
||||||
record: DnsRecord,
|
record: DnsRecord,
|
||||||
): Promise<DnsRecord> {
|
): Promise<DnsRecord> {
|
||||||
|
const domain = repos.getDomain(db, domainId);
|
||||||
const payload = toCfPayload(
|
const payload = toCfPayload(
|
||||||
record.record_type,
|
record.record_type,
|
||||||
record.name,
|
record.name,
|
||||||
@@ -81,25 +168,38 @@ async function pushRecord(
|
|||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const cfRec = record.cf_record_id
|
const remote = await cf.listDnsRecords(cfZoneId);
|
||||||
? await cf.updateDnsRecord(cfZoneId, record.cf_record_id, payload)
|
const byIdentity = findRemoteByIdentity(
|
||||||
: await cf.createDnsRecord(cfZoneId, payload);
|
remote,
|
||||||
|
domain.zone_name,
|
||||||
repos.updateDnsFields(
|
record.record_type,
|
||||||
db,
|
record.name,
|
||||||
record.id,
|
record.content,
|
||||||
cfRec.type ?? record.record_type,
|
|
||||||
cfRec.name,
|
|
||||||
cfRec.content,
|
|
||||||
cfRec.ttl,
|
|
||||||
cfRec.proxied ?? false,
|
|
||||||
cfRec.priority ?? null,
|
|
||||||
SYNC_SYNCED,
|
|
||||||
cfRec.id ?? null,
|
|
||||||
null,
|
|
||||||
);
|
);
|
||||||
return repos.getDnsRecord(db, domainId, record.id);
|
const byCachedId = findRemoteByCfId(remote, record.cf_record_id);
|
||||||
|
const targetId = byIdentity?.id ?? byCachedId?.id ?? null;
|
||||||
|
|
||||||
|
const cfRec = targetId
|
||||||
|
? await cf.updateDnsRecord(cfZoneId, targetId, payload)
|
||||||
|
: await cf.createDnsRecord(cfZoneId, payload);
|
||||||
|
return markSynced(db, domainId, record, cfRec);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
// Race: cached id vanished mid-flight — recreate by identity.
|
||||||
|
if (isMissingCfDnsRecord(e)) {
|
||||||
|
try {
|
||||||
|
const created = await cf.createDnsRecord(cfZoneId, payload);
|
||||||
|
return markSynced(db, domainId, record, created);
|
||||||
|
} catch (createErr) {
|
||||||
|
repos.setDnsSyncStatus(
|
||||||
|
db,
|
||||||
|
record.id,
|
||||||
|
SYNC_ERROR,
|
||||||
|
null,
|
||||||
|
createErr instanceof Error ? createErr.message : String(createErr),
|
||||||
|
);
|
||||||
|
throw createErr;
|
||||||
|
}
|
||||||
|
}
|
||||||
repos.setDnsSyncStatus(
|
repos.setDnsSyncStatus(
|
||||||
db,
|
db,
|
||||||
record.id,
|
record.id,
|
||||||
@@ -188,15 +288,23 @@ export async function patchContent(
|
|||||||
): Promise<DnsRecord> {
|
): Promise<DnsRecord> {
|
||||||
const domain = repos.getDomain(db, domainId);
|
const domain = repos.getDomain(db, domainId);
|
||||||
const existing = repos.getDnsRecord(db, domainId, recordId);
|
const existing = repos.getDnsRecord(db, domainId, recordId);
|
||||||
if (!existing.cf_record_id) {
|
|
||||||
throw AppError.dnsUpdateFailed("у DNS-записи нет идентификатора Cloudflare");
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const cfRec = await cf.patchDnsRecord(
|
const remote = await cf.listDnsRecords(domain.cf_zone_id);
|
||||||
domain.cf_zone_id,
|
const byIdentity = findRemoteByIdentity(
|
||||||
existing.cf_record_id,
|
remote,
|
||||||
payload,
|
domain.zone_name,
|
||||||
|
existing.record_type,
|
||||||
|
existing.name,
|
||||||
|
existing.content,
|
||||||
);
|
);
|
||||||
|
const byCachedId = findRemoteByCfId(remote, existing.cf_record_id);
|
||||||
|
const targetId = byIdentity?.id ?? byCachedId?.id ?? null;
|
||||||
|
if (!targetId) {
|
||||||
|
throw AppError.dnsUpdateFailed(
|
||||||
|
"DNS-запись не найдена в Cloudflare по имени и содержимому",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const cfRec = await cf.patchDnsRecord(domain.cf_zone_id, targetId, payload);
|
||||||
repos.updateDnsFields(
|
repos.updateDnsFields(
|
||||||
db,
|
db,
|
||||||
existing.id,
|
existing.id,
|
||||||
@@ -207,7 +315,7 @@ export async function patchContent(
|
|||||||
cfRec.proxied ?? existing.proxied,
|
cfRec.proxied ?? existing.proxied,
|
||||||
cfRec.priority ?? existing.priority,
|
cfRec.priority ?? existing.priority,
|
||||||
SYNC_SYNCED,
|
SYNC_SYNCED,
|
||||||
cfRec.id ?? existing.cf_record_id,
|
cfRec.id ?? targetId,
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
return repos.getDnsRecord(db, domainId, existing.id);
|
return repos.getDnsRecord(db, domainId, existing.id);
|
||||||
@@ -235,20 +343,41 @@ export async function deleteRecord(
|
|||||||
const record = repos.getDnsRecord(db, domainId, recordId);
|
const record = repos.getDnsRecord(db, domainId, recordId);
|
||||||
repos.markDnsPendingDelete(db, recordId);
|
repos.markDnsPendingDelete(db, recordId);
|
||||||
|
|
||||||
if (record.cf_record_id) {
|
let targetId: string | null = null;
|
||||||
|
try {
|
||||||
|
const remote = await cf.listDnsRecords(domain.cf_zone_id);
|
||||||
|
const byIdentity = findRemoteByIdentity(
|
||||||
|
remote,
|
||||||
|
domain.zone_name,
|
||||||
|
record.record_type,
|
||||||
|
record.name,
|
||||||
|
record.content,
|
||||||
|
);
|
||||||
|
const byCachedId = findRemoteByCfId(remote, record.cf_record_id);
|
||||||
|
targetId = byIdentity?.id ?? byCachedId?.id ?? null;
|
||||||
|
} catch {
|
||||||
|
// Zone list failed — fall back to cached id only.
|
||||||
|
targetId = record.cf_record_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetId) {
|
||||||
try {
|
try {
|
||||||
await cf.deleteDnsRecord(domain.cf_zone_id, record.cf_record_id);
|
await cf.deleteDnsRecord(domain.cf_zone_id, targetId);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
repos.setDnsSyncStatus(
|
// Already gone in Cloudflare (manual delete) — drop local row.
|
||||||
db,
|
if (!isMissingCfDnsRecord(e)) {
|
||||||
recordId,
|
repos.setDnsSyncStatus(
|
||||||
SYNC_ERROR,
|
db,
|
||||||
record.cf_record_id,
|
recordId,
|
||||||
e instanceof Error ? e.message : String(e),
|
SYNC_ERROR,
|
||||||
);
|
targetId,
|
||||||
throw e;
|
e instanceof Error ? e.message : String(e),
|
||||||
|
);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
repos.deleteDnsRecord(db, recordId);
|
repos.deleteDnsRecord(db, recordId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,24 +455,30 @@ export async function resolveConflict(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (req.source === "cloudflare") {
|
if (req.source === "cloudflare") {
|
||||||
if (record.cf_record_id) {
|
const remote = await cf.listDnsRecords(domain.cf_zone_id);
|
||||||
const remote = await cf.listDnsRecords(domain.cf_zone_id);
|
const byIdentity = findRemoteByIdentity(
|
||||||
const r = remote.find((x) => x.id === record.cf_record_id);
|
remote,
|
||||||
if (r) {
|
domain.zone_name,
|
||||||
repos.updateDnsFields(
|
record.record_type,
|
||||||
db,
|
record.name,
|
||||||
recordId,
|
record.content,
|
||||||
r.type,
|
);
|
||||||
r.name,
|
const byCachedId = findRemoteByCfId(remote, record.cf_record_id);
|
||||||
r.content,
|
const r = byIdentity ?? byCachedId;
|
||||||
r.ttl,
|
if (r) {
|
||||||
r.proxied ?? false,
|
repos.updateDnsFields(
|
||||||
r.priority ?? null,
|
db,
|
||||||
SYNC_SYNCED,
|
recordId,
|
||||||
r.id ?? null,
|
r.type,
|
||||||
null,
|
r.name,
|
||||||
);
|
r.content,
|
||||||
}
|
r.ttl,
|
||||||
|
r.proxied ?? false,
|
||||||
|
r.priority ?? null,
|
||||||
|
SYNC_SYNCED,
|
||||||
|
r.id ?? null,
|
||||||
|
null,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return repos.getDnsRecord(db, domainId, recordId);
|
return repos.getDnsRecord(db, domainId, recordId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -290,7 +290,7 @@ export interface RunAllChecksOptions {
|
|||||||
target: HealthCheckTarget,
|
target: HealthCheckTarget,
|
||||||
prevState: IpHealthState | null,
|
prevState: IpHealthState | null,
|
||||||
nextState: IpHealthState,
|
nextState: IpHealthState,
|
||||||
) => void;
|
) => void | Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function sleep(ms: number): Promise<void> {
|
function sleep(ms: number): Promise<void> {
|
||||||
@@ -326,7 +326,7 @@ function logSourceResult(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyAggregatedStatus(
|
async function applyAggregatedStatus(
|
||||||
db: Db,
|
db: Db,
|
||||||
target: HealthCheckTarget,
|
target: HealthCheckTarget,
|
||||||
sources: Array<{ provider: HealthCheckProvider; result: ProbeResult }>,
|
sources: Array<{ provider: HealthCheckProvider; result: ProbeResult }>,
|
||||||
@@ -397,7 +397,7 @@ function applyAggregatedStatus(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (prevState !== state) {
|
if (prevState !== state) {
|
||||||
options.onStatusChange?.(target, prevState, state);
|
await options.onStatusChange?.(target, prevState, state);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -513,7 +513,7 @@ export async function runAllChecks(
|
|||||||
for (const source of sources) {
|
for (const source of sources) {
|
||||||
logSourceResult(db, target, source.provider, source.result);
|
logSourceResult(db, target, source.provider, source.result);
|
||||||
}
|
}
|
||||||
applyAggregatedStatus(db, target, sources, options);
|
await applyAggregatedStatus(db, target, sources, options);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ import type {
|
|||||||
import { AppError } from "../errors.js";
|
import { AppError } from "../errors.js";
|
||||||
import { isValidIpv4 } from "../lib/validators.js";
|
import { isValidIpv4 } from "../lib/validators.js";
|
||||||
import { getView } from "./service-config-service.js";
|
import { getView } from "./service-config-service.js";
|
||||||
import { selectActiveIpsByMode } from "./routing/index.js";
|
import {
|
||||||
|
isSharedPool,
|
||||||
|
resolveDesiredAIps,
|
||||||
|
} from "./routing/index.js";
|
||||||
|
|
||||||
function assertAddress(address: string): void {
|
function assertAddress(address: string): void {
|
||||||
if (!isValidIpv4(address)) {
|
if (!isValidIpv4(address)) {
|
||||||
@@ -89,8 +92,12 @@ export async function getOverview(
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
const active = new Set<string>();
|
const active = new Set<string>();
|
||||||
|
const serviceIps = service.ips.filter(
|
||||||
|
(ip) => service.ip_enabled[ip] !== false,
|
||||||
|
);
|
||||||
for (const binding of bindings) {
|
for (const binding of bindings) {
|
||||||
const metas = repos.listBindingIpsWithMeta(db, binding.id);
|
const metas = repos.listBindingIpsWithMeta(db, binding.id);
|
||||||
|
const targetIps = metas.map((entry) => entry.ip);
|
||||||
const rows = metas.map((entry) => {
|
const rows = metas.map((entry) => {
|
||||||
const status = repos.getIpHealthStatusRow(db, "binding", binding.id, entry.ip);
|
const status = repos.getIpHealthStatusRow(db, "binding", binding.id, entry.ip);
|
||||||
return {
|
return {
|
||||||
@@ -100,12 +107,15 @@ export async function getOverview(
|
|||||||
health: status ? status.status : ("unknown" as const),
|
health: status ? status.status : ("unknown" as const),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
for (const ip of selectActiveIpsByMode(
|
for (const ip of resolveDesiredAIps(
|
||||||
{
|
{
|
||||||
lb_mode: binding.lb_mode,
|
lb_mode: binding.lb_mode,
|
||||||
health_check_enabled: binding.health_check_enabled,
|
health_check_enabled: binding.health_check_enabled,
|
||||||
},
|
},
|
||||||
rows,
|
rows,
|
||||||
|
targetIps,
|
||||||
|
Date.now(),
|
||||||
|
serviceIps,
|
||||||
)) {
|
)) {
|
||||||
active.add(ip);
|
active.add(ip);
|
||||||
}
|
}
|
||||||
@@ -134,10 +144,13 @@ export function opsSummary(db: Db) {
|
|||||||
if (binding.lb_mode !== "failover" || !binding.health_check_enabled) {
|
if (binding.lb_mode !== "failover" || !binding.health_check_enabled) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return binding.target_ips.some((ip) => {
|
return (
|
||||||
const row = repos.getIpHealthStatusRow(db, "binding", binding.id, ip);
|
isSharedPool(binding.target_ips) &&
|
||||||
return row?.status === "down";
|
binding.target_ips.some((ip) => {
|
||||||
});
|
const row = repos.getIpHealthStatusRow(db, "binding", binding.id, ip);
|
||||||
|
return row?.status === "down";
|
||||||
|
})
|
||||||
|
);
|
||||||
}).length;
|
}).length;
|
||||||
return {
|
return {
|
||||||
domains: domains.length,
|
domains: domains.length,
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import type { LbIpRow } from "./types.js";
|
import type { LbIpRow } from "./types.js";
|
||||||
import { isHealthy } from "./health.js";
|
import { isPoolMember } from "./health.js";
|
||||||
|
|
||||||
export function failoverDesired(rows: LbIpRow[]): string[] {
|
export function failoverDesired(rows: LbIpRow[]): string[] {
|
||||||
if (rows.length === 0) return [];
|
if (rows.length === 0) return [];
|
||||||
const healthy = rows.filter((r) => isHealthy(r.health));
|
const live = rows.filter((r) => isPoolMember(r.health));
|
||||||
const pool = healthy.length > 0 ? healthy : rows;
|
const pool = live.length > 0 ? live : rows;
|
||||||
const sorted = [...pool].sort(
|
const sorted = [...pool].sort(
|
||||||
(a, b) => a.priority - b.priority || a.weight - b.weight,
|
(a, b) => a.priority - b.priority || a.weight - b.weight,
|
||||||
);
|
);
|
||||||
const minPriority = sorted[0]!.priority;
|
const minPriority = sorted[0]!.priority;
|
||||||
const primaries = sorted.filter((r) => r.priority === minPriority);
|
const primaries = sorted.filter((r) => r.priority === minPriority);
|
||||||
if (healthy.length > 0) {
|
if (live.length > 0) {
|
||||||
return primaries.map((r) => r.ip);
|
return primaries.map((r) => r.ip);
|
||||||
}
|
}
|
||||||
return [sorted[0]!.ip];
|
return [sorted[0]!.ip];
|
||||||
|
|||||||
@@ -3,3 +3,12 @@ import type { IpHealthState, NodeHealthState } from "@cfdm/shared";
|
|||||||
export function isHealthy(state: IpHealthState | NodeHealthState | string): boolean {
|
export function isHealthy(state: IpHealthState | NodeHealthState | string): boolean {
|
||||||
return state === "up" || state === "healthy";
|
return state === "up" || state === "healthy";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isDown(state: IpHealthState | NodeHealthState | string): boolean {
|
||||||
|
return state === "down" || state === "unhealthy";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A-pool membership: only Down is drained. Recovering (unknown/checking) and Slow return immediately. */
|
||||||
|
export function isPoolMember(state: IpHealthState | NodeHealthState | string): boolean {
|
||||||
|
return !isDown(state);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,26 +1,55 @@
|
|||||||
import type { LbMode } from "@cfdm/shared";
|
import type { LbMode } from "@cfdm/shared";
|
||||||
import { failoverDesired } from "./failover.js";
|
import { failoverDesired } from "./failover.js";
|
||||||
|
import { canApplyLb, isSharedPool } from "./pool.js";
|
||||||
import { roundRobinDesired } from "./round-robin.js";
|
import { roundRobinDesired } from "./round-robin.js";
|
||||||
import type { LbIpRow, LbTargetConfig } from "./types.js";
|
import type { LbIpRow, LbTargetConfig } from "./types.js";
|
||||||
|
import { weightedDesired } from "./weighted.js";
|
||||||
|
|
||||||
export type { LbIpRow, LbTargetConfig } from "./types.js";
|
export type { LbIpRow, LbTargetConfig } from "./types.js";
|
||||||
export { isHealthy } from "./health.js";
|
export { isDown, isHealthy, isPoolMember } from "./health.js";
|
||||||
export { withBindingLock } from "./binding-lock.js";
|
export { withBindingLock } from "./binding-lock.js";
|
||||||
|
export {
|
||||||
|
canApplyLb,
|
||||||
|
isSharedPool,
|
||||||
|
shouldRecordFailoverDnsDiff,
|
||||||
|
uniqueIpCount,
|
||||||
|
} from "./pool.js";
|
||||||
|
export { WEIGHTED_DNS_TTL, WEIGHTED_SLOT_MS, weightedDesired } from "./weighted.js";
|
||||||
|
|
||||||
export function selectActiveIpsByMode(
|
export function selectActiveIpsByMode(
|
||||||
config: LbTargetConfig,
|
config: LbTargetConfig,
|
||||||
rows: LbIpRow[],
|
rows: LbIpRow[],
|
||||||
|
nowMs = Date.now(),
|
||||||
): string[] {
|
): string[] {
|
||||||
if (rows.length === 0) return [];
|
if (rows.length === 0) return [];
|
||||||
if (config.lb_mode === "failover") {
|
if (config.lb_mode === "failover") {
|
||||||
return failoverDesired(rows);
|
return failoverDesired(rows);
|
||||||
}
|
}
|
||||||
// weighted = round_robin on DNS (one A per IP)
|
if (config.lb_mode === "weighted") {
|
||||||
|
return weightedDesired(rows, nowMs);
|
||||||
|
}
|
||||||
return roundRobinDesired(rows);
|
return roundRobinDesired(rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveDesiredAIps(
|
||||||
|
config: LbTargetConfig,
|
||||||
|
rows: LbIpRow[],
|
||||||
|
fallbackIps: readonly string[],
|
||||||
|
nowMs = Date.now(),
|
||||||
|
serviceIps: readonly string[] = fallbackIps,
|
||||||
|
): string[] {
|
||||||
|
const fallback = [...fallbackIps];
|
||||||
|
if (!canApplyLb(serviceIps, fallback)) return fallback;
|
||||||
|
if (!isSharedPool(rows.map((row) => row.ip))) return fallback;
|
||||||
|
if (config.lb_mode === "weighted" || config.health_check_enabled) {
|
||||||
|
const activeIps = selectActiveIpsByMode(config, rows, nowMs);
|
||||||
|
if (activeIps.length > 0) return activeIps;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
export function strategyLabel(mode: LbMode): string {
|
export function strategyLabel(mode: LbMode): string {
|
||||||
if (mode === "failover") return "Failover";
|
if (mode === "failover") return "Failover";
|
||||||
if (mode === "weighted") return "Round Robin (weighted alias)";
|
if (mode === "weighted") return "Weighted";
|
||||||
return "Round Robin";
|
return "Round Robin";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import type { LbMode } from "@cfdm/shared";
|
||||||
|
|
||||||
|
export function uniqueIpCount(ips: readonly string[]): number {
|
||||||
|
return new Set(ips.filter(Boolean)).size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shared pool — two or more unique IPs. One IP (even duplicated) is not a pool. */
|
||||||
|
export function isSharedPool(ips: readonly string[]): boolean {
|
||||||
|
return uniqueIpCount(ips) >= 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** LB / drain only when the service itself has a pool AND this FQDN is shared. */
|
||||||
|
export function canApplyLb(
|
||||||
|
serviceIps: readonly string[],
|
||||||
|
bindingIps: readonly string[],
|
||||||
|
): boolean {
|
||||||
|
return isSharedPool(serviceIps) && isSharedPool(bindingIps);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldRecordFailoverDnsDiff(input: {
|
||||||
|
configuredIps: readonly string[];
|
||||||
|
lbMode: LbMode;
|
||||||
|
added: readonly string[];
|
||||||
|
removed: readonly string[];
|
||||||
|
downIps: ReadonlySet<string>;
|
||||||
|
}): boolean {
|
||||||
|
if (!isSharedPool(input.configuredIps)) return false;
|
||||||
|
if (input.lbMode !== "weighted") return true;
|
||||||
|
return [...input.added, ...input.removed].some((ip) => input.downIps.has(ip));
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import type { LbIpRow } from "./types.js";
|
import type { LbIpRow } from "./types.js";
|
||||||
import { isHealthy } from "./health.js";
|
import { isPoolMember } from "./health.js";
|
||||||
|
|
||||||
export function roundRobinDesired(rows: LbIpRow[]): string[] {
|
export function roundRobinDesired(rows: LbIpRow[]): string[] {
|
||||||
const healthy = rows.filter((r) => isHealthy(r.health));
|
const live = rows.filter((r) => isPoolMember(r.health));
|
||||||
const pool = healthy.length > 0 ? healthy : rows;
|
const pool = live.length > 0 ? live : rows;
|
||||||
return pool.map((r) => r.ip);
|
return pool.map((r) => r.ip);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { LbIpRow } from "./types.js";
|
||||||
|
import { isPoolMember } from "./health.js";
|
||||||
|
|
||||||
|
/** Slot length for time-sliced weighted DNS (one A at a time). */
|
||||||
|
export const WEIGHTED_SLOT_MS = 60_000;
|
||||||
|
|
||||||
|
/** Cloudflare DNS-only minimum TTL; Auto (1) is ~300s and would smear ratios. */
|
||||||
|
export const WEIGHTED_DNS_TTL = 60;
|
||||||
|
|
||||||
|
export function weightedDesired(rows: LbIpRow[], nowMs = Date.now()): string[] {
|
||||||
|
if (rows.length === 0) return [];
|
||||||
|
const live = rows.filter((r) => isPoolMember(r.health));
|
||||||
|
const pool = live.length > 0 ? live : rows;
|
||||||
|
if (pool.length === 1) return [pool[0]!.ip];
|
||||||
|
|
||||||
|
const sorted = [...pool].sort((a, b) => a.ip.localeCompare(b.ip));
|
||||||
|
const cycle: string[] = [];
|
||||||
|
for (const row of sorted) {
|
||||||
|
const weight = Math.max(1, Math.round(row.weight));
|
||||||
|
for (let i = 0; i < weight; i++) cycle.push(row.ip);
|
||||||
|
}
|
||||||
|
const slot = Math.floor(nowMs / WEIGHTED_SLOT_MS) % cycle.length;
|
||||||
|
return [cycle[slot]!];
|
||||||
|
}
|
||||||
@@ -29,15 +29,38 @@ 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 { fireEnsureHealthWorker, DEFAULT_HEALTH_FALLBACKS } from "./health/health-worker-deploy.js";
|
||||||
import {
|
import {
|
||||||
isHealthy,
|
canApplyLb,
|
||||||
|
isPoolMember,
|
||||||
|
isSharedPool,
|
||||||
|
resolveDesiredAIps,
|
||||||
selectActiveIpsByMode,
|
selectActiveIpsByMode,
|
||||||
|
shouldRecordFailoverDnsDiff,
|
||||||
withBindingLock,
|
withBindingLock,
|
||||||
|
WEIGHTED_DNS_TTL,
|
||||||
type LbIpRow,
|
type LbIpRow,
|
||||||
type LbTargetConfig,
|
type LbTargetConfig,
|
||||||
} from "./routing/index.js";
|
} from "./routing/index.js";
|
||||||
|
|
||||||
export type { LbIpRow, LbTargetConfig };
|
export type { LbIpRow, LbTargetConfig };
|
||||||
export { selectActiveIpsByMode };
|
export {
|
||||||
|
canApplyLb,
|
||||||
|
resolveDesiredAIps,
|
||||||
|
selectActiveIpsByMode,
|
||||||
|
shouldRecordFailoverDnsDiff,
|
||||||
|
};
|
||||||
|
|
||||||
|
const AUTO_DNS_TTL = 1;
|
||||||
|
|
||||||
|
function ttlForBinding(mode: LbMode, ips: readonly string[]): number {
|
||||||
|
return mode === "weighted" && isSharedPool(ips) ? WEIGHTED_DNS_TTL : AUTO_DNS_TTL;
|
||||||
|
}
|
||||||
|
|
||||||
|
function enabledServiceIps(db: Db, serviceId: number): string[] {
|
||||||
|
return repos
|
||||||
|
.listServiceIpRows(db, serviceId)
|
||||||
|
.filter((row) => row.enabled)
|
||||||
|
.map((row) => row.ip);
|
||||||
|
}
|
||||||
|
|
||||||
export function failoverARecordDiff(
|
export function failoverARecordDiff(
|
||||||
existingA: readonly string[],
|
existingA: readonly string[],
|
||||||
@@ -65,6 +88,22 @@ function recordFailoverDnsDiff(
|
|||||||
const { added, removed } = failoverARecordDiff(existingA, desiredIps);
|
const { added, removed } = failoverARecordDiff(existingA, desiredIps);
|
||||||
if (added.length === 0 && removed.length === 0) return;
|
if (added.length === 0 && removed.length === 0) return;
|
||||||
const binding = repos.getBinding(db, bindingId);
|
const binding = repos.getBinding(db, bindingId);
|
||||||
|
const configuredIps = repos.listBindingIps(db, bindingId);
|
||||||
|
const { config, rows } = getBindingLbState(db, bindingId);
|
||||||
|
const downIps = new Set(
|
||||||
|
rows.filter((row) => row.health === "down").map((row) => row.ip),
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!shouldRecordFailoverDnsDiff({
|
||||||
|
configuredIps,
|
||||||
|
lbMode: config.lb_mode,
|
||||||
|
added,
|
||||||
|
removed,
|
||||||
|
downIps,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
repos.insertFailoverLog(db, {
|
repos.insertFailoverLog(db, {
|
||||||
serviceId: binding.service_id,
|
serviceId: binding.service_id,
|
||||||
bindingId,
|
bindingId,
|
||||||
@@ -248,7 +287,7 @@ function getGroupLbState(
|
|||||||
} else {
|
} else {
|
||||||
existing.weight += weight;
|
existing.weight += weight;
|
||||||
existing.priority = Math.min(existing.priority, priority);
|
existing.priority = Math.min(existing.priority, priority);
|
||||||
if (isHealthy(existing.health) && status && !isHealthy(status.status as IpHealthState)) {
|
if (isPoolMember(existing.health) && status && !isPoolMember(status.status as IpHealthState)) {
|
||||||
existing.health = status.status as IpHealthState;
|
existing.health = status.status as IpHealthState;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -264,16 +303,31 @@ function getGroupLbState(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function computeActiveIps(
|
function desiredAIps(
|
||||||
db: Db,
|
db: Db,
|
||||||
scope: HealthCheckScope,
|
scope: HealthCheckScope,
|
||||||
refId: number,
|
refId: number,
|
||||||
|
fallbackIps: string[],
|
||||||
): string[] {
|
): string[] {
|
||||||
const state =
|
const state =
|
||||||
scope === "binding"
|
scope === "binding"
|
||||||
? getBindingLbState(db, refId)
|
? getBindingLbState(db, refId)
|
||||||
: getGroupLbState(db, refId);
|
: getGroupLbState(db, refId);
|
||||||
return selectActiveIpsByMode(state.config, state.rows);
|
// Configured binding/group IPs stay intact; DNS publishes only enabled ones.
|
||||||
|
const enabledIps =
|
||||||
|
scope === "binding"
|
||||||
|
? enabledServiceIps(db, repos.getBinding(db, refId).service_id)
|
||||||
|
: fallbackIps;
|
||||||
|
const enabledSet = new Set(enabledIps);
|
||||||
|
const activeFallback = fallbackIps.filter((ip) => enabledSet.has(ip));
|
||||||
|
const activeRows = state.rows.filter((row) => enabledSet.has(row.ip));
|
||||||
|
return resolveDesiredAIps(
|
||||||
|
state.config,
|
||||||
|
activeRows,
|
||||||
|
activeFallback,
|
||||||
|
Date.now(),
|
||||||
|
enabledIps,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function collectKnownZones(
|
async function collectKnownZones(
|
||||||
@@ -291,7 +345,38 @@ async function collectKnownZones(
|
|||||||
return zones;
|
return zones;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Restore common A-bindings whose IPs were shrunk by legacy IP toggles. */
|
||||||
|
function repairPoolSubsetBindings(db: Db, serviceId: number): void {
|
||||||
|
const pool = repos.listServiceIps(db, serviceId);
|
||||||
|
if (pool.length < 2) return;
|
||||||
|
const poolSet = new Set(pool);
|
||||||
|
|
||||||
|
for (const binding of repos.listBindingsByService(db, serviceId)) {
|
||||||
|
if (binding.cname_target?.trim()) continue;
|
||||||
|
const current = repos.listBindingIpsWithMeta(db, binding.id);
|
||||||
|
if (current.length <= 1) continue;
|
||||||
|
if (!current.every((entry) => poolSet.has(entry.ip))) continue;
|
||||||
|
|
||||||
|
const currentSet = new Set(current.map((entry) => entry.ip));
|
||||||
|
if (currentSet.size === pool.length && pool.every((ip) => currentSet.has(ip))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const byIp = new Map(current.map((entry) => [entry.ip, entry]));
|
||||||
|
repos.replaceBindingIpsWithMeta(
|
||||||
|
db,
|
||||||
|
binding.id,
|
||||||
|
pool.map((ip) => ({
|
||||||
|
ip,
|
||||||
|
weight: byIp.get(ip)?.weight ?? 1,
|
||||||
|
priority: byIp.get(ip)?.priority ?? 1,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
|
async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
|
||||||
|
repairPoolSubsetBindings(db, serviceId);
|
||||||
const service = repos.getService(db, serviceId);
|
const service = repos.getService(db, serviceId);
|
||||||
const ipRows = repos.listServiceIpRows(db, serviceId);
|
const ipRows = repos.listServiceIpRows(db, serviceId);
|
||||||
const ips = ipRows.map((row) => row.ip);
|
const ips = ipRows.map((row) => row.ip);
|
||||||
@@ -325,7 +410,7 @@ async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
|
|||||||
const { config, rows } = getBindingLbState(db, binding.id);
|
const { config, rows } = getBindingLbState(db, binding.id);
|
||||||
const bindingActiveIps = targetCname
|
const bindingActiveIps = targetCname
|
||||||
? []
|
? []
|
||||||
: selectActiveIpsByMode(config, rows);
|
: resolveDesiredAIps(config, rows, targetIps, Date.now(), ips);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
binding_id: binding.id,
|
binding_id: binding.id,
|
||||||
@@ -446,6 +531,49 @@ function fallbackCnameHealth(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function overlayLiveHealth(
|
||||||
|
stored: IpHealthState | undefined,
|
||||||
|
live: IpHealthState | undefined,
|
||||||
|
): IpHealthState {
|
||||||
|
if (live && live !== "unknown") return live;
|
||||||
|
return stored ?? live ?? "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
function bestAliveDisplayStatus(statuses: readonly string[]): IpHealthState {
|
||||||
|
if (statuses.some((status) => status === "up")) return "up";
|
||||||
|
if (statuses.some((status) => status === "degraded")) return "degraded";
|
||||||
|
if (statuses.some((status) => status === "down")) return "down";
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Health badge applies only when the service, IP and HC (binding or group) are active. */
|
||||||
|
function isServiceHealthCheckActive(db: Db, view: ServiceView): boolean {
|
||||||
|
if ((view.domains ?? []).some((domain) => domain.health_check_enabled)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!view.service_group_id) return false;
|
||||||
|
const group = repos.getServiceGroup(db, view.service_group_id);
|
||||||
|
return Boolean(group.health_check_enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isIpHealthMonitored(db: Db, view: ServiceView, ip: string): boolean {
|
||||||
|
if (!view.enabled) return false;
|
||||||
|
if (view.ip_enabled[ip] === false) return false;
|
||||||
|
return isServiceHealthCheckActive(db, view);
|
||||||
|
}
|
||||||
|
|
||||||
|
function inactiveIpHealthRow(ip: string): ServiceHealthRow {
|
||||||
|
return {
|
||||||
|
ip,
|
||||||
|
status: "unknown",
|
||||||
|
latency_ms: null,
|
||||||
|
last_checked_at: null,
|
||||||
|
last_error: null,
|
||||||
|
provider: "local",
|
||||||
|
colo: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function attachServiceHealth(
|
function attachServiceHealth(
|
||||||
db: Db,
|
db: Db,
|
||||||
views: ServiceView[],
|
views: ServiceView[],
|
||||||
@@ -453,10 +581,13 @@ function attachServiceHealth(
|
|||||||
const ids = views.map((v) => v.id);
|
const ids = views.map((v) => v.id);
|
||||||
const healthByService = repos.aggregateIpHealthByServiceIds(db, ids);
|
const healthByService = repos.aggregateIpHealthByServiceIds(db, ids);
|
||||||
const ipHealthByService = repos.listIpHealthByServiceIds(db, ids);
|
const ipHealthByService = repos.listIpHealthByServiceIds(db, ids);
|
||||||
|
const liveByService = repos.listLatestLiveHealthByServiceIds(db, ids);
|
||||||
return views.map((view) => {
|
return views.map((view) => {
|
||||||
const health = healthByService.get(view.id);
|
const health = healthByService.get(view.id);
|
||||||
const rows = ipHealthByService.get(view.id) ?? [];
|
const rows = ipHealthByService.get(view.id) ?? [];
|
||||||
|
const liveRows = liveByService.get(view.id) ?? [];
|
||||||
const byIp = new Map(rows.map((row) => [row.ip, row]));
|
const byIp = new Map(rows.map((row) => [row.ip, row]));
|
||||||
|
const liveByIp = new Map(liveRows.map((row) => [row.ip, row]));
|
||||||
const cnameFallback = fallbackCnameHealth(rows, view);
|
const cnameFallback = fallbackCnameHealth(rows, view);
|
||||||
const aRecordIps = new Set(
|
const aRecordIps = new Set(
|
||||||
(view.domains ?? []).flatMap((domain) =>
|
(view.domains ?? []).flatMap((domain) =>
|
||||||
@@ -464,21 +595,46 @@ function attachServiceHealth(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
const ip_health = (view.ips ?? []).map((ip) => {
|
const ip_health = (view.ips ?? []).map((ip) => {
|
||||||
|
if (!isIpHealthMonitored(db, view, ip)) {
|
||||||
|
return inactiveIpHealthRow(ip);
|
||||||
|
}
|
||||||
const row = byIp.get(ip) ?? (aRecordIps.has(ip) ? undefined : cnameFallback);
|
const row = byIp.get(ip) ?? (aRecordIps.has(ip) ? undefined : cnameFallback);
|
||||||
|
const live = liveByIp.get(ip);
|
||||||
|
const status = overlayLiveHealth(row?.status, live?.status);
|
||||||
|
const extras = live && live.status !== "unknown" ? live : row;
|
||||||
return {
|
return {
|
||||||
ip,
|
ip,
|
||||||
status: row?.status ?? ("unknown" as const),
|
status,
|
||||||
latency_ms: row?.latency_ms ?? null,
|
latency_ms: extras?.latency_ms ?? null,
|
||||||
last_checked_at: row?.last_checked_at ?? null,
|
last_checked_at: extras?.last_checked_at ?? null,
|
||||||
last_error: row?.last_error ?? null,
|
last_error:
|
||||||
provider: row?.provider ?? "local",
|
live && live.status !== "unknown"
|
||||||
colo: row?.colo ?? null,
|
? live.last_error
|
||||||
|
: (row?.last_error ?? null),
|
||||||
|
provider: extras?.provider ?? "local",
|
||||||
|
colo: extras?.colo ?? null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
const monitoredStatuses = ip_health
|
||||||
|
.filter((row) => isIpHealthMonitored(db, view, row.ip))
|
||||||
|
.map((row) => row.status);
|
||||||
|
const displayStatus =
|
||||||
|
monitoredStatuses.length > 0
|
||||||
|
? bestAliveDisplayStatus(monitoredStatuses)
|
||||||
|
: ("unknown" as const);
|
||||||
|
const latencyRow =
|
||||||
|
ip_health.find((row) => row.status === displayStatus && row.latency_ms != null) ??
|
||||||
|
ip_health.find((row) => row.latency_ms != null);
|
||||||
return {
|
return {
|
||||||
...view,
|
...view,
|
||||||
health_status: health?.health_status ?? "unknown",
|
health_status:
|
||||||
health_latency_ms: health?.health_latency_ms ?? null,
|
monitoredStatuses.length > 0
|
||||||
|
? overlayLiveHealth(health?.health_status, displayStatus)
|
||||||
|
: "unknown",
|
||||||
|
health_latency_ms:
|
||||||
|
monitoredStatuses.length > 0 && displayStatus !== "unknown"
|
||||||
|
? (latencyRow?.latency_ms ?? null)
|
||||||
|
: null,
|
||||||
ip_health,
|
ip_health,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -581,26 +737,11 @@ async function syncBindingDns(
|
|||||||
desiredIps: string[],
|
desiredIps: string[],
|
||||||
cnameTarget: string | null,
|
cnameTarget: string | null,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const domain = repos.getDomain(db, domainId);
|
const effectiveCname = cnameTarget?.trim() || null;
|
||||||
const zoneName = domain.zone_name;
|
|
||||||
let effectiveCname = cnameTarget?.trim() || null;
|
|
||||||
|
|
||||||
if (!effectiveCname) {
|
|
||||||
const existingCname = await findOrImportDnsRecord(
|
|
||||||
db,
|
|
||||||
cf,
|
|
||||||
domainId,
|
|
||||||
zoneName,
|
|
||||||
hostname,
|
|
||||||
"CNAME",
|
|
||||||
);
|
|
||||||
if (existingCname) {
|
|
||||||
effectiveCname = existingCname.content;
|
|
||||||
repos.setBindingCnameTarget(db, bindingId, effectiveCname);
|
|
||||||
repos.replaceBindingIps(db, bindingId, []);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Desired config wins. Do NOT auto-adopt leftover CNAME from local/CF when the
|
||||||
|
// binding is A-mode — that wiped IPs and blocked extra FQDN publishes.
|
||||||
|
// Docs: https://developers.cloudflare.com/dns/manage-dns-records/troubleshooting/records-with-same-name/
|
||||||
if (effectiveCname) {
|
if (effectiveCname) {
|
||||||
await syncBindingCnameDns(
|
await syncBindingCnameDns(
|
||||||
db,
|
db,
|
||||||
@@ -613,7 +754,83 @@ async function syncBindingDns(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await syncBindingADns(db, cf, bindingId, domainId, hostname, desiredIps);
|
const binding = repos.getBinding(db, bindingId);
|
||||||
|
const configuredIps = repos.listBindingIps(db, bindingId);
|
||||||
|
await syncBindingADns(
|
||||||
|
db,
|
||||||
|
cf,
|
||||||
|
bindingId,
|
||||||
|
domainId,
|
||||||
|
hostname,
|
||||||
|
desiredIps,
|
||||||
|
ttlForBinding(binding.lb_mode, configuredIps),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delete every local+CF record for hostname that must not remain in A mode. */
|
||||||
|
async function reconcileHostnameForA(
|
||||||
|
db: Db,
|
||||||
|
cf: CloudflareClient,
|
||||||
|
domainId: number,
|
||||||
|
zoneName: string,
|
||||||
|
hostname: string,
|
||||||
|
desiredIps: readonly string[],
|
||||||
|
): Promise<void> {
|
||||||
|
const desired = new Set(desiredIps);
|
||||||
|
|
||||||
|
// CNAME on the same name blocks creating A records in Cloudflare.
|
||||||
|
for (;;) {
|
||||||
|
const cname = await findOrImportDnsRecord(
|
||||||
|
db,
|
||||||
|
cf,
|
||||||
|
domainId,
|
||||||
|
zoneName,
|
||||||
|
hostname,
|
||||||
|
"CNAME",
|
||||||
|
);
|
||||||
|
if (!cname) break;
|
||||||
|
await dnsService.deleteRecord(db, cf, domainId, cname.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const domain = repos.getDomain(db, domainId);
|
||||||
|
const staleLocal = repos
|
||||||
|
.listDnsByDomain(db, domainId)
|
||||||
|
.filter(
|
||||||
|
(record) =>
|
||||||
|
record.record_type.toUpperCase() === "A" &&
|
||||||
|
dnsRecordNamesMatch(record.name, hostname, zoneName) &&
|
||||||
|
!desired.has(record.content),
|
||||||
|
);
|
||||||
|
for (const record of staleLocal) {
|
||||||
|
await dnsService.deleteRecord(db, cf, domainId, record.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const remote = await cf.listDnsRecords(domain.cf_zone_id);
|
||||||
|
for (const cfRec of remote) {
|
||||||
|
if ((cfRec.type ?? "").toUpperCase() !== "A" || !cfRec.id) continue;
|
||||||
|
if (!dnsRecordNamesMatch(cfRec.name, hostname, zoneName)) continue;
|
||||||
|
if (desired.has(cfRec.content)) continue;
|
||||||
|
|
||||||
|
const existing = repos.findDnsByCfId(db, domainId, cfRec.id);
|
||||||
|
if (existing) {
|
||||||
|
await dnsService.deleteRecord(db, cf, domainId, existing.id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const imported = repos.insertDnsRecord(
|
||||||
|
db,
|
||||||
|
domainId,
|
||||||
|
cfRec.type,
|
||||||
|
cfRec.name,
|
||||||
|
cfRec.content,
|
||||||
|
cfRec.ttl,
|
||||||
|
cfRec.proxied ?? false,
|
||||||
|
cfRec.priority ?? null,
|
||||||
|
SYNC_SYNCED,
|
||||||
|
"cloudflare",
|
||||||
|
cfRec.id,
|
||||||
|
);
|
||||||
|
await dnsService.deleteRecord(db, cf, domainId, imported.id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function syncBindingCnameDns(
|
async function syncBindingCnameDns(
|
||||||
@@ -627,6 +844,21 @@ async function syncBindingCnameDns(
|
|||||||
const domain = repos.getDomain(db, domainId);
|
const domain = repos.getDomain(db, domainId);
|
||||||
const zoneName = domain.zone_name;
|
const zoneName = domain.zone_name;
|
||||||
const normalized = normalizeCnameTarget(cnameTarget, zoneName);
|
const normalized = normalizeCnameTarget(cnameTarget, zoneName);
|
||||||
|
|
||||||
|
// A/AAAA on the same name blocks CNAME create in Cloudflare.
|
||||||
|
for (;;) {
|
||||||
|
const conflictingA = await findOrImportDnsRecord(
|
||||||
|
db,
|
||||||
|
cf,
|
||||||
|
domainId,
|
||||||
|
zoneName,
|
||||||
|
hostname,
|
||||||
|
"A",
|
||||||
|
);
|
||||||
|
if (!conflictingA) break;
|
||||||
|
await dnsService.deleteRecord(db, cf, domainId, conflictingA.id);
|
||||||
|
}
|
||||||
|
|
||||||
const existingRecords = repos.listRecordsForBinding(db, bindingId);
|
const existingRecords = repos.listRecordsForBinding(db, bindingId);
|
||||||
|
|
||||||
for (const record of existingRecords) {
|
for (const record of existingRecords) {
|
||||||
@@ -700,9 +932,14 @@ async function syncBindingADns(
|
|||||||
domainId: number,
|
domainId: number,
|
||||||
hostname: string,
|
hostname: string,
|
||||||
desiredIps: string[],
|
desiredIps: string[],
|
||||||
|
ttl: number,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const domain = repos.getDomain(db, domainId);
|
const domain = repos.getDomain(db, domainId);
|
||||||
const zoneName = domain.zone_name;
|
const zoneName = domain.zone_name;
|
||||||
|
|
||||||
|
// Align zone/local leftovers with desired A set (CNAME conflicts, stale A IPs).
|
||||||
|
await reconcileHostnameForA(db, cf, domainId, zoneName, hostname, desiredIps);
|
||||||
|
|
||||||
const existingRecords = repos.listRecordsForBinding(db, bindingId);
|
const existingRecords = repos.listRecordsForBinding(db, bindingId);
|
||||||
|
|
||||||
for (const record of existingRecords) {
|
for (const record of existingRecords) {
|
||||||
@@ -735,13 +972,15 @@ async function syncBindingADns(
|
|||||||
|
|
||||||
for (const ip of desiredIps) {
|
for (const ip of desiredIps) {
|
||||||
const existing = refreshed.find((r) => r.content === ip);
|
const existing = refreshed.find((r) => r.content === ip);
|
||||||
|
const recordName = dnsNameForBinding(hostname, zoneName);
|
||||||
let recordId: number;
|
let recordId: number;
|
||||||
if (existing) {
|
if (existing) {
|
||||||
if (!dnsRecordNamesMatch(existing.name, hostname, zoneName)) {
|
if (!dnsRecordNamesMatch(existing.name, hostname, zoneName) || existing.ttl !== ttl) {
|
||||||
await dnsService.update(db, cf, domainId, existing.id, {
|
await dnsService.update(db, cf, domainId, existing.id, {
|
||||||
record_type: "A",
|
record_type: "A",
|
||||||
name: dnsNameForBinding(hostname, zoneName),
|
name: recordName,
|
||||||
content: ip,
|
content: ip,
|
||||||
|
ttl,
|
||||||
proxied: false,
|
proxied: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -759,12 +998,24 @@ async function syncBindingADns(
|
|||||||
if (adopted) {
|
if (adopted) {
|
||||||
repos.linkBindingRecord(db, bindingId, adopted.id);
|
repos.linkBindingRecord(db, bindingId, adopted.id);
|
||||||
recordId = adopted.id;
|
recordId = adopted.id;
|
||||||
|
if (
|
||||||
|
!dnsRecordNamesMatch(adopted.name, hostname, zoneName) ||
|
||||||
|
adopted.ttl !== ttl
|
||||||
|
) {
|
||||||
|
await dnsService.update(db, cf, domainId, adopted.id, {
|
||||||
|
record_type: "A",
|
||||||
|
name: recordName,
|
||||||
|
content: ip,
|
||||||
|
ttl,
|
||||||
|
proxied: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const record = await dnsService.create(db, cf, domainId, {
|
const record = await dnsService.create(db, cf, domainId, {
|
||||||
record_type: "A",
|
record_type: "A",
|
||||||
name: dnsNameForBinding(hostname, zoneName),
|
name: recordName,
|
||||||
content: ip,
|
content: ip,
|
||||||
ttl: 1,
|
ttl,
|
||||||
proxied: false,
|
proxied: false,
|
||||||
});
|
});
|
||||||
repos.linkBindingRecord(db, bindingId, record.id);
|
repos.linkBindingRecord(db, bindingId, record.id);
|
||||||
@@ -1001,12 +1252,7 @@ async function syncServiceBindingsToDns(
|
|||||||
}
|
}
|
||||||
validateTargetIpsInPool(targetIps, ips);
|
validateTargetIpsInPool(targetIps, ips);
|
||||||
|
|
||||||
if (binding.health_check_enabled) {
|
const desiredIps = desiredAIps(db, "binding", binding.id, targetIps);
|
||||||
const activeIps = computeActiveIps(db, "binding", binding.id);
|
|
||||||
if (activeIps.length > 0) {
|
|
||||||
targetIps = activeIps;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await syncBindingDns(
|
await syncBindingDns(
|
||||||
db,
|
db,
|
||||||
@@ -1014,7 +1260,7 @@ async function syncServiceBindingsToDns(
|
|||||||
binding.id,
|
binding.id,
|
||||||
binding.domain_id,
|
binding.domain_id,
|
||||||
binding.hostname,
|
binding.hostname,
|
||||||
targetIps,
|
desiredIps,
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1028,10 +1274,12 @@ async function collectGroupDnsIps(
|
|||||||
const ips: string[] = [];
|
const ips: string[] = [];
|
||||||
for (const service of services) {
|
for (const service of services) {
|
||||||
if (!service.enabled) continue;
|
if (!service.enabled) continue;
|
||||||
|
const enabled = new Set(enabledServiceIps(db, service.id));
|
||||||
const bindings = repos.listBindingsByService(db, service.id);
|
const bindings = repos.listBindingsByService(db, service.id);
|
||||||
for (const binding of bindings) {
|
for (const binding of bindings) {
|
||||||
for (const ip of repos.listBindingIps(db, binding.id)) {
|
for (const ip of repos.listBindingIps(db, binding.id)) {
|
||||||
if (!ips.includes(ip)) ips.push(ip);
|
if (!enabled.has(ip) || ips.includes(ip)) continue;
|
||||||
|
ips.push(ip);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1046,6 +1294,7 @@ async function syncGroupDomainDnsRecords(
|
|||||||
domainId: number,
|
domainId: number,
|
||||||
hostname: string,
|
hostname: string,
|
||||||
desiredIps: string[],
|
desiredIps: string[],
|
||||||
|
ttl: number = AUTO_DNS_TTL,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const domain = repos.getDomain(db, domainId);
|
const domain = repos.getDomain(db, domainId);
|
||||||
const zoneName = domain.zone_name;
|
const zoneName = domain.zone_name;
|
||||||
@@ -1061,14 +1310,16 @@ async function syncGroupDomainDnsRecords(
|
|||||||
if (desiredIps.length === 0) return;
|
if (desiredIps.length === 0) return;
|
||||||
|
|
||||||
const refreshed = repos.listGroupDnsRecords(db, groupId);
|
const refreshed = repos.listGroupDnsRecords(db, groupId);
|
||||||
|
const recordName = dnsNameForBinding(hostname, zoneName);
|
||||||
for (const ip of desiredIps) {
|
for (const ip of desiredIps) {
|
||||||
const existing = refreshed.find((r) => r.content === ip);
|
const existing = refreshed.find((r) => r.content === ip);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
if (!dnsRecordNamesMatch(existing.name, hostname, zoneName)) {
|
if (!dnsRecordNamesMatch(existing.name, hostname, zoneName) || existing.ttl !== ttl) {
|
||||||
await dnsService.update(db, cf, domainId, existing.id, {
|
await dnsService.update(db, cf, domainId, existing.id, {
|
||||||
record_type: "A",
|
record_type: "A",
|
||||||
name: dnsNameForBinding(hostname, zoneName),
|
name: recordName,
|
||||||
content: ip,
|
content: ip,
|
||||||
|
ttl,
|
||||||
proxied: false,
|
proxied: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1084,13 +1335,25 @@ async function syncGroupDomainDnsRecords(
|
|||||||
);
|
);
|
||||||
if (adopted) {
|
if (adopted) {
|
||||||
repos.linkGroupDnsRecord(db, groupId, adopted.id);
|
repos.linkGroupDnsRecord(db, groupId, adopted.id);
|
||||||
|
if (
|
||||||
|
!dnsRecordNamesMatch(adopted.name, hostname, zoneName) ||
|
||||||
|
adopted.ttl !== ttl
|
||||||
|
) {
|
||||||
|
await dnsService.update(db, cf, domainId, adopted.id, {
|
||||||
|
record_type: "A",
|
||||||
|
name: recordName,
|
||||||
|
content: ip,
|
||||||
|
ttl,
|
||||||
|
proxied: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const record = await dnsService.create(db, cf, domainId, {
|
const record = await dnsService.create(db, cf, domainId, {
|
||||||
record_type: "A",
|
record_type: "A",
|
||||||
name: dnsNameForBinding(hostname, zoneName),
|
name: recordName,
|
||||||
content: ip,
|
content: ip,
|
||||||
ttl: 1,
|
ttl,
|
||||||
proxied: false,
|
proxied: false,
|
||||||
});
|
});
|
||||||
repos.linkGroupDnsRecord(db, groupId, record.id);
|
repos.linkGroupDnsRecord(db, groupId, record.id);
|
||||||
@@ -1141,9 +1404,8 @@ async function syncGroupDomainDns(
|
|||||||
const knownZones = await collectKnownZones(db, cf);
|
const knownZones = await collectKnownZones(db, cf);
|
||||||
const { zoneName, hostname } = parseFqdn(domainValue, knownZones);
|
const { zoneName, hostname } = parseFqdn(domainValue, knownZones);
|
||||||
const domainId = await resolveDomainId(db, cf, zoneName);
|
const domainId = await resolveDomainId(db, cf, zoneName);
|
||||||
const desiredIps = group.health_check_enabled
|
const fallbackIps = await collectGroupDnsIps(db, groupId);
|
||||||
? computeActiveIps(db, "group", groupId)
|
const desiredIps = desiredAIps(db, "group", groupId, fallbackIps);
|
||||||
: await collectGroupDnsIps(db, groupId);
|
|
||||||
await syncGroupDomainDnsRecords(
|
await syncGroupDomainDnsRecords(
|
||||||
db,
|
db,
|
||||||
cf,
|
cf,
|
||||||
@@ -1151,6 +1413,7 @@ async function syncGroupDomainDns(
|
|||||||
domainId,
|
domainId,
|
||||||
hostname,
|
hostname,
|
||||||
desiredIps,
|
desiredIps,
|
||||||
|
ttlForBinding(group.lb_mode, fallbackIps),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1331,14 +1594,7 @@ export async function updateConfig(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (pushDns) {
|
if (pushDns) {
|
||||||
let effectiveIps = targetIps;
|
const effectiveIps = desiredAIps(db, "binding", binding.id, 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(
|
await syncBindingDns(
|
||||||
db,
|
db,
|
||||||
cf,
|
cf,
|
||||||
@@ -1544,26 +1800,8 @@ export async function toggleServiceIp(
|
|||||||
repos.updateNode(db, node.id, { enabled });
|
repos.updateNode(db, node.id, { enabled });
|
||||||
}
|
}
|
||||||
|
|
||||||
const bindings = repos.listBindingsByService(db, serviceId);
|
// Keep binding IP membership stable (common FQDN = full pool). DNS sync
|
||||||
for (const binding of bindings) {
|
// filters by enabledServiceIps via desiredAIps — do not reshuffle bindings.
|
||||||
if (binding.cname_target?.trim()) continue;
|
|
||||||
const current = repos.listBindingIpsWithMeta(db, binding.id);
|
|
||||||
const hasIp = current.some((entry) => entry.ip === ip);
|
|
||||||
if (enabled && !hasIp) {
|
|
||||||
repos.replaceBindingIpsWithMeta(db, binding.id, [
|
|
||||||
...current,
|
|
||||||
{ ip, weight: 1, priority: 1 },
|
|
||||||
]);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!enabled && hasIp) {
|
|
||||||
repos.replaceBindingIpsWithMeta(
|
|
||||||
db,
|
|
||||||
binding.id,
|
|
||||||
current.filter((entry) => entry.ip !== ip),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const service = repos.getService(db, serviceId);
|
const service = repos.getService(db, serviceId);
|
||||||
if (shouldPushDns(db, service)) {
|
if (shouldPushDns(db, service)) {
|
||||||
@@ -1644,16 +1882,18 @@ export async function reconcileDnsForTarget(
|
|||||||
if (scope === "binding") {
|
if (scope === "binding") {
|
||||||
await withBindingLock(refId, async () => {
|
await withBindingLock(refId, async () => {
|
||||||
const binding = repos.getBinding(db, refId);
|
const binding = repos.getBinding(db, refId);
|
||||||
if (!binding.health_check_enabled) return;
|
if (!binding.health_check_enabled && binding.lb_mode !== "weighted") return;
|
||||||
const service = repos.getService(db, binding.service_id);
|
const service = repos.getService(db, binding.service_id);
|
||||||
if (!shouldPushDns(db, service)) return;
|
if (!shouldPushDns(db, service)) return;
|
||||||
const cnameTarget = binding.cname_target?.trim() || null;
|
const cnameTarget = binding.cname_target?.trim() || null;
|
||||||
if (cnameTarget) return;
|
if (cnameTarget) return;
|
||||||
const ips = repos.listServiceIps(db, service.id);
|
const ips = repos.listServiceIps(db, service.id);
|
||||||
|
const poolIps = enabledServiceIps(db, service.id);
|
||||||
const targetIps = repos.listBindingIps(db, binding.id);
|
const targetIps = repos.listBindingIps(db, binding.id);
|
||||||
validateTargetIpsInPool(targetIps, ips);
|
validateTargetIpsInPool(targetIps, ips);
|
||||||
const activeIps = computeActiveIps(db, "binding", refId);
|
const desiredIps = canApplyLb(poolIps, targetIps)
|
||||||
const desiredIps = activeIps.length > 0 ? activeIps : targetIps;
|
? desiredAIps(db, "binding", refId, targetIps)
|
||||||
|
: targetIps;
|
||||||
await syncBindingDns(
|
await syncBindingDns(
|
||||||
db,
|
db,
|
||||||
cf,
|
cf,
|
||||||
@@ -1668,8 +1908,61 @@ export async function reconcileDnsForTarget(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const group = repos.getServiceGroup(db, refId);
|
const group = repos.getServiceGroup(db, refId);
|
||||||
if (!group.enabled || !group.domain?.trim() || !group.health_check_enabled) {
|
if (!group.enabled || !group.domain?.trim()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!group.health_check_enabled && group.lb_mode !== "weighted") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await syncGroupDomainDns(db, cf, refId);
|
await syncGroupDomainDns(db, cf, refId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function reconcileWeightedDns(
|
||||||
|
db: Db,
|
||||||
|
cf: CloudflareClient,
|
||||||
|
): Promise<number> {
|
||||||
|
let n = 0;
|
||||||
|
for (const binding of repos.listAllBindings(db)) {
|
||||||
|
if (binding.lb_mode !== "weighted") continue;
|
||||||
|
if (binding.cname_target?.trim()) continue;
|
||||||
|
if (!isSharedPool(binding.target_ips ?? [])) continue;
|
||||||
|
try {
|
||||||
|
await withBindingLock(binding.id, async () => {
|
||||||
|
const latest = repos.getBinding(db, binding.id);
|
||||||
|
if (latest.lb_mode !== "weighted") return;
|
||||||
|
if (latest.cname_target?.trim()) return;
|
||||||
|
const service = repos.getService(db, latest.service_id);
|
||||||
|
if (!shouldPushDns(db, service)) return;
|
||||||
|
const targetIps = repos.listBindingIps(db, latest.id);
|
||||||
|
const poolIps = enabledServiceIps(db, service.id);
|
||||||
|
if (!canApplyLb(poolIps, targetIps)) return;
|
||||||
|
const ips = repos.listServiceIps(db, service.id);
|
||||||
|
validateTargetIpsInPool(targetIps, ips);
|
||||||
|
const desiredIps = desiredAIps(db, "binding", latest.id, targetIps);
|
||||||
|
await syncBindingDns(
|
||||||
|
db,
|
||||||
|
cf,
|
||||||
|
latest.id,
|
||||||
|
latest.domain_id,
|
||||||
|
latest.hostname,
|
||||||
|
desiredIps,
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
n += 1;
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const group of repos.listServiceGroups(db)) {
|
||||||
|
if (group.lb_mode !== "weighted") continue;
|
||||||
|
if (!group.enabled || !group.domain?.trim()) continue;
|
||||||
|
try {
|
||||||
|
await syncGroupDomainDns(db, cf, group.id);
|
||||||
|
n += 1;
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,53 @@
|
|||||||
import { resolve4 } from "node:dns/promises";
|
import { resolve4 } from "node:dns/promises";
|
||||||
import type { CfdmBindingSyncItem, ServiceBindingView } from "@cfdm/shared";
|
import type { CfdmBindingSyncItem, LbMode, ServiceBindingView } from "@cfdm/shared";
|
||||||
import { isIpLiteral } from "@cfdm/shared";
|
import { isIpLiteral } from "@cfdm/shared";
|
||||||
import type { Db } from "@cfdm/db";
|
import type { Db } from "@cfdm/db";
|
||||||
import { repos, getAppSettingsSecrets, touchVpsTrackerSync } from "@cfdm/db";
|
import { repos, getAppSettingsSecrets, touchVpsTrackerSync } from "@cfdm/db";
|
||||||
|
import { isSharedPool } from "./routing/pool.js";
|
||||||
|
|
||||||
|
export function isLbMode(value: unknown): value is LbMode {
|
||||||
|
return value === "round_robin" || value === "failover" || value === "weighted";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** lb_mode binding, иначе service group. */
|
||||||
|
export function resolveLbModeForSync(
|
||||||
|
bindingLbMode: string | undefined | null,
|
||||||
|
groupLbMode?: string | null,
|
||||||
|
): LbMode | undefined {
|
||||||
|
if (isLbMode(bindingLbMode)) return bindingLbMode;
|
||||||
|
if (isLbMode(groupLbMode)) return groupLbMode;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Effective HA only when the service has two or more unique origin IPs. */
|
||||||
|
export function effectiveLbModeForSync(
|
||||||
|
bindingLbMode: string | undefined | null,
|
||||||
|
groupLbMode: string | undefined | null,
|
||||||
|
serviceIps: readonly string[],
|
||||||
|
): LbMode | undefined {
|
||||||
|
if (!isSharedPool(serviceIps)) return undefined;
|
||||||
|
return resolveLbModeForSync(bindingLbMode, groupLbMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupLbModeForService(
|
||||||
|
db: Db,
|
||||||
|
serviceId: number,
|
||||||
|
cache: Map<number, LbMode | undefined>,
|
||||||
|
): LbMode | undefined {
|
||||||
|
if (cache.has(serviceId)) return cache.get(serviceId);
|
||||||
|
let mode: LbMode | undefined;
|
||||||
|
try {
|
||||||
|
const service = repos.getService(db, serviceId);
|
||||||
|
if (service.service_group_id != null) {
|
||||||
|
const group = repos.getServiceGroup(db, service.service_group_id);
|
||||||
|
mode = resolveLbModeForSync(undefined, group.lb_mode);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
mode = undefined;
|
||||||
|
}
|
||||||
|
cache.set(serviceId, mode);
|
||||||
|
return mode;
|
||||||
|
}
|
||||||
|
|
||||||
function fqdnToDisplay(hostname: string, zoneName: string): string {
|
function fqdnToDisplay(hostname: string, zoneName: string): string {
|
||||||
if (hostname === "@" || !hostname.trim()) return zoneName;
|
if (hostname === "@" || !hostname.trim()) return zoneName;
|
||||||
@@ -126,10 +171,13 @@ export async function buildServiceSyncBindingsAsync(
|
|||||||
const allBindings = repos.listAllBindings(db);
|
const allBindings = repos.listAllBindings(db);
|
||||||
const index = buildBindingIndex(allBindings);
|
const index = buildBindingIndex(allBindings);
|
||||||
const bindings = allBindings.filter((row) => row.service_id === serviceId);
|
const bindings = allBindings.filter((row) => row.service_id === serviceId);
|
||||||
|
const groupLbCache = new Map<number, LbMode | undefined>();
|
||||||
|
const groupLb = groupLbModeForService(db, serviceId, groupLbCache);
|
||||||
|
|
||||||
const items: CfdmBindingSyncItem[] = [];
|
const items: CfdmBindingSyncItem[] = [];
|
||||||
for (const binding of bindings) {
|
for (const binding of bindings) {
|
||||||
const ips = await resolveBindingIpsForSync(binding, serviceIps, index, db);
|
const ips = await resolveBindingIpsForSync(binding, serviceIps, index, db);
|
||||||
|
const lbMode = effectiveLbModeForSync(binding.lb_mode, groupLb, serviceIps);
|
||||||
items.push({
|
items.push({
|
||||||
bindingId: binding.id,
|
bindingId: binding.id,
|
||||||
serviceId: service.id,
|
serviceId: service.id,
|
||||||
@@ -140,6 +188,7 @@ export async function buildServiceSyncBindingsAsync(
|
|||||||
hostname: binding.hostname,
|
hostname: binding.hostname,
|
||||||
ips,
|
ips,
|
||||||
cnameTarget: cnameTargetForSync(binding),
|
cnameTarget: cnameTargetForSync(binding),
|
||||||
|
...(lbMode ? { lbMode } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,6 +215,7 @@ export async function buildAllSyncBindings(
|
|||||||
const bindings = repos.listAllBindings(db);
|
const bindings = repos.listAllBindings(db);
|
||||||
const index = buildBindingIndex(bindings);
|
const index = buildBindingIndex(bindings);
|
||||||
const serviceIpCache = new Map<number, string[]>();
|
const serviceIpCache = new Map<number, string[]>();
|
||||||
|
const groupLbCache = new Map<number, LbMode | undefined>();
|
||||||
|
|
||||||
const items: CfdmBindingSyncItem[] = [];
|
const items: CfdmBindingSyncItem[] = [];
|
||||||
for (const binding of bindings) {
|
for (const binding of bindings) {
|
||||||
@@ -175,6 +225,8 @@ export async function buildAllSyncBindings(
|
|||||||
serviceIpCache.set(binding.service_id, serviceIps);
|
serviceIpCache.set(binding.service_id, serviceIps);
|
||||||
}
|
}
|
||||||
const ips = await resolveBindingIpsForSync(binding, serviceIps, index, db);
|
const ips = await resolveBindingIpsForSync(binding, serviceIps, index, db);
|
||||||
|
const groupLb = groupLbModeForService(db, binding.service_id, groupLbCache);
|
||||||
|
const lbMode = effectiveLbModeForSync(binding.lb_mode, groupLb, serviceIps);
|
||||||
items.push({
|
items.push({
|
||||||
bindingId: binding.id,
|
bindingId: binding.id,
|
||||||
serviceId: binding.service_id,
|
serviceId: binding.service_id,
|
||||||
@@ -185,6 +237,7 @@ export async function buildAllSyncBindings(
|
|||||||
hostname: binding.hostname,
|
hostname: binding.hostname,
|
||||||
ips,
|
ips,
|
||||||
cnameTarget: cnameTargetForSync(binding),
|
cnameTarget: cnameTargetForSync(binding),
|
||||||
|
...(lbMode ? { lbMode } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return items;
|
return items;
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { AsyncTask, SimpleIntervalJob } from "toad-scheduler";
|
||||||
|
import * as serviceConfigService from "./service-config-service.js";
|
||||||
|
import { WEIGHTED_SLOT_MS } from "./routing/weighted.js";
|
||||||
|
|
||||||
|
export const WEIGHTED_DNS_JOB_ID = "weighted-dns";
|
||||||
|
|
||||||
|
export function createWeightedDnsTask(app: FastifyInstance): AsyncTask {
|
||||||
|
return new AsyncTask(
|
||||||
|
WEIGHTED_DNS_JOB_ID,
|
||||||
|
async () => {
|
||||||
|
const n = await serviceConfigService.reconcileWeightedDns(app.db, app.cf);
|
||||||
|
if (n > 0) {
|
||||||
|
app.log.info({ reconciled: n }, "weighted dns rotated");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(err) => {
|
||||||
|
app.log.warn({ err }, "weighted dns rotate failed");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scheduleWeightedDnsJob(
|
||||||
|
app: FastifyInstance,
|
||||||
|
task: AsyncTask,
|
||||||
|
): void {
|
||||||
|
const scheduler = app.scheduler;
|
||||||
|
if (!scheduler) return;
|
||||||
|
if (scheduler.existsById(WEIGHTED_DNS_JOB_ID)) {
|
||||||
|
scheduler.removeById(WEIGHTED_DNS_JOB_ID);
|
||||||
|
}
|
||||||
|
scheduler.addSimpleIntervalJob(
|
||||||
|
new SimpleIntervalJob(
|
||||||
|
{ seconds: WEIGHTED_SLOT_MS / 1000, runImmediately: true },
|
||||||
|
task,
|
||||||
|
{ id: WEIGHTED_DNS_JOB_ID, preventOverrun: true },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { createMemoryDb, repos, runMigrations } from "@cfdm/db";
|
||||||
|
import { SYNC_SYNCED } from "@cfdm/shared";
|
||||||
|
import type { CloudflareClient } from "../src/lib/cf-client.js";
|
||||||
|
import * as dnsService from "../src/services/dns-service.js";
|
||||||
|
import { updateConfig } from "../src/services/service-config-service.js";
|
||||||
|
|
||||||
|
type CfRec = {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
name: string;
|
||||||
|
content: string;
|
||||||
|
ttl: number;
|
||||||
|
proxied: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function setupDb() {
|
||||||
|
const { db, sqlite } = createMemoryDb();
|
||||||
|
runMigrations(sqlite);
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("DNS identity by name + content", () => {
|
||||||
|
it("deletes by name+IP when cached cf_record_id is stale/missing in CF", async () => {
|
||||||
|
const db = setupDb();
|
||||||
|
const remote: CfRec[] = [
|
||||||
|
{
|
||||||
|
id: "cf-live",
|
||||||
|
type: "A",
|
||||||
|
name: "nsgt.example.com",
|
||||||
|
content: "130.49.213.176",
|
||||||
|
ttl: 1,
|
||||||
|
proxied: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const deleted: string[] = [];
|
||||||
|
|
||||||
|
const cf = {
|
||||||
|
listDnsRecords: async () => [...remote],
|
||||||
|
createDnsRecord: async () => {
|
||||||
|
throw new Error("create should not run");
|
||||||
|
},
|
||||||
|
updateDnsRecord: async () => {
|
||||||
|
throw new Error("update should not run");
|
||||||
|
},
|
||||||
|
deleteDnsRecord: async (_zoneId: string, id: string) => {
|
||||||
|
deleted.push(id);
|
||||||
|
const idx = remote.findIndex((r) => r.id === id);
|
||||||
|
if (idx >= 0) remote.splice(idx, 1);
|
||||||
|
},
|
||||||
|
verifyToken: async () => true,
|
||||||
|
listZones: async () => [],
|
||||||
|
} as unknown as CloudflareClient;
|
||||||
|
|
||||||
|
const domain = repos.createDomain(db, null, "example.com", "zone-1");
|
||||||
|
const local = repos.insertDnsRecord(
|
||||||
|
db,
|
||||||
|
domain.id,
|
||||||
|
"A",
|
||||||
|
"nsgt.example.com",
|
||||||
|
"130.49.213.176",
|
||||||
|
1,
|
||||||
|
false,
|
||||||
|
null,
|
||||||
|
SYNC_SYNCED,
|
||||||
|
"local",
|
||||||
|
"cf-stale-gone", // not present in Cloudflare
|
||||||
|
);
|
||||||
|
|
||||||
|
await dnsService.deleteRecord(db, cf, domain.id, local.id);
|
||||||
|
|
||||||
|
expect(deleted).toEqual(["cf-live"]);
|
||||||
|
expect(repos.listDnsByDomain(db, domain.id)).toEqual([]);
|
||||||
|
expect(remote).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("delete is no-op success when record already removed outside CFDM", async () => {
|
||||||
|
const db = setupDb();
|
||||||
|
const cf = {
|
||||||
|
listDnsRecords: async () => [],
|
||||||
|
deleteDnsRecord: async () => {
|
||||||
|
throw new Error("Record does not exist");
|
||||||
|
},
|
||||||
|
verifyToken: async () => true,
|
||||||
|
listZones: async () => [],
|
||||||
|
} as unknown as CloudflareClient;
|
||||||
|
|
||||||
|
const domain = repos.createDomain(db, null, "example.com", "zone-1");
|
||||||
|
const local = repos.insertDnsRecord(
|
||||||
|
db,
|
||||||
|
domain.id,
|
||||||
|
"A",
|
||||||
|
"nsgt.example.com",
|
||||||
|
"130.49.213.176",
|
||||||
|
1,
|
||||||
|
false,
|
||||||
|
null,
|
||||||
|
SYNC_SYNCED,
|
||||||
|
"local",
|
||||||
|
"cf-already-gone",
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
dnsService.deleteRecord(db, cf, domain.id, local.id),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
expect(repos.listDnsByDomain(db, domain.id)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updateConfig can remove extra FQDN when CF id is stale", async () => {
|
||||||
|
const db = setupDb();
|
||||||
|
const remote: CfRec[] = [
|
||||||
|
{
|
||||||
|
id: "cf-gt",
|
||||||
|
type: "A",
|
||||||
|
name: "gt.example.com",
|
||||||
|
content: "130.49.213.176",
|
||||||
|
ttl: 1,
|
||||||
|
proxied: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "cf-nsgt-live",
|
||||||
|
type: "A",
|
||||||
|
name: "nsgt.example.com",
|
||||||
|
content: "130.49.213.176",
|
||||||
|
ttl: 1,
|
||||||
|
proxied: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const deleted: string[] = [];
|
||||||
|
|
||||||
|
const cf = {
|
||||||
|
listDnsRecords: async () => [...remote],
|
||||||
|
createDnsRecord: async (
|
||||||
|
_zoneId: string,
|
||||||
|
payload: { type: string; name: string; content: string },
|
||||||
|
) => {
|
||||||
|
const rec: CfRec = {
|
||||||
|
id: `cf-new-${remote.length}`,
|
||||||
|
type: payload.type,
|
||||||
|
name: payload.name,
|
||||||
|
content: payload.content,
|
||||||
|
ttl: 1,
|
||||||
|
proxied: false,
|
||||||
|
};
|
||||||
|
remote.push(rec);
|
||||||
|
return rec;
|
||||||
|
},
|
||||||
|
updateDnsRecord: async (
|
||||||
|
_zoneId: string,
|
||||||
|
id: string,
|
||||||
|
payload: { type: string; name: string; content: string },
|
||||||
|
) => {
|
||||||
|
const idx = remote.findIndex((r) => r.id === id);
|
||||||
|
if (idx < 0) throw new Error("Record does not exist");
|
||||||
|
const rec: CfRec = {
|
||||||
|
id,
|
||||||
|
type: payload.type,
|
||||||
|
name: payload.name,
|
||||||
|
content: payload.content,
|
||||||
|
ttl: 1,
|
||||||
|
proxied: false,
|
||||||
|
};
|
||||||
|
remote[idx] = rec;
|
||||||
|
return rec;
|
||||||
|
},
|
||||||
|
deleteDnsRecord: async (_zoneId: string, id: string) => {
|
||||||
|
const idx = remote.findIndex((r) => r.id === id);
|
||||||
|
if (idx < 0) throw new Error("Record does not exist");
|
||||||
|
deleted.push(id);
|
||||||
|
remote.splice(idx, 1);
|
||||||
|
},
|
||||||
|
verifyToken: async () => true,
|
||||||
|
listZones: async () => [
|
||||||
|
{ id: "zone-1", name: "example.com", status: "active" },
|
||||||
|
],
|
||||||
|
} as unknown as CloudflareClient;
|
||||||
|
|
||||||
|
repos.createDomain(db, null, "example.com", "zone-1");
|
||||||
|
const service = repos.createService(db, "Main TG", "tg-pr");
|
||||||
|
repos.setServiceEnabled(db, service.id, true);
|
||||||
|
|
||||||
|
await updateConfig(db, cf, service.id, {
|
||||||
|
ips: ["130.49.213.176"],
|
||||||
|
domains: [
|
||||||
|
{ fqdn: "gt.example.com", target_ips: ["130.49.213.176"] },
|
||||||
|
{ fqdn: "nsgt.example.com", target_ips: ["130.49.213.176"] },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Poison cached id on extra binding's DNS row.
|
||||||
|
const nsgtBinding = repos
|
||||||
|
.listBindingsByService(db, service.id)
|
||||||
|
.find((b) => b.hostname === "nsgt")!;
|
||||||
|
const nsgtRecords = repos.listRecordsForBinding(db, nsgtBinding.id);
|
||||||
|
for (const row of nsgtRecords) {
|
||||||
|
repos.updateDnsFields(
|
||||||
|
db,
|
||||||
|
row.id,
|
||||||
|
row.record_type,
|
||||||
|
row.name,
|
||||||
|
row.content,
|
||||||
|
row.ttl,
|
||||||
|
row.proxied,
|
||||||
|
row.priority,
|
||||||
|
SYNC_SYNCED,
|
||||||
|
"cf-stale-nsgt",
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const view = await updateConfig(db, cf, service.id, {
|
||||||
|
ips: ["130.49.213.176"],
|
||||||
|
domains: [{ fqdn: "gt.example.com", target_ips: ["130.49.213.176"] }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(view.domains.map((d) => d.fqdn)).toEqual(["gt.example.com"]);
|
||||||
|
expect(deleted).toContain("cf-nsgt-live");
|
||||||
|
expect(remote.some((r) => r.name.includes("nsgt"))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { createMemoryDb, repos, runMigrations } from "@cfdm/db";
|
||||||
|
import { SYNC_SYNCED } from "@cfdm/shared";
|
||||||
|
import type { CloudflareClient } from "../src/lib/cf-client.js";
|
||||||
|
import { updateConfig } from "../src/services/service-config-service.js";
|
||||||
|
|
||||||
|
type CfRec = {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
name: string;
|
||||||
|
content: string;
|
||||||
|
ttl: number;
|
||||||
|
proxied: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function setupDb() {
|
||||||
|
const { db, sqlite } = createMemoryDb();
|
||||||
|
runMigrations(sqlite);
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("DNS reconcile for extra FQDN", () => {
|
||||||
|
it("publishes extra A even when local/CF already have CNAME on same name", async () => {
|
||||||
|
const db = setupDb();
|
||||||
|
const remote: CfRec[] = [
|
||||||
|
{
|
||||||
|
id: "cf-cname-nsgt",
|
||||||
|
type: "CNAME",
|
||||||
|
name: "nsgt.example.com",
|
||||||
|
content: "legacy.example.com",
|
||||||
|
ttl: 1,
|
||||||
|
proxied: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const created: Array<{ type: string; name: string; content: string }> = [];
|
||||||
|
const deleted: string[] = [];
|
||||||
|
|
||||||
|
const cf = {
|
||||||
|
listDnsRecords: async () => remote,
|
||||||
|
createDnsRecord: async (
|
||||||
|
_zoneId: string,
|
||||||
|
payload: { type: string; name: string; content: string },
|
||||||
|
) => {
|
||||||
|
created.push(payload);
|
||||||
|
const rec: CfRec = {
|
||||||
|
id: `cf-new-${created.length}`,
|
||||||
|
type: payload.type,
|
||||||
|
name: payload.name,
|
||||||
|
content: payload.content,
|
||||||
|
ttl: 1,
|
||||||
|
proxied: false,
|
||||||
|
};
|
||||||
|
remote.push(rec);
|
||||||
|
return rec;
|
||||||
|
},
|
||||||
|
updateDnsRecord: async (
|
||||||
|
_zoneId: string,
|
||||||
|
id: string,
|
||||||
|
payload: { type: string; name: string; content: string },
|
||||||
|
) => {
|
||||||
|
const idx = remote.findIndex((r) => r.id === id);
|
||||||
|
const rec: CfRec = {
|
||||||
|
id,
|
||||||
|
type: payload.type,
|
||||||
|
name: payload.name,
|
||||||
|
content: payload.content,
|
||||||
|
ttl: 1,
|
||||||
|
proxied: false,
|
||||||
|
};
|
||||||
|
if (idx >= 0) remote[idx] = rec;
|
||||||
|
else remote.push(rec);
|
||||||
|
return rec;
|
||||||
|
},
|
||||||
|
deleteDnsRecord: async (_zoneId: string, id: string) => {
|
||||||
|
deleted.push(id);
|
||||||
|
const idx = remote.findIndex((r) => r.id === id);
|
||||||
|
if (idx >= 0) remote.splice(idx, 1);
|
||||||
|
},
|
||||||
|
verifyToken: async () => true,
|
||||||
|
listZones: async () => [
|
||||||
|
{ id: "zone-1", name: "example.com", status: "active" },
|
||||||
|
],
|
||||||
|
} as unknown as CloudflareClient;
|
||||||
|
|
||||||
|
const domain = repos.createDomain(db, null, "example.com", "zone-1");
|
||||||
|
// Leftover local CNAME (previous service / manual import).
|
||||||
|
repos.insertDnsRecord(
|
||||||
|
db,
|
||||||
|
domain.id,
|
||||||
|
"CNAME",
|
||||||
|
"nsgt.example.com",
|
||||||
|
"legacy.example.com",
|
||||||
|
1,
|
||||||
|
false,
|
||||||
|
null,
|
||||||
|
SYNC_SYNCED,
|
||||||
|
"cloudflare",
|
||||||
|
"cf-cname-nsgt",
|
||||||
|
);
|
||||||
|
|
||||||
|
const service = repos.createService(db, "MSK Hip", "tg-msk-hip");
|
||||||
|
repos.setServiceEnabled(db, service.id, true);
|
||||||
|
|
||||||
|
const view = await updateConfig(db, cf, service.id, {
|
||||||
|
ips: ["130.49.213.176"],
|
||||||
|
domains: [
|
||||||
|
{
|
||||||
|
fqdn: "gt.example.com",
|
||||||
|
target_ips: ["130.49.213.176"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fqdn: "nsgt.example.com",
|
||||||
|
target_ips: ["130.49.213.176"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const nsgt = view.domains.find((d) => d.fqdn === "nsgt.example.com");
|
||||||
|
expect(nsgt?.record_type).toBe("A");
|
||||||
|
expect(nsgt?.target_ips).toEqual(["130.49.213.176"]);
|
||||||
|
expect(nsgt?.target_cname).toBeFalsy();
|
||||||
|
|
||||||
|
const binding = repos
|
||||||
|
.listBindingsByService(db, service.id)
|
||||||
|
.find((b) => b.hostname === "nsgt")!;
|
||||||
|
expect(repos.listBindingIps(db, binding.id)).toEqual(["130.49.213.176"]);
|
||||||
|
expect(deleted).toContain("cf-cname-nsgt");
|
||||||
|
expect(
|
||||||
|
created.some(
|
||||||
|
(r) =>
|
||||||
|
r.type === "A" &&
|
||||||
|
(r.name === "nsgt" || r.name === "nsgt.example.com") &&
|
||||||
|
r.content === "130.49.213.176",
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
expect(remote.some((r) => r.type === "CNAME" && r.name.includes("nsgt"))).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
remote.some(
|
||||||
|
(r) =>
|
||||||
|
r.type === "A" &&
|
||||||
|
r.name.includes("nsgt") &&
|
||||||
|
r.content === "130.49.213.176",
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces stale A content for extra FQDN on the same hostname", async () => {
|
||||||
|
const db = setupDb();
|
||||||
|
const remote: CfRec[] = [
|
||||||
|
{
|
||||||
|
id: "cf-stale-a",
|
||||||
|
type: "A",
|
||||||
|
name: "nsgt.example.com",
|
||||||
|
content: "1.1.1.1",
|
||||||
|
ttl: 1,
|
||||||
|
proxied: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const deleted: string[] = [];
|
||||||
|
const created: Array<{ type: string; name: string; content: string }> = [];
|
||||||
|
|
||||||
|
const cf = {
|
||||||
|
listDnsRecords: async () => [...remote],
|
||||||
|
createDnsRecord: async (
|
||||||
|
_zoneId: string,
|
||||||
|
payload: { type: string; name: string; content: string },
|
||||||
|
) => {
|
||||||
|
created.push(payload);
|
||||||
|
const rec: CfRec = {
|
||||||
|
id: `cf-new-${created.length}`,
|
||||||
|
type: payload.type,
|
||||||
|
name: payload.name,
|
||||||
|
content: payload.content,
|
||||||
|
ttl: 1,
|
||||||
|
proxied: false,
|
||||||
|
};
|
||||||
|
remote.push(rec);
|
||||||
|
return rec;
|
||||||
|
},
|
||||||
|
updateDnsRecord: async (
|
||||||
|
_zoneId: string,
|
||||||
|
id: string,
|
||||||
|
payload: { type: string; name: string; content: string },
|
||||||
|
) => ({
|
||||||
|
id,
|
||||||
|
type: payload.type,
|
||||||
|
name: payload.name,
|
||||||
|
content: payload.content,
|
||||||
|
ttl: 1,
|
||||||
|
proxied: false,
|
||||||
|
}),
|
||||||
|
deleteDnsRecord: async (_zoneId: string, id: string) => {
|
||||||
|
deleted.push(id);
|
||||||
|
const idx = remote.findIndex((r) => r.id === id);
|
||||||
|
if (idx >= 0) remote.splice(idx, 1);
|
||||||
|
},
|
||||||
|
verifyToken: async () => true,
|
||||||
|
listZones: async () => [
|
||||||
|
{ id: "zone-1", name: "example.com", status: "active" },
|
||||||
|
],
|
||||||
|
} as unknown as CloudflareClient;
|
||||||
|
|
||||||
|
repos.createDomain(db, null, "example.com", "zone-1");
|
||||||
|
const service = repos.createService(db, "MSK", "msk");
|
||||||
|
repos.setServiceEnabled(db, service.id, true);
|
||||||
|
|
||||||
|
await updateConfig(db, cf, service.id, {
|
||||||
|
ips: ["130.49.213.176"],
|
||||||
|
domains: [
|
||||||
|
{ fqdn: "nsgt.example.com", target_ips: ["130.49.213.176"] },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(deleted).toContain("cf-stale-a");
|
||||||
|
expect(
|
||||||
|
created.some(
|
||||||
|
(r) => r.type === "A" && r.content === "130.49.213.176",
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
expect(remote.map((r) => r.content)).toEqual(["130.49.213.176"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -376,6 +376,7 @@ describe("CNAME health mapped onto service IPs", () => {
|
|||||||
repos.replaceServiceIps(db, service.id, ["2.59.161.102"]);
|
repos.replaceServiceIps(db, service.id, ["2.59.161.102"]);
|
||||||
const binding = repos.insertBinding(db, domain.id, service.id, "s", null);
|
const binding = repos.insertBinding(db, domain.id, service.id, "s", null);
|
||||||
repos.setBindingCnameTarget(db, binding.id, "ihome.rkns.top");
|
repos.setBindingCnameTarget(db, binding.id, "ihome.rkns.top");
|
||||||
|
repos.updateBindingLbConfig(db, binding.id, { health_check_enabled: true });
|
||||||
repos.upsertIpHealthStatus(
|
repos.upsertIpHealthStatus(
|
||||||
db,
|
db,
|
||||||
"binding",
|
"binding",
|
||||||
@@ -412,6 +413,7 @@ describe("CNAME health mapped onto service IPs", () => {
|
|||||||
{ ip: "10.0.0.1", weight: 1, priority: 1 },
|
{ ip: "10.0.0.1", weight: 1, priority: 1 },
|
||||||
{ ip: "10.0.0.2", weight: 1, priority: 1 },
|
{ ip: "10.0.0.2", weight: 1, priority: 1 },
|
||||||
]);
|
]);
|
||||||
|
repos.updateBindingLbConfig(db, binding.id, { health_check_enabled: true });
|
||||||
repos.upsertIpHealthStatus(
|
repos.upsertIpHealthStatus(
|
||||||
db,
|
db,
|
||||||
"binding",
|
"binding",
|
||||||
@@ -436,4 +438,117 @@ describe("CNAME health mapped onto service IPs", () => {
|
|||||||
const view = await getView(db, service.id);
|
const view = await getView(db, service.id);
|
||||||
expect(view.health_status).toBe("up");
|
expect(view.health_status).toBe("up");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("getView shows live OK over hysteresis unknown", async () => {
|
||||||
|
const { createMemoryDb, repos, runMigrations } = await import("@cfdm/db");
|
||||||
|
const { getView } = await import("../src/services/service-config-service.js");
|
||||||
|
const { db, sqlite } = createMemoryDb();
|
||||||
|
runMigrations(sqlite);
|
||||||
|
|
||||||
|
const domain = repos.createDomain(db, null, "rkns.top", "zone-id");
|
||||||
|
const service = repos.createService(db, "RW Panel", "rw-panel");
|
||||||
|
repos.replaceServiceIps(db, service.id, ["2.59.161.102"]);
|
||||||
|
const binding = repos.insertBinding(db, domain.id, service.id, "c", null);
|
||||||
|
repos.replaceBindingIpsWithMeta(db, binding.id, [
|
||||||
|
{ ip: "2.59.161.102", weight: 1, priority: 1 },
|
||||||
|
]);
|
||||||
|
repos.updateBindingLbConfig(db, binding.id, { health_check_enabled: true });
|
||||||
|
repos.upsertIpHealthStatus(
|
||||||
|
db,
|
||||||
|
"binding",
|
||||||
|
binding.id,
|
||||||
|
"2.59.161.102",
|
||||||
|
"unknown",
|
||||||
|
63,
|
||||||
|
0,
|
||||||
|
null,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
repos.insertHealthProbeLog(db, {
|
||||||
|
scope: "binding",
|
||||||
|
refId: binding.id,
|
||||||
|
ip: "2.59.161.102",
|
||||||
|
provider: "local",
|
||||||
|
status: "up",
|
||||||
|
ok: true,
|
||||||
|
latencyMs: 63,
|
||||||
|
colo: null,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const view = await getView(db, service.id);
|
||||||
|
expect(view.health_status).toBe("up");
|
||||||
|
expect(view.ip_health[0]?.status).toBe("up");
|
||||||
|
expect(view.ip_health[0]?.latency_ms).toBe(63);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getView masks stale down when health-check is disabled", async () => {
|
||||||
|
const { createMemoryDb, repos, runMigrations } = await import("@cfdm/db");
|
||||||
|
const { getView } = await import("../src/services/service-config-service.js");
|
||||||
|
const { db, sqlite } = createMemoryDb();
|
||||||
|
runMigrations(sqlite);
|
||||||
|
|
||||||
|
const domain = repos.createDomain(db, null, "rkns.top", "zone-id");
|
||||||
|
const service = repos.createService(db, "Main TG", "main-tg");
|
||||||
|
repos.setServiceEnabled(db, service.id, true);
|
||||||
|
repos.replaceServiceIps(db, service.id, ["130.49.213.176"]);
|
||||||
|
const binding = repos.insertBinding(db, domain.id, service.id, "gt", null);
|
||||||
|
repos.replaceBindingIpsWithMeta(db, binding.id, [
|
||||||
|
{ ip: "130.49.213.176", weight: 1, priority: 1 },
|
||||||
|
]);
|
||||||
|
repos.updateBindingLbConfig(db, binding.id, { health_check_enabled: false });
|
||||||
|
repos.upsertIpHealthStatus(
|
||||||
|
db,
|
||||||
|
"binding",
|
||||||
|
binding.id,
|
||||||
|
"130.49.213.176",
|
||||||
|
"down",
|
||||||
|
null,
|
||||||
|
5,
|
||||||
|
"timeout",
|
||||||
|
);
|
||||||
|
|
||||||
|
const view = await getView(db, service.id);
|
||||||
|
expect(view.health_status).toBe("unknown");
|
||||||
|
expect(view.ip_health).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
ip: "130.49.213.176",
|
||||||
|
status: "unknown",
|
||||||
|
latency_ms: null,
|
||||||
|
last_error: null,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getView masks stale down when IP is disabled in pool", async () => {
|
||||||
|
const { createMemoryDb, repos, runMigrations } = await import("@cfdm/db");
|
||||||
|
const { getView } = await import("../src/services/service-config-service.js");
|
||||||
|
const { db, sqlite } = createMemoryDb();
|
||||||
|
runMigrations(sqlite);
|
||||||
|
|
||||||
|
const domain = repos.createDomain(db, null, "rkns.top", "zone-id");
|
||||||
|
const service = repos.createService(db, "Main TG", "main-tg");
|
||||||
|
repos.setServiceEnabled(db, service.id, true);
|
||||||
|
repos.replaceServiceIps(db, service.id, ["130.49.213.176"]);
|
||||||
|
repos.setServiceIpEnabled(db, service.id, "130.49.213.176", false);
|
||||||
|
const binding = repos.insertBinding(db, domain.id, service.id, "gt", null);
|
||||||
|
repos.replaceBindingIpsWithMeta(db, binding.id, [
|
||||||
|
{ ip: "130.49.213.176", weight: 1, priority: 1 },
|
||||||
|
]);
|
||||||
|
repos.updateBindingLbConfig(db, binding.id, { health_check_enabled: true });
|
||||||
|
repos.upsertIpHealthStatus(
|
||||||
|
db,
|
||||||
|
"binding",
|
||||||
|
binding.id,
|
||||||
|
"130.49.213.176",
|
||||||
|
"down",
|
||||||
|
null,
|
||||||
|
5,
|
||||||
|
"timeout",
|
||||||
|
);
|
||||||
|
|
||||||
|
const view = await getView(db, service.id);
|
||||||
|
expect(view.health_status).toBe("unknown");
|
||||||
|
expect(view.ip_health[0]?.status).toBe("unknown");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
|
resolveDesiredAIps,
|
||||||
selectActiveIpsByMode,
|
selectActiveIpsByMode,
|
||||||
|
shouldRecordFailoverDnsDiff,
|
||||||
type LbIpRow,
|
type LbIpRow,
|
||||||
type LbTargetConfig,
|
type LbTargetConfig,
|
||||||
} from "../src/services/service-config-service.js";
|
} from "../src/services/service-config-service.js";
|
||||||
|
import { WEIGHTED_SLOT_MS } from "../src/services/routing/weighted.js";
|
||||||
|
|
||||||
function row(
|
function row(
|
||||||
ip: string,
|
ip: string,
|
||||||
@@ -17,6 +20,11 @@ function row(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const weightedConfig: LbTargetConfig = {
|
||||||
|
lb_mode: "weighted",
|
||||||
|
health_check_enabled: true,
|
||||||
|
};
|
||||||
|
|
||||||
describe("selectActiveIpsByMode", () => {
|
describe("selectActiveIpsByMode", () => {
|
||||||
it("round_robin returns all healthy ips, falls back to all if none healthy", () => {
|
it("round_robin returns all healthy ips, falls back to all if none healthy", () => {
|
||||||
const config: LbTargetConfig = {
|
const config: LbTargetConfig = {
|
||||||
@@ -62,6 +70,18 @@ describe("selectActiveIpsByMode", () => {
|
|||||||
expect(selectActiveIpsByMode(config, rows)).toEqual(["1.1.1.1"]);
|
expect(selectActiveIpsByMode(config, rows)).toEqual(["1.1.1.1"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("failover returns recovering unknown primary immediately", () => {
|
||||||
|
const config: LbTargetConfig = {
|
||||||
|
lb_mode: "failover",
|
||||||
|
health_check_enabled: true,
|
||||||
|
};
|
||||||
|
const rows = [
|
||||||
|
row("1.1.1.1", { priority: 1, health: "unknown" }),
|
||||||
|
row("2.2.2.2", { priority: 2, health: "up" }),
|
||||||
|
];
|
||||||
|
expect(selectActiveIpsByMode(config, rows)).toEqual(["1.1.1.1"]);
|
||||||
|
});
|
||||||
|
|
||||||
it("failover falls back to min-priority ip among all when none healthy", () => {
|
it("failover falls back to min-priority ip among all when none healthy", () => {
|
||||||
const config: LbTargetConfig = {
|
const config: LbTargetConfig = {
|
||||||
lb_mode: "failover",
|
lb_mode: "failover",
|
||||||
@@ -75,23 +95,61 @@ describe("selectActiveIpsByMode", () => {
|
|||||||
expect(selectActiveIpsByMode(config, rows)).toEqual(["2.2.2.2"]);
|
expect(selectActiveIpsByMode(config, rows)).toEqual(["2.2.2.2"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("weighted returns all healthy ips (one A per ip; weights stored for display)", () => {
|
it("weighted 1:3 picks the lighter ip on slot 0 and the heavier on slot 1", () => {
|
||||||
const config: LbTargetConfig = {
|
|
||||||
lb_mode: "weighted",
|
|
||||||
health_check_enabled: true,
|
|
||||||
};
|
|
||||||
const rows = [
|
const rows = [
|
||||||
row("1.1.1.1", { weight: 3, health: "up" }),
|
row("1.1.1.1", { weight: 1, health: "up" }),
|
||||||
row("2.2.2.2", { weight: 1, health: "up" }),
|
row("2.2.2.2", { weight: 3, health: "up" }),
|
||||||
row("3.3.3.3", { weight: 2, health: "down" }),
|
|
||||||
];
|
];
|
||||||
expect(selectActiveIpsByMode(config, rows).sort()).toEqual([
|
expect(selectActiveIpsByMode(weightedConfig, rows, 0)).toEqual(["1.1.1.1"]);
|
||||||
"1.1.1.1",
|
expect(selectActiveIpsByMode(weightedConfig, rows, WEIGHTED_SLOT_MS)).toEqual([
|
||||||
"2.2.2.2",
|
"2.2.2.2",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("round_robin excludes unknown when another ip is up", () => {
|
it("weighted excludes down ips from the cycle", () => {
|
||||||
|
const rows = [
|
||||||
|
row("1.1.1.1", { weight: 1, health: "up" }),
|
||||||
|
row("2.2.2.2", { weight: 3, health: "down" }),
|
||||||
|
];
|
||||||
|
expect(selectActiveIpsByMode(weightedConfig, rows, 0)).toEqual(["1.1.1.1"]);
|
||||||
|
expect(
|
||||||
|
selectActiveIpsByMode(weightedConfig, rows, WEIGHTED_SLOT_MS),
|
||||||
|
).toEqual(["1.1.1.1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("weighted includes recovering unknown in the cycle", () => {
|
||||||
|
const rows = [
|
||||||
|
row("1.1.1.1", { weight: 1, health: "up" }),
|
||||||
|
row("2.2.2.2", { weight: 3, health: "unknown" }),
|
||||||
|
];
|
||||||
|
expect(selectActiveIpsByMode(weightedConfig, rows, 0)).toEqual(["1.1.1.1"]);
|
||||||
|
expect(
|
||||||
|
selectActiveIpsByMode(weightedConfig, rows, WEIGHTED_SLOT_MS),
|
||||||
|
).toEqual(["2.2.2.2"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("weighted with one ip always returns that ip", () => {
|
||||||
|
expect(
|
||||||
|
selectActiveIpsByMode(weightedConfig, [row("1.1.1.1", { weight: 5 })], 0),
|
||||||
|
).toEqual(["1.1.1.1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("weighted with all unknown rotates across every ip", () => {
|
||||||
|
const rows = [
|
||||||
|
row("1.1.1.1", { weight: 1, health: "unknown" }),
|
||||||
|
row("2.2.2.2", { weight: 3, health: "unknown" }),
|
||||||
|
];
|
||||||
|
expect(selectActiveIpsByMode(weightedConfig, rows, 0)).toEqual(["1.1.1.1"]);
|
||||||
|
expect(selectActiveIpsByMode(weightedConfig, rows, WEIGHTED_SLOT_MS)).toEqual([
|
||||||
|
"2.2.2.2",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("weighted returns empty array for no rows", () => {
|
||||||
|
expect(selectActiveIpsByMode(weightedConfig, [], 0)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round_robin puts recovering unknown back with live ips", () => {
|
||||||
const config: LbTargetConfig = {
|
const config: LbTargetConfig = {
|
||||||
lb_mode: "round_robin",
|
lb_mode: "round_robin",
|
||||||
health_check_enabled: true,
|
health_check_enabled: true,
|
||||||
@@ -100,7 +158,25 @@ describe("selectActiveIpsByMode", () => {
|
|||||||
row("1.1.1.1", { health: "up" }),
|
row("1.1.1.1", { health: "up" }),
|
||||||
row("2.2.2.2", { health: "unknown" }),
|
row("2.2.2.2", { health: "unknown" }),
|
||||||
];
|
];
|
||||||
expect(selectActiveIpsByMode(config, rows)).toEqual(["1.1.1.1"]);
|
expect(selectActiveIpsByMode(config, rows).sort()).toEqual([
|
||||||
|
"1.1.1.1",
|
||||||
|
"2.2.2.2",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round_robin keeps degraded in the pool with live ips", () => {
|
||||||
|
const config: LbTargetConfig = {
|
||||||
|
lb_mode: "round_robin",
|
||||||
|
health_check_enabled: true,
|
||||||
|
};
|
||||||
|
const rows = [
|
||||||
|
row("1.1.1.1", { health: "up" }),
|
||||||
|
row("2.2.2.2", { health: "degraded" }),
|
||||||
|
];
|
||||||
|
expect(selectActiveIpsByMode(config, rows).sort()).toEqual([
|
||||||
|
"1.1.1.1",
|
||||||
|
"2.2.2.2",
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns empty array for no rows", () => {
|
it("returns empty array for no rows", () => {
|
||||||
@@ -111,3 +187,115 @@ describe("selectActiveIpsByMode", () => {
|
|||||||
expect(selectActiveIpsByMode(config, [])).toEqual([]);
|
expect(selectActiveIpsByMode(config, [])).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("resolveDesiredAIps", () => {
|
||||||
|
it("keeps a dedicated single IP even when down", () => {
|
||||||
|
expect(
|
||||||
|
resolveDesiredAIps(
|
||||||
|
weightedConfig,
|
||||||
|
[row("1.1.1.1", { health: "down" })],
|
||||||
|
["1.1.1.1"],
|
||||||
|
),
|
||||||
|
).toEqual(["1.1.1.1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not drain when the service has only one unique IP", () => {
|
||||||
|
expect(
|
||||||
|
resolveDesiredAIps(
|
||||||
|
weightedConfig,
|
||||||
|
[row("1.1.1.1", { health: "down" })],
|
||||||
|
["1.1.1.1", "1.1.1.1"],
|
||||||
|
0,
|
||||||
|
["1.1.1.1"],
|
||||||
|
),
|
||||||
|
).toEqual(["1.1.1.1", "1.1.1.1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not apply overlay when service pool is a single IP", () => {
|
||||||
|
const rows = [
|
||||||
|
row("1.1.1.1", { health: "up" }),
|
||||||
|
row("2.2.2.2", { health: "down" }),
|
||||||
|
];
|
||||||
|
expect(
|
||||||
|
resolveDesiredAIps(
|
||||||
|
weightedConfig,
|
||||||
|
rows,
|
||||||
|
["1.1.1.1", "2.2.2.2"],
|
||||||
|
0,
|
||||||
|
["1.1.1.1"],
|
||||||
|
),
|
||||||
|
).toEqual(["1.1.1.1", "2.2.2.2"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies weighted overlay on a shared pool", () => {
|
||||||
|
const rows = [
|
||||||
|
row("1.1.1.1", { weight: 1, health: "up" }),
|
||||||
|
row("2.2.2.2", { weight: 3, health: "up" }),
|
||||||
|
];
|
||||||
|
expect(
|
||||||
|
resolveDesiredAIps(weightedConfig, rows, ["1.1.1.1", "2.2.2.2"], 0),
|
||||||
|
).toEqual(selectActiveIpsByMode(weightedConfig, rows, 0));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("shouldRecordFailoverDnsDiff", () => {
|
||||||
|
it("skips duplicate listings of the same IP", () => {
|
||||||
|
expect(
|
||||||
|
shouldRecordFailoverDnsDiff({
|
||||||
|
configuredIps: ["1.1.1.1", "1.1.1.1"],
|
||||||
|
lbMode: "failover",
|
||||||
|
added: [],
|
||||||
|
removed: ["1.1.1.1"],
|
||||||
|
downIps: new Set(["1.1.1.1"]),
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips dedicated extra-FQDN", () => {
|
||||||
|
expect(
|
||||||
|
shouldRecordFailoverDnsDiff({
|
||||||
|
configuredIps: ["1.1.1.1"],
|
||||||
|
lbMode: "failover",
|
||||||
|
added: [],
|
||||||
|
removed: ["1.1.1.1"],
|
||||||
|
downIps: new Set(["1.1.1.1"]),
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips weighted live-to-live slot swap", () => {
|
||||||
|
expect(
|
||||||
|
shouldRecordFailoverDnsDiff({
|
||||||
|
configuredIps: ["1.1.1.1", "2.2.2.2"],
|
||||||
|
lbMode: "weighted",
|
||||||
|
added: ["2.2.2.2"],
|
||||||
|
removed: ["1.1.1.1"],
|
||||||
|
downIps: new Set(),
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs weighted swap when a down ip leaves the pool", () => {
|
||||||
|
expect(
|
||||||
|
shouldRecordFailoverDnsDiff({
|
||||||
|
configuredIps: ["1.1.1.1", "2.2.2.2"],
|
||||||
|
lbMode: "weighted",
|
||||||
|
added: ["2.2.2.2"],
|
||||||
|
removed: ["1.1.1.1"],
|
||||||
|
downIps: new Set(["1.1.1.1"]),
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs failover diffs on a shared pool", () => {
|
||||||
|
expect(
|
||||||
|
shouldRecordFailoverDnsDiff({
|
||||||
|
configuredIps: ["1.1.1.1", "2.2.2.2"],
|
||||||
|
lbMode: "failover",
|
||||||
|
added: ["2.2.2.2"],
|
||||||
|
removed: ["1.1.1.1"],
|
||||||
|
downIps: new Set(["1.1.1.1"]),
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { buildApp } from "../src/app.js";
|
|||||||
import { loadConfig } from "../src/config.js";
|
import { loadConfig } from "../src/config.js";
|
||||||
import {
|
import {
|
||||||
listGroupViews,
|
listGroupViews,
|
||||||
|
toggleServiceIp,
|
||||||
updateConfig,
|
updateConfig,
|
||||||
} from "../src/services/service-config-service.js";
|
} from "../src/services/service-config-service.js";
|
||||||
|
|
||||||
@@ -250,7 +251,7 @@ describe("create service then list groups", () => {
|
|||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("PATCH /services/:id/ips/toggle keeps IP in pool and removes it from A-binding", async () => {
|
it("PATCH /services/:id/ips/toggle keeps IP in pool and in A-binding", async () => {
|
||||||
const app = await buildApp({
|
const app = await buildApp({
|
||||||
config: { ...loadConfig(), staticDir: null },
|
config: { ...loadConfig(), staticDir: null },
|
||||||
memory: true,
|
memory: true,
|
||||||
@@ -280,6 +281,18 @@ describe("create service then list groups", () => {
|
|||||||
expect(createRes.statusCode).toBe(200);
|
expect(createRes.statusCode).toBe(200);
|
||||||
const created = createRes.json() as { id: number };
|
const created = createRes.json() as { id: number };
|
||||||
|
|
||||||
|
const domainPayload = {
|
||||||
|
lb_mode: "round_robin" as const,
|
||||||
|
health_check_enabled: false,
|
||||||
|
health_check_type: "tcp" as const,
|
||||||
|
health_check_port: 443,
|
||||||
|
health_check_path: null,
|
||||||
|
health_check_expected_status: null,
|
||||||
|
health_check_interval_sec: 30,
|
||||||
|
health_check_timeout_ms: 3000,
|
||||||
|
health_check_verify_tls: false,
|
||||||
|
};
|
||||||
|
|
||||||
await updateConfig(app.db, cf, created.id, {
|
await updateConfig(app.db, cf, created.id, {
|
||||||
ips: ["1.2.3.4", "5.6.7.8"],
|
ips: ["1.2.3.4", "5.6.7.8"],
|
||||||
service_group_id: group.id,
|
service_group_id: group.id,
|
||||||
@@ -289,23 +302,81 @@ describe("create service then list groups", () => {
|
|||||||
target_ips: ["1.2.3.4", "5.6.7.8"],
|
target_ips: ["1.2.3.4", "5.6.7.8"],
|
||||||
target_ip_weights: { "1.2.3.4": 1, "5.6.7.8": 1 },
|
target_ip_weights: { "1.2.3.4": 1, "5.6.7.8": 1 },
|
||||||
target_ip_priorities: { "1.2.3.4": 1, "5.6.7.8": 1 },
|
target_ip_priorities: { "1.2.3.4": 1, "5.6.7.8": 1 },
|
||||||
lb_mode: "round_robin",
|
...domainPayload,
|
||||||
health_check_enabled: false,
|
},
|
||||||
health_check_type: "tcp",
|
{
|
||||||
health_check_port: 443,
|
fqdn: "extra.example.com",
|
||||||
health_check_path: null,
|
target_ips: ["1.2.3.4"],
|
||||||
health_check_expected_status: null,
|
target_ip_weights: { "1.2.3.4": 1 },
|
||||||
health_check_interval_sec: 30,
|
target_ip_priorities: { "1.2.3.4": 1 },
|
||||||
health_check_timeout_ms: 3000,
|
...domainPayload,
|
||||||
health_check_verify_tls: false,
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
// HTTP toggle uses request.server.cf; disable DNS push so the test
|
const commonBinding = repos
|
||||||
// does not call the real Cloudflare client.
|
.listBindingsByService(app.db, created.id)
|
||||||
repos.setServiceEnabled(app.db, created.id, false);
|
.find((b) => b.hostname === "panel")!;
|
||||||
|
const extraBinding = repos
|
||||||
|
.listBindingsByService(app.db, created.id)
|
||||||
|
.find((b) => b.hostname === "extra")!;
|
||||||
|
expect(repos.listBindingIps(app.db, commonBinding.id)).toEqual(
|
||||||
|
expect.arrayContaining(["1.2.3.4", "5.6.7.8"]),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
repos
|
||||||
|
.listRecordsForBinding(app.db, commonBinding.id)
|
||||||
|
.map((r) => r.content)
|
||||||
|
.sort(),
|
||||||
|
).toEqual(["1.2.3.4", "5.6.7.8"]);
|
||||||
|
|
||||||
|
// Direct service call with mock CF — keep HTTP path free of real Cloudflare.
|
||||||
|
await toggleServiceIp(app.db, cf, created.id, "1.2.3.4", false);
|
||||||
|
|
||||||
|
expect(repos.listServiceIps(app.db, created.id)).toEqual(
|
||||||
|
expect.arrayContaining(["1.2.3.4", "5.6.7.8"]),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
repos.listServiceIpRows(app.db, created.id).find((r) => r.ip === "1.2.3.4")
|
||||||
|
?.enabled,
|
||||||
|
).toBe(false);
|
||||||
|
// Common + per-IP bindings keep configured IPs (UI hydrate stays stable).
|
||||||
|
expect(repos.listBindingIps(app.db, commonBinding.id)).toEqual(
|
||||||
|
expect.arrayContaining(["1.2.3.4", "5.6.7.8"]),
|
||||||
|
);
|
||||||
|
expect(repos.listBindingIps(app.db, extraBinding.id)).toEqual(["1.2.3.4"]);
|
||||||
|
// DNS for common FQDN drops the disabled IP only.
|
||||||
|
expect(
|
||||||
|
repos
|
||||||
|
.listRecordsForBinding(app.db, commonBinding.id)
|
||||||
|
.map((r) => r.content)
|
||||||
|
.sort(),
|
||||||
|
).toEqual(["5.6.7.8"]);
|
||||||
|
// Per-IP extra FQDN has no enabled targets → A records removed.
|
||||||
|
expect(repos.listRecordsForBinding(app.db, extraBinding.id)).toEqual([]);
|
||||||
|
|
||||||
|
await toggleServiceIp(app.db, cf, created.id, "1.2.3.4", true);
|
||||||
|
expect(
|
||||||
|
repos.listServiceIpRows(app.db, created.id).find((r) => r.ip === "1.2.3.4")
|
||||||
|
?.enabled,
|
||||||
|
).toBe(true);
|
||||||
|
expect(repos.listBindingIps(app.db, commonBinding.id)).toEqual(
|
||||||
|
expect.arrayContaining(["1.2.3.4", "5.6.7.8"]),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
repos
|
||||||
|
.listRecordsForBinding(app.db, commonBinding.id)
|
||||||
|
.map((r) => r.content)
|
||||||
|
.sort(),
|
||||||
|
).toEqual(["1.2.3.4", "5.6.7.8"]);
|
||||||
|
expect(
|
||||||
|
repos
|
||||||
|
.listRecordsForBinding(app.db, extraBinding.id)
|
||||||
|
.map((r) => r.content),
|
||||||
|
).toEqual(["1.2.3.4"]);
|
||||||
|
|
||||||
|
// HTTP toggle still updates ip_enabled without mutating bindings.
|
||||||
|
repos.setServiceEnabled(app.db, created.id, false);
|
||||||
const offRes = await app.inject({
|
const offRes = await app.inject({
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
url: `/api/v1/services/${created.id}/ips/toggle`,
|
url: `/api/v1/services/${created.id}/ips/toggle`,
|
||||||
@@ -319,24 +390,117 @@ describe("create service then list groups", () => {
|
|||||||
};
|
};
|
||||||
expect(offView.ips).toEqual(expect.arrayContaining(["1.2.3.4", "5.6.7.8"]));
|
expect(offView.ips).toEqual(expect.arrayContaining(["1.2.3.4", "5.6.7.8"]));
|
||||||
expect(offView.ip_enabled["1.2.3.4"]).toBe(false);
|
expect(offView.ip_enabled["1.2.3.4"]).toBe(false);
|
||||||
expect(offView.ip_enabled["5.6.7.8"]).toBe(true);
|
expect(repos.listBindingIps(app.db, commonBinding.id)).toEqual(
|
||||||
|
|
||||||
const binding = repos.listBindingsByService(app.db, created.id)[0]!;
|
|
||||||
expect(repos.listBindingIps(app.db, binding.id)).toEqual(["5.6.7.8"]);
|
|
||||||
|
|
||||||
const onRes = await app.inject({
|
|
||||||
method: "PATCH",
|
|
||||||
url: `/api/v1/services/${created.id}/ips/toggle`,
|
|
||||||
headers,
|
|
||||||
payload: { ip: "1.2.3.4", enabled: true },
|
|
||||||
});
|
|
||||||
expect(onRes.statusCode).toBe(200);
|
|
||||||
const onView = onRes.json() as { ip_enabled: Record<string, boolean> };
|
|
||||||
expect(onView.ip_enabled["1.2.3.4"]).toBe(true);
|
|
||||||
expect(repos.listBindingIps(app.db, binding.id)).toEqual(
|
|
||||||
expect.arrayContaining(["1.2.3.4", "5.6.7.8"]),
|
expect.arrayContaining(["1.2.3.4", "5.6.7.8"]),
|
||||||
);
|
);
|
||||||
|
|
||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("GET /services/:id repairs multi-IP bindings shrunk below the pool", async () => {
|
||||||
|
const app = await buildApp({
|
||||||
|
config: { ...loadConfig(), staticDir: null },
|
||||||
|
memory: true,
|
||||||
|
});
|
||||||
|
const headers = await authHeaders(app);
|
||||||
|
const cf = mockCf();
|
||||||
|
|
||||||
|
repos.createDomain(app.db, null, "example.com", "zone-1");
|
||||||
|
const group = repos.createServiceGroup(
|
||||||
|
app.db,
|
||||||
|
"VPN",
|
||||||
|
"vpn-repair",
|
||||||
|
null,
|
||||||
|
"vpn.example.com",
|
||||||
|
);
|
||||||
|
|
||||||
|
const createRes = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/v1/services",
|
||||||
|
headers,
|
||||||
|
payload: {
|
||||||
|
name: "Repair",
|
||||||
|
slug: "panel-ip-repair",
|
||||||
|
service_group_id: group.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(createRes.statusCode).toBe(200);
|
||||||
|
const created = createRes.json() as { id: number };
|
||||||
|
|
||||||
|
await updateConfig(app.db, cf, created.id, {
|
||||||
|
ips: ["1.2.3.4", "5.6.7.8", "9.9.9.9"],
|
||||||
|
service_group_id: group.id,
|
||||||
|
domains: [
|
||||||
|
{
|
||||||
|
fqdn: "gw.example.com",
|
||||||
|
target_ips: ["1.2.3.4", "5.6.7.8", "9.9.9.9"],
|
||||||
|
target_ip_weights: { "1.2.3.4": 1, "5.6.7.8": 1, "9.9.9.9": 1 },
|
||||||
|
target_ip_priorities: { "1.2.3.4": 1, "5.6.7.8": 1, "9.9.9.9": 1 },
|
||||||
|
lb_mode: "round_robin",
|
||||||
|
health_check_enabled: false,
|
||||||
|
health_check_type: "tcp",
|
||||||
|
health_check_port: 443,
|
||||||
|
health_check_path: null,
|
||||||
|
health_check_expected_status: null,
|
||||||
|
health_check_interval_sec: 30,
|
||||||
|
health_check_timeout_ms: 3000,
|
||||||
|
health_check_verify_tls: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fqdn: "extra.example.com",
|
||||||
|
target_ips: ["1.2.3.4"],
|
||||||
|
target_ip_weights: { "1.2.3.4": 1 },
|
||||||
|
target_ip_priorities: { "1.2.3.4": 1 },
|
||||||
|
lb_mode: "round_robin",
|
||||||
|
health_check_enabled: false,
|
||||||
|
health_check_type: "tcp",
|
||||||
|
health_check_port: 443,
|
||||||
|
health_check_path: null,
|
||||||
|
health_check_expected_status: null,
|
||||||
|
health_check_interval_sec: 30,
|
||||||
|
health_check_timeout_ms: 3000,
|
||||||
|
health_check_verify_tls: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const commonBinding = repos
|
||||||
|
.listBindingsByService(app.db, created.id)
|
||||||
|
.find((b) => b.hostname === "gw")!;
|
||||||
|
const extraBinding = repos
|
||||||
|
.listBindingsByService(app.db, created.id)
|
||||||
|
.find((b) => b.hostname === "extra")!;
|
||||||
|
|
||||||
|
// Simulate legacy toggle damage: shrink common binding, leave extra alone.
|
||||||
|
repos.replaceBindingIpsWithMeta(app.db, commonBinding.id, [
|
||||||
|
{ ip: "1.2.3.4", weight: 1, priority: 1 },
|
||||||
|
{ ip: "5.6.7.8", weight: 1, priority: 1 },
|
||||||
|
]);
|
||||||
|
expect(repos.listBindingIps(app.db, commonBinding.id)).toEqual([
|
||||||
|
"1.2.3.4",
|
||||||
|
"5.6.7.8",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const getRes = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: `/api/v1/services/${created.id}`,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
expect(getRes.statusCode).toBe(200);
|
||||||
|
const view = getRes.json() as {
|
||||||
|
domains: Array<{ fqdn: string; target_ips: string[] }>;
|
||||||
|
};
|
||||||
|
const gw = view.domains.find((d) => d.fqdn === "gw.example.com");
|
||||||
|
const extra = view.domains.find((d) => d.fqdn === "extra.example.com");
|
||||||
|
expect(gw?.target_ips.sort()).toEqual(["1.2.3.4", "5.6.7.8", "9.9.9.9"]);
|
||||||
|
expect(extra?.target_ips).toEqual(["1.2.3.4"]);
|
||||||
|
expect(repos.listBindingIps(app.db, commonBinding.id).sort()).toEqual([
|
||||||
|
"1.2.3.4",
|
||||||
|
"5.6.7.8",
|
||||||
|
"9.9.9.9",
|
||||||
|
]);
|
||||||
|
expect(repos.listBindingIps(app.db, extraBinding.id)).toEqual(["1.2.3.4"]);
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import type { ServiceBindingView } from "@cfdm/shared";
|
import {
|
||||||
import { resolveBindingIpsForSync } from "../src/services/vps-tracker-sync.js";
|
cfdmBindingSyncItemSchema,
|
||||||
|
type ServiceBindingView,
|
||||||
|
} from "@cfdm/shared";
|
||||||
|
import {
|
||||||
|
resolveBindingIpsForSync,
|
||||||
|
resolveLbModeForSync,
|
||||||
|
effectiveLbModeForSync,
|
||||||
|
} from "../src/services/vps-tracker-sync.js";
|
||||||
|
|
||||||
function binding(
|
function binding(
|
||||||
partial: Partial<ServiceBindingView> &
|
partial: Partial<ServiceBindingView> &
|
||||||
@@ -160,3 +167,71 @@ describe("resolveBindingIpsForSync", () => {
|
|||||||
expect(ips).toEqual(["203.0.113.55"]);
|
expect(ips).toEqual(["203.0.113.55"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("resolveLbModeForSync", () => {
|
||||||
|
it("prefers binding lb_mode", () => {
|
||||||
|
expect(resolveLbModeForSync("failover", "round_robin")).toBe("failover");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to service group lb_mode", () => {
|
||||||
|
expect(resolveLbModeForSync("off", "weighted")).toBe("weighted");
|
||||||
|
expect(resolveLbModeForSync(undefined, "round_robin")).toBe("round_robin");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined when neither is a known mode", () => {
|
||||||
|
expect(resolveLbModeForSync("off", "off")).toBeUndefined();
|
||||||
|
expect(resolveLbModeForSync(null, undefined)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("effectiveLbModeForSync", () => {
|
||||||
|
it("omits mode when unique origin IPs are below two", () => {
|
||||||
|
expect(
|
||||||
|
effectiveLbModeForSync("round_robin", "failover", ["203.0.113.10"]),
|
||||||
|
).toBeUndefined();
|
||||||
|
expect(
|
||||||
|
effectiveLbModeForSync("round_robin", "failover", [
|
||||||
|
"203.0.113.10",
|
||||||
|
"203.0.113.10",
|
||||||
|
]),
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits configured mode when the service has a pool", () => {
|
||||||
|
expect(
|
||||||
|
effectiveLbModeForSync("failover", "round_robin", [
|
||||||
|
"203.0.113.10",
|
||||||
|
"203.0.113.20",
|
||||||
|
]),
|
||||||
|
).toBe("failover");
|
||||||
|
expect(
|
||||||
|
effectiveLbModeForSync("off", "weighted", [
|
||||||
|
"203.0.113.10",
|
||||||
|
"198.51.100.1",
|
||||||
|
]),
|
||||||
|
).toBe("weighted");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("cfdmBindingSyncItemSchema lbMode", () => {
|
||||||
|
const base = {
|
||||||
|
bindingId: 1,
|
||||||
|
serviceId: 10,
|
||||||
|
serviceName: "VPN",
|
||||||
|
serviceSlug: "vpn",
|
||||||
|
fqdn: "vpn.example.com",
|
||||||
|
zoneName: "example.com",
|
||||||
|
hostname: "vpn",
|
||||||
|
ips: ["203.0.113.10"],
|
||||||
|
};
|
||||||
|
|
||||||
|
it("accepts optional lbMode on sync payload", () => {
|
||||||
|
const parsed = cfdmBindingSyncItemSchema.parse({ ...base, lbMode: "failover" });
|
||||||
|
expect(parsed.lbMode).toBe("failover");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts payload without lbMode (legacy)", () => {
|
||||||
|
const parsed = cfdmBindingSyncItemSchema.parse(base);
|
||||||
|
expect(parsed.lbMode).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -63,8 +63,8 @@ export function FailoverTimeline({
|
|||||||
return (
|
return (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={ShieldCheckIcon}
|
icon={ShieldCheckIcon}
|
||||||
title="Нет инцидентов Failover"
|
title="Нет инцидентов"
|
||||||
description="Нет Down и нет выходов из пула"
|
description="Нет Down и нет выходов из общего пула"
|
||||||
stackedIcon={false}
|
stackedIcon={false}
|
||||||
centered={false}
|
centered={false}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ type HealthStatus =
|
|||||||
|
|
||||||
function normalizeHealth(status: HealthStatus): IpHealthStatus['status'] {
|
function normalizeHealth(status: HealthStatus): IpHealthStatus['status'] {
|
||||||
if (status === 'healthy') return 'up'
|
if (status === 'healthy') return 'up'
|
||||||
if (status === 'unhealthy' || status === 'disabled') return 'down'
|
if (status === 'unhealthy') return 'down'
|
||||||
if (status === 'checking') return 'unknown'
|
if (status === 'disabled' || status === 'checking') return 'unknown'
|
||||||
return status
|
return status
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { FormFieldSimple } from '@/components/form-field'
|
import { FormFieldSimple } from '@/components/form-field'
|
||||||
import { AppInput } from '@/components/app-input'
|
import { AppInput } from '@/components/app-input'
|
||||||
import { SettingRow } from '@/components/setting-row'
|
import { SettingRow } from '@/components/setting-row'
|
||||||
|
import { SelectField } from '@/components/select-field'
|
||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
import {
|
import {
|
||||||
NumberField,
|
NumberField,
|
||||||
@@ -9,15 +10,8 @@ import {
|
|||||||
NumberFieldIncrement,
|
NumberFieldIncrement,
|
||||||
NumberFieldInput,
|
NumberFieldInput,
|
||||||
} from '@/components/reui/number-field'
|
} from '@/components/reui/number-field'
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@cfdm/ui/components/select'
|
|
||||||
import { Switch } from '@cfdm/ui/components/switch'
|
import { Switch } from '@cfdm/ui/components/switch'
|
||||||
import { FieldGroup } from '@cfdm/ui/components/field'
|
import { Field, FieldGroup, FieldLabel } from '@cfdm/ui/components/field'
|
||||||
import { Button } from '@cfdm/ui/components/button'
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
import { ButtonGroup } from '@cfdm/ui/components/button-group'
|
import { ButtonGroup } from '@cfdm/ui/components/button-group'
|
||||||
import { CableIcon, GlobeIcon } from 'lucide-react'
|
import { CableIcon, GlobeIcon } from 'lucide-react'
|
||||||
@@ -59,9 +53,14 @@ export interface LbAndHealthConfig extends HealthCheckConfig {
|
|||||||
const defaultLbModeOptions = [
|
const defaultLbModeOptions = [
|
||||||
{ value: 'round_robin', label: 'Round Robin' },
|
{ value: 'round_robin', label: 'Round Robin' },
|
||||||
{ value: 'failover', label: 'Failover (приоритет)' },
|
{ value: 'failover', label: 'Failover (приоритет)' },
|
||||||
{ value: 'weighted', label: 'Weighted (веса)' },
|
{ value: 'weighted', label: 'Веса (подмена IP)' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
export interface LbPoolMetaChange {
|
||||||
|
weight?: number
|
||||||
|
priority?: number
|
||||||
|
}
|
||||||
|
|
||||||
function CompactNumberField({
|
function CompactNumberField({
|
||||||
id,
|
id,
|
||||||
value,
|
value,
|
||||||
@@ -95,6 +94,89 @@ function CompactNumberField({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PoolLbMetaFields({
|
||||||
|
idPrefix,
|
||||||
|
mode,
|
||||||
|
ips,
|
||||||
|
weights,
|
||||||
|
priorities,
|
||||||
|
onMetaChange,
|
||||||
|
}: {
|
||||||
|
idPrefix: string
|
||||||
|
mode: Exclude<LbMode, 'round_robin'>
|
||||||
|
ips: readonly string[]
|
||||||
|
weights: Record<string, number>
|
||||||
|
priorities: Record<string, number>
|
||||||
|
onMetaChange?: (ip: string, meta: LbPoolMetaChange) => void
|
||||||
|
}) {
|
||||||
|
const isWeighted = mode === 'weighted'
|
||||||
|
const minPriority =
|
||||||
|
ips.length === 0
|
||||||
|
? 1
|
||||||
|
: Math.min(...ips.map((ip) => priorities[ip] ?? 1))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingRow
|
||||||
|
title={isWeighted ? 'Вес IP' : 'Приоритет IP'}
|
||||||
|
description={
|
||||||
|
isWeighted
|
||||||
|
? 'Доля времени на общем FQDN: 1 и 3 = ¼ и ¾ цикла (слот 60 с)'
|
||||||
|
: '1 — основной, больше — запасной'
|
||||||
|
}
|
||||||
|
compact
|
||||||
|
stacked
|
||||||
|
className="gap-3 px-0 py-3"
|
||||||
|
contentClassName="min-w-0"
|
||||||
|
>
|
||||||
|
{ips.length === 0 ? (
|
||||||
|
<p className="text-muted-foreground text-sm">Сначала добавьте IP выше</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex w-full flex-col gap-2">
|
||||||
|
{ips.map((ip) => {
|
||||||
|
const isPrimary = (priorities[ip] ?? 1) === minPriority
|
||||||
|
const fieldId = isWeighted
|
||||||
|
? `${idPrefix}-weight-${ip}`
|
||||||
|
: `${idPrefix}-priority-${ip}`
|
||||||
|
return (
|
||||||
|
<div key={ip} className="flex items-center gap-3">
|
||||||
|
<span className="min-w-0 flex-1 truncate font-mono text-sm">{ip}</span>
|
||||||
|
{!isWeighted ? (
|
||||||
|
<Badge
|
||||||
|
variant={isPrimary ? 'success-light' : 'outline'}
|
||||||
|
size="xs"
|
||||||
|
radius="full"
|
||||||
|
>
|
||||||
|
{isPrimary ? 'Основной' : 'Запасной'}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
<Field className="w-28 gap-0">
|
||||||
|
<FieldLabel htmlFor={fieldId} className="sr-only">
|
||||||
|
{isWeighted ? `Вес ${ip}` : `Приоритет ${ip}`}
|
||||||
|
</FieldLabel>
|
||||||
|
<CompactNumberField
|
||||||
|
id={fieldId}
|
||||||
|
value={isWeighted ? (weights[ip] ?? 1) : (priorities[ip] ?? 1)}
|
||||||
|
min={1}
|
||||||
|
max={100}
|
||||||
|
onValueChange={(next) =>
|
||||||
|
onMetaChange?.(
|
||||||
|
ip,
|
||||||
|
isWeighted
|
||||||
|
? { weight: next ?? 1 }
|
||||||
|
: { priority: next ?? 1 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SettingRow>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function HealthCheckConfigFields({
|
export function HealthCheckConfigFields({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -102,6 +184,10 @@ export function HealthCheckConfigFields({
|
|||||||
lbModeOptions = defaultLbModeOptions,
|
lbModeOptions = defaultLbModeOptions,
|
||||||
idPrefix = 'health',
|
idPrefix = 'health',
|
||||||
showLbMode = true,
|
showLbMode = true,
|
||||||
|
ips = [],
|
||||||
|
weights = {},
|
||||||
|
priorities = {},
|
||||||
|
onMetaChange,
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
value: LbAndHealthConfig
|
value: LbAndHealthConfig
|
||||||
@@ -110,6 +196,10 @@ export function HealthCheckConfigFields({
|
|||||||
lbModeOptions?: { value: string; label: string }[]
|
lbModeOptions?: { value: string; label: string }[]
|
||||||
idPrefix?: string
|
idPrefix?: string
|
||||||
showLbMode?: boolean
|
showLbMode?: boolean
|
||||||
|
ips?: readonly string[]
|
||||||
|
weights?: Record<string, number>
|
||||||
|
priorities?: Record<string, number>
|
||||||
|
onMetaChange?: (ip: string, meta: LbPoolMetaChange) => void
|
||||||
className?: string
|
className?: string
|
||||||
}) {
|
}) {
|
||||||
function patch(next: Partial<LbAndHealthConfig>) {
|
function patch(next: Partial<LbAndHealthConfig>) {
|
||||||
@@ -134,25 +224,28 @@ export function HealthCheckConfigFields({
|
|||||||
compact
|
compact
|
||||||
className={rowClass}
|
className={rowClass}
|
||||||
>
|
>
|
||||||
<Select
|
<SelectField
|
||||||
modal={false}
|
modal={false}
|
||||||
value={value.lb_mode}
|
value={value.lb_mode}
|
||||||
onValueChange={(v) => patch({ lb_mode: (v ?? 'round_robin') as LbMode })}
|
onValueChange={(v) => patch({ lb_mode: (v ?? 'round_robin') as LbMode })}
|
||||||
>
|
triggerId={`${idPrefix}-lb-mode`}
|
||||||
<SelectTrigger id={`${idPrefix}-lb-mode`} className="w-full">
|
placeholder="Выберите режим"
|
||||||
<SelectValue placeholder="Выберите режим" />
|
options={lbModeOptions}
|
||||||
</SelectTrigger>
|
/>
|
||||||
<SelectContent>
|
|
||||||
{lbModeOptions.map((item) => (
|
|
||||||
<SelectItem key={item.value} value={item.value}>
|
|
||||||
{item.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{showLbMode && value.lb_mode !== 'round_robin' ? (
|
||||||
|
<PoolLbMetaFields
|
||||||
|
idPrefix={idPrefix}
|
||||||
|
mode={value.lb_mode}
|
||||||
|
ips={ips}
|
||||||
|
weights={weights}
|
||||||
|
priorities={priorities}
|
||||||
|
onMetaChange={onMetaChange}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<SettingRow
|
<SettingRow
|
||||||
title="Провайдер health-check"
|
title="Провайдер health-check"
|
||||||
description="Кто пробирует цель. Можно выбрать несколько источников."
|
description="Кто пробирует цель. Можно выбрать несколько источников."
|
||||||
|
|||||||
@@ -16,10 +16,13 @@ import { parseFqdn } from '@/lib/parse-fqdn'
|
|||||||
import {
|
import {
|
||||||
addAddressNode,
|
addAddressNode,
|
||||||
addCommonFqdn,
|
addCommonFqdn,
|
||||||
|
addExtraFqdn,
|
||||||
addressHasFqdn,
|
addressHasFqdn,
|
||||||
removeAddressNode,
|
removeAddressNode,
|
||||||
removeCommonFqdn,
|
removeCommonFqdn,
|
||||||
|
removeExtraFqdn,
|
||||||
updateCommonFqdn,
|
updateCommonFqdn,
|
||||||
|
updateExtraFqdn,
|
||||||
type AddressBlockState,
|
type AddressBlockState,
|
||||||
} from '@/lib/service-address'
|
} from '@/lib/service-address'
|
||||||
import { Button } from '@cfdm/ui/components/button'
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
@@ -67,7 +70,7 @@ function ZoneAddon({
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Единый блок адресов сервиса: список общих FQDN на весь пул + IP с доп. доменом.
|
* Единый блок адресов сервиса: список общих FQDN на весь пул + IP с доп. доменами.
|
||||||
* Preview: https://reui.io/preview/base/settings-3
|
* Preview: https://reui.io/preview/base/settings-3
|
||||||
* Preview: https://reui.io/preview/base/list-9
|
* Preview: https://reui.io/preview/base/list-9
|
||||||
* Preview: https://reui.io/preview/base/form-7
|
* Preview: https://reui.io/preview/base/form-7
|
||||||
@@ -88,6 +91,8 @@ export function ServiceAddressBlock({
|
|||||||
const [ipInvalid, setIpInvalid] = useState(false)
|
const [ipInvalid, setIpInvalid] = useState(false)
|
||||||
const [pendingFqdn, setPendingFqdn] = useState('')
|
const [pendingFqdn, setPendingFqdn] = useState('')
|
||||||
const [fqdnInvalid, setFqdnInvalid] = useState(false)
|
const [fqdnInvalid, setFqdnInvalid] = useState(false)
|
||||||
|
const [pendingExtraByIp, setPendingExtraByIp] = useState<Record<string, string>>({})
|
||||||
|
const [extraInvalidByIp, setExtraInvalidByIp] = useState<Record<string, boolean>>({})
|
||||||
|
|
||||||
const pool = value.nodes.map((node) => node.ip)
|
const pool = value.nodes.map((node) => node.ip)
|
||||||
const pendingIpTrimmed = pendingIp.trim()
|
const pendingIpTrimmed = pendingIp.trim()
|
||||||
@@ -141,13 +146,26 @@ export function ServiceAddressBlock({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleNodeFqdn(ip: string, extraFqdn: string) {
|
function tryAddExtra(ip: string, raw: string) {
|
||||||
onChange({
|
const trimmed = raw.trim()
|
||||||
...value,
|
if (!trimmed) {
|
||||||
nodes: value.nodes.map((node) =>
|
setExtraInvalidByIp((current) => ({ ...current, [ip]: false }))
|
||||||
node.ip === ip ? { ...node, extraFqdn } : node,
|
return
|
||||||
),
|
}
|
||||||
})
|
if (addressHasFqdn(value, trimmed)) {
|
||||||
|
setExtraInvalidByIp((current) => ({ ...current, [ip]: true }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onChange(addExtraFqdn(value, ip, trimmed))
|
||||||
|
setPendingExtraByIp((current) => ({ ...current, [ip]: '' }))
|
||||||
|
setExtraInvalidByIp((current) => ({ ...current, [ip]: false }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleExtraKeyDown(ip: string, event: KeyboardEvent<HTMLInputElement>) {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
event.preventDefault()
|
||||||
|
tryAddExtra(ip, pendingExtraByIp[ip] ?? '')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -156,7 +174,7 @@ export function ServiceAddressBlock({
|
|||||||
<FrameHeader className="px-0 pt-0">
|
<FrameHeader className="px-0 pt-0">
|
||||||
<FrameTitle>Адреса</FrameTitle>
|
<FrameTitle>Адреса</FrameTitle>
|
||||||
<FrameDescription>
|
<FrameDescription>
|
||||||
Общие FQDN — на весь пул · у IP свой доп. домен
|
Общие FQDN — на весь пул · у IP свои доп. домены
|
||||||
</FrameDescription>
|
</FrameDescription>
|
||||||
</FrameHeader>
|
</FrameHeader>
|
||||||
<Field>
|
<Field>
|
||||||
@@ -224,7 +242,7 @@ export function ServiceAddressBlock({
|
|||||||
<EmptyState
|
<EmptyState
|
||||||
icon={ServerIcon}
|
icon={ServerIcon}
|
||||||
title="Добавьте IP пула"
|
title="Добавьте IP пула"
|
||||||
description="IPv4 сервиса. Для каждого адреса можно указать доп. FQDN."
|
description="IPv4 сервиса. Для каждого адреса можно указать несколько доп. FQDN, в том числе wildcard."
|
||||||
stackedIcon={false}
|
stackedIcon={false}
|
||||||
centered={false}
|
centered={false}
|
||||||
/>
|
/>
|
||||||
@@ -265,27 +283,98 @@ export function ServiceAddressBlock({
|
|||||||
</div>
|
</div>
|
||||||
<Field className="gap-1.5">
|
<Field className="gap-1.5">
|
||||||
<FieldLabel
|
<FieldLabel
|
||||||
htmlFor={`service-ip-extra-${node.ip}`}
|
htmlFor={`service-ip-extra-add-${node.ip}`}
|
||||||
className="text-muted-foreground text-xs"
|
className="text-muted-foreground text-xs"
|
||||||
>
|
>
|
||||||
Доп. FQDN
|
Доп. FQDN
|
||||||
</FieldLabel>
|
</FieldLabel>
|
||||||
<InputGroup>
|
<div className="flex w-full flex-col gap-2">
|
||||||
<InputGroupInput
|
{node.extraFqdns.map((fqdn, index) => (
|
||||||
id={`service-ip-extra-${node.ip}`}
|
<InputGroup key={`extra-fqdn-${node.ip}-${index}`}>
|
||||||
className="font-mono"
|
<InputGroupInput
|
||||||
value={node.extraFqdn}
|
id={`service-ip-extra-${node.ip}-${index}`}
|
||||||
placeholder={
|
className="font-mono"
|
||||||
zoneHints[0]
|
value={fqdn}
|
||||||
? `необязательно · spb.${zoneHints[0]}`
|
placeholder={
|
||||||
: 'необязательно · spb.example.com'
|
zoneHints[0]
|
||||||
}
|
? `*.mdns.${zoneHints[0]}`
|
||||||
onChange={(event) =>
|
: '*.mdns.example.com'
|
||||||
handleNodeFqdn(node.ip, event.target.value)
|
}
|
||||||
}
|
onChange={(event) =>
|
||||||
/>
|
onChange(
|
||||||
<ZoneAddon fqdn={node.extraFqdn} zoneHints={zoneHints} />
|
updateExtraFqdn(
|
||||||
</InputGroup>
|
value,
|
||||||
|
node.ip,
|
||||||
|
index,
|
||||||
|
event.target.value,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ZoneAddon
|
||||||
|
fqdn={fqdn}
|
||||||
|
zoneHints={zoneHints}
|
||||||
|
trailing={
|
||||||
|
<InputGroupButton
|
||||||
|
size="icon-xs"
|
||||||
|
aria-label={`Удалить ${fqdn || 'FQDN'}`}
|
||||||
|
onClick={() =>
|
||||||
|
onChange(removeExtraFqdn(value, node.ip, index))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Trash2Icon />
|
||||||
|
</InputGroupButton>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</InputGroup>
|
||||||
|
))}
|
||||||
|
<InputGroup>
|
||||||
|
<InputGroupInput
|
||||||
|
id={`service-ip-extra-add-${node.ip}`}
|
||||||
|
className="font-mono"
|
||||||
|
value={pendingExtraByIp[node.ip] ?? ''}
|
||||||
|
placeholder={
|
||||||
|
zoneHints[0]
|
||||||
|
? `необязательно · *.mdns.${zoneHints[0]}`
|
||||||
|
: 'необязательно · *.mdns.example.com'
|
||||||
|
}
|
||||||
|
aria-invalid={
|
||||||
|
extraInvalidByIp[node.ip] &&
|
||||||
|
(pendingExtraByIp[node.ip] ?? '').trim().length > 0
|
||||||
|
? true
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onChange={(event) => {
|
||||||
|
setPendingExtraByIp((current) => ({
|
||||||
|
...current,
|
||||||
|
[node.ip]: event.target.value,
|
||||||
|
}))
|
||||||
|
setExtraInvalidByIp((current) => ({
|
||||||
|
...current,
|
||||||
|
[node.ip]: false,
|
||||||
|
}))
|
||||||
|
}}
|
||||||
|
onKeyDown={(event) => handleExtraKeyDown(node.ip, event)}
|
||||||
|
onBlur={() =>
|
||||||
|
tryAddExtra(node.ip, pendingExtraByIp[node.ip] ?? '')
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ZoneAddon
|
||||||
|
fqdn={pendingExtraByIp[node.ip] ?? ''}
|
||||||
|
zoneHints={zoneHints}
|
||||||
|
trailing={
|
||||||
|
<InputGroupButton
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
tryAddExtra(node.ip, pendingExtraByIp[node.ip] ?? '')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Добавить
|
||||||
|
</InputGroupButton>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</InputGroup>
|
||||||
|
</div>
|
||||||
</Field>
|
</Field>
|
||||||
</ItemContent>
|
</ItemContent>
|
||||||
</Item>
|
</Item>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
type HealthLogProbe,
|
type HealthLogProbe,
|
||||||
type HealthLogStatus,
|
type HealthLogStatus,
|
||||||
} from '@/lib/health-log'
|
} from '@/lib/health-log'
|
||||||
import type { FailoverLogEntry } from '@/lib/schemas'
|
import type { FailoverLogEntry, ServiceView } from '@/lib/schemas'
|
||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
import {
|
import {
|
||||||
Frame,
|
Frame,
|
||||||
@@ -28,6 +28,23 @@ import {
|
|||||||
AlertTitle,
|
AlertTitle,
|
||||||
} from '@/components/reui/alert'
|
} from '@/components/reui/alert'
|
||||||
|
|
||||||
|
type LbMode = ServiceView['lb_mode']
|
||||||
|
|
||||||
|
const PANEL_COPY: Record<LbMode, { title: string; description: string }> = {
|
||||||
|
failover: {
|
||||||
|
title: 'Failover (приоритет)',
|
||||||
|
description: 'Down снимается с общего FQDN. История: кто вышел из пула и кто вернулся',
|
||||||
|
},
|
||||||
|
weighted: {
|
||||||
|
title: 'Веса (подмена IP)',
|
||||||
|
description: 'На общем FQDN один IP по весам. Down выводится из цикла',
|
||||||
|
},
|
||||||
|
round_robin: {
|
||||||
|
title: 'Round Robin',
|
||||||
|
description: 'На общем FQDN все живые A. Down снимается с пула',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
function failoverCountLabel(count: number): string {
|
function failoverCountLabel(count: number): string {
|
||||||
const mod10 = count % 10
|
const mod10 = count % 10
|
||||||
const mod100 = count % 100
|
const mod100 = count % 100
|
||||||
@@ -38,8 +55,17 @@ function failoverCountLabel(count: number): string {
|
|||||||
return `${count} адресов Down`
|
return `${count} адресов Down`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function alertDescription(events: { kind: string; fqdns: string[] }[]): string {
|
||||||
|
const removedCount = events.filter((event) => event.kind === 'removed').length
|
||||||
|
if (removedCount > 0) return 'Сняты с общего FQDN'
|
||||||
|
if (events.some((event) => event.fqdns.length > 0)) {
|
||||||
|
return 'Остались в A-записях общего FQDN как last-resort'
|
||||||
|
}
|
||||||
|
return 'Down: персональные FQDN не меняются'
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Failover — текущие Down + кто вышел из пула и кто вернулся.
|
* Балансировка — текущие Down + кто вышел из общего пула.
|
||||||
* Preview: https://reui.io/preview/base/components/c-timeline-10
|
* Preview: https://reui.io/preview/base/components/c-timeline-10
|
||||||
* Preview: https://reui.io/preview/base/empty-state-12
|
* Preview: https://reui.io/preview/base/empty-state-12
|
||||||
* Docs: https://reui.io/docs/components/base/frame
|
* Docs: https://reui.io/docs/components/base/frame
|
||||||
@@ -48,16 +74,26 @@ function failoverCountLabel(count: number): string {
|
|||||||
* Docs: https://reui.io/docs/components/base/alert
|
* Docs: https://reui.io/docs/components/base/alert
|
||||||
*/
|
*/
|
||||||
export function ServiceFailoverPanel({
|
export function ServiceFailoverPanel({
|
||||||
|
lbMode = 'round_robin',
|
||||||
|
hasPool = true,
|
||||||
ipHealth,
|
ipHealth,
|
||||||
bindings,
|
bindings,
|
||||||
history,
|
history,
|
||||||
probes = [],
|
probes = [],
|
||||||
}: {
|
}: {
|
||||||
|
lbMode?: LbMode
|
||||||
|
hasPool?: boolean
|
||||||
ipHealth: readonly FailoverHealthInput[]
|
ipHealth: readonly FailoverHealthInput[]
|
||||||
bindings: readonly FailoverBindingPool[]
|
bindings: readonly FailoverBindingPool[]
|
||||||
history: readonly FailoverLogEntry[]
|
history: readonly FailoverLogEntry[]
|
||||||
probes?: readonly HealthLogProbe[]
|
probes?: readonly HealthLogProbe[]
|
||||||
}) {
|
}) {
|
||||||
|
const copy = hasPool
|
||||||
|
? (PANEL_COPY[lbMode] ?? PANEL_COPY.round_robin)
|
||||||
|
: {
|
||||||
|
title: 'без резервирования',
|
||||||
|
description: 'Один origin IP — балансировка не применяется',
|
||||||
|
}
|
||||||
const liveByIp = latestHealthByIp(probes)
|
const liveByIp = latestHealthByIp(probes)
|
||||||
const overlayHealth = ipHealth.map((row) => {
|
const overlayHealth = ipHealth.map((row) => {
|
||||||
const live = liveByIp.get(row.ip)
|
const live = liveByIp.get(row.ip)
|
||||||
@@ -77,14 +113,13 @@ export function ServiceFailoverPanel({
|
|||||||
})
|
})
|
||||||
const events = toFailoverEvents(overlayHealth, bindings)
|
const events = toFailoverEvents(overlayHealth, bindings)
|
||||||
const mergedHistory = mergeFailoverHistory(history, probes, bindings)
|
const mergedHistory = mergeFailoverHistory(history, probes, bindings)
|
||||||
const removedCount = events.filter((event) => event.kind === 'removed').length
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Frame stacked spacing="sm" className="min-w-0 w-full">
|
<Frame stacked spacing="sm" className="min-w-0 w-full">
|
||||||
<FramePanel className="flex flex-col gap-3">
|
<FramePanel className="flex flex-col gap-3">
|
||||||
<FrameHeader className="gap-1 px-0 py-0">
|
<FrameHeader className="gap-1 px-0 py-0">
|
||||||
<FrameTitle className="flex flex-wrap items-center gap-2">
|
<FrameTitle className="flex flex-wrap items-center gap-2">
|
||||||
Failover
|
{copy.title}
|
||||||
{events.length > 0 ? (
|
{events.length > 0 ? (
|
||||||
<Badge variant="destructive-light" size="xs" radius="full">
|
<Badge variant="destructive-light" size="xs" radius="full">
|
||||||
{events.length}
|
{events.length}
|
||||||
@@ -95,20 +130,14 @@ export function ServiceFailoverPanel({
|
|||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</FrameTitle>
|
</FrameTitle>
|
||||||
<FrameDescription>
|
<FrameDescription>{copy.description}</FrameDescription>
|
||||||
Текущие Down и история: кто вышел из пула и кто вернулся
|
|
||||||
</FrameDescription>
|
|
||||||
</FrameHeader>
|
</FrameHeader>
|
||||||
|
|
||||||
{events.length > 0 ? (
|
{events.length > 0 ? (
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
<UnplugIcon aria-hidden="true" />
|
<UnplugIcon aria-hidden="true" />
|
||||||
<AlertTitle>{failoverCountLabel(events.length)}</AlertTitle>
|
<AlertTitle>{failoverCountLabel(events.length)}</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>{alertDescription(events)}</AlertDescription>
|
||||||
{removedCount > 0
|
|
||||||
? 'Сняты с FQDN или остались last-resort'
|
|
||||||
: 'Остались в A-записях как last-resort'}
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
DEFAULT_BINDING_HEALTH,
|
DEFAULT_BINDING_HEALTH,
|
||||||
emptyAddressBlock,
|
emptyAddressBlock,
|
||||||
hydrateAddressBlock,
|
hydrateAddressBlock,
|
||||||
|
patchAddressIpMeta,
|
||||||
toBindingDrafts,
|
toBindingDrafts,
|
||||||
toDomainsPayload,
|
toDomainsPayload,
|
||||||
type AddressBlockState,
|
type AddressBlockState,
|
||||||
@@ -218,8 +219,8 @@ export function ServiceEditSheet({
|
|||||||
<SheetHeader className="shrink-0 border-b pb-4">
|
<SheetHeader className="shrink-0 border-b pb-4">
|
||||||
<SheetTitle>{isCreate ? 'Новый сервис' : 'Редактирование сервиса'}</SheetTitle>
|
<SheetTitle>{isCreate ? 'Новый сервис' : 'Редактирование сервиса'}</SheetTitle>
|
||||||
<SheetDescription>
|
<SheetDescription>
|
||||||
Общие FQDN на весь пул IP. У каждого адреса можно указать свой доп.
|
Общие FQDN на весь пул IP. У каждого адреса можно указать несколько доп.
|
||||||
FQDN.
|
FQDN, в том числе wildcard.
|
||||||
</SheetDescription>
|
</SheetDescription>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
|
|
||||||
@@ -282,6 +283,12 @@ export function ServiceEditSheet({
|
|||||||
idPrefix="service-health"
|
idPrefix="service-health"
|
||||||
value={primaryHealthValue}
|
value={primaryHealthValue}
|
||||||
onChange={handlePrimaryHealthChange}
|
onChange={handlePrimaryHealthChange}
|
||||||
|
ips={address.nodes.map((node) => node.ip)}
|
||||||
|
weights={address.target_ip_weights}
|
||||||
|
priorities={address.target_ip_priorities}
|
||||||
|
onMetaChange={(ip, meta) =>
|
||||||
|
setAddress((current) => patchAddressIpMeta(current, ip, meta))
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ const VISIBLE_IP_LIMIT = 6
|
|||||||
interface ServiceIpListProps {
|
interface ServiceIpListProps {
|
||||||
ips: string[]
|
ips: string[]
|
||||||
ipHealth?: ServiceView['ip_health']
|
ipHealth?: ServiceView['ip_health']
|
||||||
|
healthCheckEnabled?: boolean
|
||||||
ipEnabled?: Record<string, boolean>
|
ipEnabled?: Record<string, boolean>
|
||||||
togglingIp?: string | null
|
togglingIp?: string | null
|
||||||
ipToggleDisabled?: boolean
|
ipToggleDisabled?: boolean
|
||||||
@@ -151,6 +152,7 @@ interface ServiceIpListProps {
|
|||||||
export function ServiceIpList({
|
export function ServiceIpList({
|
||||||
ips,
|
ips,
|
||||||
ipHealth = [],
|
ipHealth = [],
|
||||||
|
healthCheckEnabled = true,
|
||||||
ipEnabled = {},
|
ipEnabled = {},
|
||||||
togglingIp = null,
|
togglingIp = null,
|
||||||
ipToggleDisabled = false,
|
ipToggleDisabled = false,
|
||||||
@@ -184,6 +186,10 @@ export function ServiceIpList({
|
|||||||
{visible.map((ip) => {
|
{visible.map((ip) => {
|
||||||
const health = healthByIp.get(ip)
|
const health = healthByIp.get(ip)
|
||||||
const enabled = ipEnabled[ip] !== false
|
const enabled = ipEnabled[ip] !== false
|
||||||
|
const monitored = healthCheckEnabled && enabled
|
||||||
|
const badgeStatus = monitored
|
||||||
|
? (health?.status ?? 'unknown')
|
||||||
|
: 'disabled'
|
||||||
return (
|
return (
|
||||||
<Item
|
<Item
|
||||||
key={ip}
|
key={ip}
|
||||||
@@ -192,7 +198,7 @@ export function ServiceIpList({
|
|||||||
>
|
>
|
||||||
<ItemMedia>
|
<ItemMedia>
|
||||||
<HealthCheckBadge
|
<HealthCheckBadge
|
||||||
status={health?.status ?? 'unknown'}
|
status={badgeStatus}
|
||||||
latencyMs={health?.latency_ms}
|
latencyMs={health?.latency_ms}
|
||||||
lastCheckedAt={health?.last_checked_at}
|
lastCheckedAt={health?.last_checked_at}
|
||||||
lastError={health?.last_error}
|
lastError={health?.last_error}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
Repeat2Icon,
|
Repeat2Icon,
|
||||||
ScaleIcon,
|
ScaleIcon,
|
||||||
ServerIcon,
|
ServerIcon,
|
||||||
|
UnplugIcon,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
@@ -21,6 +22,7 @@ import {
|
|||||||
ServiceIpList,
|
ServiceIpList,
|
||||||
} from '@/components/services/service-fqdn-list'
|
} from '@/components/services/service-fqdn-list'
|
||||||
import { serviceDisplayFqdn, serviceDisplayFqdns } from '@/lib/service-utils'
|
import { serviceDisplayFqdn, serviceDisplayFqdns } from '@/lib/service-utils'
|
||||||
|
import { uniqueIpCount } from '@/lib/failover-events'
|
||||||
import type { ServiceView } from '@/lib/schemas'
|
import type { ServiceView } from '@/lib/schemas'
|
||||||
import { Button } from '@cfdm/ui/components/button'
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
import {
|
import {
|
||||||
@@ -71,11 +73,39 @@ const LB_MODE_META: Record<
|
|||||||
weighted: {
|
weighted: {
|
||||||
icon: ScaleIcon,
|
icon: ScaleIcon,
|
||||||
className: 'text-info',
|
className: 'text-info',
|
||||||
label: 'Weighted (веса)',
|
label: 'Веса (подмена IP)',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LbModeTile({ mode }: { mode: LbMode }) {
|
export function LbModeTile({
|
||||||
|
mode,
|
||||||
|
hasPool = true,
|
||||||
|
}: {
|
||||||
|
mode: LbMode
|
||||||
|
hasPool?: boolean
|
||||||
|
}) {
|
||||||
|
if (!hasPool) {
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger
|
||||||
|
render={
|
||||||
|
<IconTile
|
||||||
|
variant="elevated"
|
||||||
|
size="xs"
|
||||||
|
className="shrink-0 text-muted-foreground"
|
||||||
|
aria-label="без резервирования"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<UnplugIcon aria-hidden="true" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>без резервирования</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const meta = LB_MODE_META[mode]
|
const meta = LB_MODE_META[mode]
|
||||||
const Icon = meta.icon
|
const Icon = meta.icon
|
||||||
|
|
||||||
@@ -148,7 +178,10 @@ export function ServiceUnitCard({
|
|||||||
{service.name}
|
{service.name}
|
||||||
</Link>
|
</Link>
|
||||||
</FrameTitle>
|
</FrameTitle>
|
||||||
<LbModeTile mode={service.lb_mode} />
|
<LbModeTile
|
||||||
|
mode={service.lb_mode}
|
||||||
|
hasPool={uniqueIpCount(service.ips) >= 2}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex min-w-0 items-center gap-1">
|
<div className="flex min-w-0 items-center gap-1">
|
||||||
<FrameDescription className="min-w-0 truncate font-mono text-xs">
|
<FrameDescription className="min-w-0 truncate font-mono text-xs">
|
||||||
@@ -240,6 +273,10 @@ export function ServiceUnitCard({
|
|||||||
alignWithMenu
|
alignWithMenu
|
||||||
ips={service.ips ?? []}
|
ips={service.ips ?? []}
|
||||||
ipHealth={service.ip_health ?? []}
|
ipHealth={service.ip_health ?? []}
|
||||||
|
healthCheckEnabled={
|
||||||
|
service.enabled &&
|
||||||
|
(service.domains ?? []).some((domain) => domain.health_check_enabled)
|
||||||
|
}
|
||||||
ipEnabled={service.ip_enabled ?? {}}
|
ipEnabled={service.ip_enabled ?? {}}
|
||||||
ipToggleDisabled={togglingId === service.id}
|
ipToggleDisabled={togglingId === service.id}
|
||||||
togglingIp={togglingIp}
|
togglingIp={togglingIp}
|
||||||
|
|||||||
@@ -51,7 +51,28 @@ describe('toFailoverEvents', () => {
|
|||||||
expect(isFailoverEventStatus('unhealthy')).toBe(false)
|
expect(isFailoverEventStatus('unhealthy')).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Down всегда виден, даже без A-записей', () => {
|
it('один IP у сервиса — не инцидент пула', () => {
|
||||||
|
const events = toFailoverEvents(
|
||||||
|
[
|
||||||
|
row({
|
||||||
|
ip: '10.0.0.1',
|
||||||
|
status: 'down',
|
||||||
|
consecutive_failures: 9,
|
||||||
|
last_error: 'fetch failed',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
fqdn: 'solo.example.com',
|
||||||
|
configured: ['10.0.0.1'],
|
||||||
|
active: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
expect(events).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Down без shared pool не событие балансировки', () => {
|
||||||
const events = toFailoverEvents([
|
const events = toFailoverEvents([
|
||||||
row({
|
row({
|
||||||
ip: '130.49.213.153',
|
ip: '130.49.213.153',
|
||||||
@@ -60,19 +81,7 @@ describe('toFailoverEvents', () => {
|
|||||||
last_error: 'fetch failed',
|
last_error: 'fetch failed',
|
||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
expect(events).toEqual([
|
expect(events).toEqual([])
|
||||||
{
|
|
||||||
id: '130.49.213.153',
|
|
||||||
address: '130.49.213.153',
|
|
||||||
status: 'down',
|
|
||||||
kind: 'last-resort',
|
|
||||||
fqdns: [],
|
|
||||||
consecutiveFailures: 9,
|
|
||||||
lastFailureReason: 'fetch failed',
|
|
||||||
lastCheckAt: null,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
expect(failoverEventCopy(events[0]!)).toBe('Down, в A-записях last-resort')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('OK вне пула не инцидент (standby)', () => {
|
it('OK вне пула не инцидент (standby)', () => {
|
||||||
@@ -137,6 +146,7 @@ describe('toFailoverEvents', () => {
|
|||||||
},
|
},
|
||||||
])
|
])
|
||||||
expect(failoverEventCopy(events[0]!)).toBe('Снята с gt.rkns.top')
|
expect(failoverEventCopy(events[0]!)).toBe('Снята с gt.rkns.top')
|
||||||
|
expect(events[0]?.fqdns).not.toContain('nsgt.rkns.top')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Down только last-resort на своём FQDN', () => {
|
it('Down только last-resort на своём FQDN', () => {
|
||||||
@@ -150,11 +160,7 @@ describe('toFailoverEvents', () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
expect(events[0]?.kind).toBe('last-resort')
|
expect(events).toEqual([])
|
||||||
expect(events[0]?.fqdns).toEqual(['nsgt.rkns.top'])
|
|
||||||
expect(failoverEventCopy(events[0]!)).toBe(
|
|
||||||
'Down, в A-записях last-resort на nsgt.rkns.top',
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('down без A-записи на configured FQDN — снятие', () => {
|
it('down без A-записи на configured FQDN — снятие', () => {
|
||||||
@@ -279,7 +285,9 @@ describe('mergeFailoverHistory', () => {
|
|||||||
'removed:probe',
|
'removed:probe',
|
||||||
])
|
])
|
||||||
expect(history[0]?.copy).toBe('130.49.213.153 добавлена на gt.rkns.top')
|
expect(history[0]?.copy).toBe('130.49.213.153 добавлена на gt.rkns.top')
|
||||||
expect(history[1]?.copy).toContain('вышла из пула')
|
expect(history[1]?.copy).toBe(
|
||||||
|
'130.49.213.153 вышла из пула (gt.rkns.top)',
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps DNS over a probe return in the same 2-minute window', () => {
|
it('keeps DNS over a probe return in the same 2-minute window', () => {
|
||||||
@@ -291,4 +299,38 @@ describe('mergeFailoverHistory', () => {
|
|||||||
expect(history).toHaveLength(1)
|
expect(history).toHaveLength(1)
|
||||||
expect(history[0]?.source).toBe('dns')
|
expect(history[0]?.source).toBe('dns')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('drops probe leave/return when the service has no shared pool', () => {
|
||||||
|
const history = mergeFailoverHistory(
|
||||||
|
[],
|
||||||
|
[
|
||||||
|
probe({ id: 1, status: 'up', checked_at: '2026-08-20T09:00:00Z' }),
|
||||||
|
probe({ id: 2, status: 'down', checked_at: '2026-08-20T09:10:00Z' }),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
fqdn: 'solo.example.com',
|
||||||
|
configured: ['130.49.213.153'],
|
||||||
|
active: ['130.49.213.153'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
expect(history).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('drops dedicated extra-FQDN DNS rows', () => {
|
||||||
|
const history = mergeFailoverHistory(
|
||||||
|
[
|
||||||
|
dns({
|
||||||
|
id: 11,
|
||||||
|
fqdn: 'nsgt.rkns.top',
|
||||||
|
action: 'added',
|
||||||
|
created_at: '2026-08-20 09:26:00',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
mskHip,
|
||||||
|
)
|
||||||
|
expect(history).toEqual([])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -44,6 +44,22 @@ export interface FailoverBindingPool {
|
|||||||
active: readonly string[]
|
active: readonly string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Unique A targets. Duplicates of the same IP are not a pool. */
|
||||||
|
export function uniqueIpCount(ips: readonly string[]): number {
|
||||||
|
return new Set(ips.filter(Boolean)).size
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shared pool FQDN — two or more unique A targets. */
|
||||||
|
export function isSharedPoolBinding(binding: FailoverBindingPool): boolean {
|
||||||
|
return uniqueIpCount(binding.configured) >= 2
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasSharedPool(
|
||||||
|
bindings: readonly FailoverBindingPool[],
|
||||||
|
): boolean {
|
||||||
|
return bindings.some(isSharedPoolBinding)
|
||||||
|
}
|
||||||
|
|
||||||
/** Инцидент только при down. up / degraded / unknown — не вывод из пула. */
|
/** Инцидент только при down. up / degraded / unknown — не вывод из пула. */
|
||||||
export function isFailoverEventStatus(status: string): boolean {
|
export function isFailoverEventStatus(status: string): boolean {
|
||||||
return status === 'down'
|
return status === 'down'
|
||||||
@@ -61,17 +77,19 @@ export function failoverEventCopy(event: FailoverEvent): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Текущие Down всегда в панели. Per-FQDN: снята с hostname или last-resort.
|
* Инциденты пула только если у сервиса есть shared FQDN (2+ уникальных IP).
|
||||||
* Standby (up / degraded / unknown) — не инцидент.
|
* Один IP — не балансировка и не вывод из пула; Down смотрит health-монитор.
|
||||||
*/
|
*/
|
||||||
export function toFailoverEvents(
|
export function toFailoverEvents(
|
||||||
ipHealth: readonly FailoverHealthInput[],
|
ipHealth: readonly FailoverHealthInput[],
|
||||||
bindings: readonly FailoverBindingPool[] = [],
|
bindings: readonly FailoverBindingPool[] = [],
|
||||||
): FailoverEvent[] {
|
): FailoverEvent[] {
|
||||||
|
if (!hasSharedPool(bindings)) return []
|
||||||
return ipHealth.filter((row) => isFailoverEventStatus(row.status)).map((row) => {
|
return ipHealth.filter((row) => isFailoverEventStatus(row.status)).map((row) => {
|
||||||
const removedFqdns: string[] = []
|
const removedFqdns: string[] = []
|
||||||
const lastResortFqdns: string[] = []
|
const lastResortFqdns: string[] = []
|
||||||
for (const binding of bindings) {
|
for (const binding of bindings) {
|
||||||
|
if (!isSharedPoolBinding(binding)) continue
|
||||||
const configured = binding.configured.includes(row.ip)
|
const configured = binding.configured.includes(row.ip)
|
||||||
const active = binding.active.includes(row.ip)
|
const active = binding.active.includes(row.ip)
|
||||||
if (configured && !active) removedFqdns.push(binding.fqdn)
|
if (configured && !active) removedFqdns.push(binding.fqdn)
|
||||||
@@ -98,7 +116,18 @@ function fqdnsForIp(
|
|||||||
ip: string,
|
ip: string,
|
||||||
bindings: readonly FailoverBindingPool[],
|
bindings: readonly FailoverBindingPool[],
|
||||||
): string[] {
|
): string[] {
|
||||||
return bindings.filter((binding) => binding.configured.includes(ip)).map((binding) => binding.fqdn)
|
return bindings
|
||||||
|
.filter((binding) => isSharedPoolBinding(binding) && binding.configured.includes(ip))
|
||||||
|
.map((binding) => binding.fqdn)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPoolFqdn(
|
||||||
|
fqdn: string,
|
||||||
|
bindings: readonly FailoverBindingPool[],
|
||||||
|
): boolean {
|
||||||
|
const binding = bindings.find((item) => item.fqdn === fqdn)
|
||||||
|
if (!binding) return true
|
||||||
|
return isSharedPoolBinding(binding)
|
||||||
}
|
}
|
||||||
|
|
||||||
function fqdnLabel(fqdns: string[]): string {
|
function fqdnLabel(fqdns: string[]): string {
|
||||||
@@ -201,10 +230,12 @@ export function mergeFailoverHistory(
|
|||||||
bindings: readonly FailoverBindingPool[] = [],
|
bindings: readonly FailoverBindingPool[] = [],
|
||||||
): FailoverHistoryItem[] {
|
): FailoverHistoryItem[] {
|
||||||
const merged = [
|
const merged = [
|
||||||
...dns.map(dnsHistoryItem),
|
...dns
|
||||||
...toIpAliveTransitions(probes).map((transition) =>
|
.filter((item) => isPoolFqdn(item.fqdn, bindings))
|
||||||
probeHistoryItem(transition, bindings),
|
.map(dnsHistoryItem),
|
||||||
),
|
...toIpAliveTransitions(probes)
|
||||||
|
.filter((transition) => fqdnsForIp(transition.ip, bindings).length > 0)
|
||||||
|
.map((transition) => probeHistoryItem(transition, bindings)),
|
||||||
]
|
]
|
||||||
merged.sort(
|
merged.sort(
|
||||||
(a, b) => eventTime(b.created_at) - eventTime(a.created_at) || a.id.localeCompare(b.id),
|
(a, b) => eventTime(b.created_at) - eventTime(a.created_at) || a.id.localeCompare(b.id),
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
latestHealthByIp,
|
latestHealthByIp,
|
||||||
providerHealthStatuses,
|
providerHealthStatuses,
|
||||||
resolveIpDisplayHealth,
|
resolveIpDisplayHealth,
|
||||||
|
resolveServiceDisplayHealth,
|
||||||
worstHealthStatus,
|
worstHealthStatus,
|
||||||
type HealthLogProbe,
|
type HealthLogProbe,
|
||||||
} from '@/lib/health-log'
|
} from '@/lib/health-log'
|
||||||
@@ -146,3 +147,22 @@ describe('resolveIpDisplayHealth', () => {
|
|||||||
expect(resolveIpDisplayHealth('unknown', undefined)).toBe('unknown')
|
expect(resolveIpDisplayHealth('unknown', undefined)).toBe('unknown')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('resolveServiceDisplayHealth', () => {
|
||||||
|
it('shows OK when live probes recovered and stored is still unknown', () => {
|
||||||
|
expect(
|
||||||
|
resolveServiceDisplayHealth(
|
||||||
|
'unknown',
|
||||||
|
[{ ip: '2.59.161.102', status: 'unknown' }],
|
||||||
|
[
|
||||||
|
probe({
|
||||||
|
id: 1,
|
||||||
|
ip: '2.59.161.102',
|
||||||
|
status: 'up',
|
||||||
|
checked_at: '2026-08-20T18:00:00Z',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
).toBe('up')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -194,3 +194,22 @@ export function resolveIpDisplayHealth(
|
|||||||
if (live && live !== 'unknown') return live
|
if (live && live !== 'unknown') return live
|
||||||
return stored ?? live ?? 'unknown'
|
return stored ?? live ?? 'unknown'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Service KPI / card: any-up of per-IP overlay (live probes beat hysteresis). */
|
||||||
|
export function resolveServiceDisplayHealth(
|
||||||
|
stored: HealthLogStatus | undefined,
|
||||||
|
ipHealth: readonly { ip: string; status: string }[],
|
||||||
|
probes: readonly HealthLogProbe[] = [],
|
||||||
|
): HealthLogStatus {
|
||||||
|
const liveByIp = latestHealthByIp(probes)
|
||||||
|
const statuses =
|
||||||
|
ipHealth.length > 0
|
||||||
|
? ipHealth.map((row) =>
|
||||||
|
resolveIpDisplayHealth(
|
||||||
|
row.status as HealthLogStatus,
|
||||||
|
liveByIp.get(row.ip)?.status,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: [...liveByIp.values()].map((row) => row.status)
|
||||||
|
return resolveIpDisplayHealth(stored, bestAliveHealthStatus(statuses))
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ import {
|
|||||||
DEFAULT_BINDING_HEALTH,
|
DEFAULT_BINDING_HEALTH,
|
||||||
addAddressNode,
|
addAddressNode,
|
||||||
addCommonFqdn,
|
addCommonFqdn,
|
||||||
|
addExtraFqdn,
|
||||||
emptyAddressBlock,
|
emptyAddressBlock,
|
||||||
emptyBindingDraft,
|
emptyBindingDraft,
|
||||||
hydrateAddressBlock,
|
hydrateAddressBlock,
|
||||||
|
patchAddressIpMeta,
|
||||||
removeAddressNode,
|
removeAddressNode,
|
||||||
toAddressBindings,
|
toAddressBindings,
|
||||||
toDomainsPayload,
|
toDomainsPayload,
|
||||||
@@ -44,8 +46,8 @@ describe('hydrateAddressBlock', () => {
|
|||||||
|
|
||||||
expect(state.commonFqdns).toEqual(['rutg.rkns.top'])
|
expect(state.commonFqdns).toEqual(['rutg.rkns.top'])
|
||||||
expect(state.nodes).toEqual([
|
expect(state.nodes).toEqual([
|
||||||
{ ip: '93.115.203.183', extraFqdn: 'msk.rutg.rkns.top' },
|
{ ip: '93.115.203.183', extraFqdns: ['msk.rutg.rkns.top'] },
|
||||||
{ ip: '185.244.181.61', extraFqdn: '' },
|
{ ip: '185.244.181.61', extraFqdns: [] },
|
||||||
])
|
])
|
||||||
expect(state.preservedBindings).toEqual([])
|
expect(state.preservedBindings).toEqual([])
|
||||||
})
|
})
|
||||||
@@ -65,7 +67,7 @@ describe('hydrateAddressBlock', () => {
|
|||||||
const state = hydrateAddressBlock(drafts, ['1.1.1.1', '2.2.2.2'])
|
const state = hydrateAddressBlock(drafts, ['1.1.1.1', '2.2.2.2'])
|
||||||
|
|
||||||
expect(state.commonFqdns).toEqual(['rutg.rkns.top', 'both.rkns.top'])
|
expect(state.commonFqdns).toEqual(['rutg.rkns.top', 'both.rkns.top'])
|
||||||
expect(state.nodes.every((node) => node.extraFqdn === '')).toBe(true)
|
expect(state.nodes.every((node) => node.extraFqdns.length === 0)).toBe(true)
|
||||||
expect(state.preservedBindings.map((item) => item.fqdn)).toEqual(['alias.rkns.top'])
|
expect(state.preservedBindings.map((item) => item.fqdn)).toEqual(['alias.rkns.top'])
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -77,11 +79,105 @@ describe('hydrateAddressBlock', () => {
|
|||||||
|
|
||||||
const state = hydrateAddressBlock(drafts, ['10.0.0.1'])
|
const state = hydrateAddressBlock(drafts, ['10.0.0.1'])
|
||||||
|
|
||||||
expect(state.nodes).toEqual([{ ip: '10.0.0.1', extraFqdn: '' }])
|
expect(state.nodes).toEqual([{ ip: '10.0.0.1', extraFqdns: [] }])
|
||||||
expect(state.commonFqdns).toEqual(['gw.example.com'])
|
expect(state.commonFqdns).toEqual(['gw.example.com'])
|
||||||
expect(state.preservedBindings).toHaveLength(1)
|
expect(state.preservedBindings).toHaveLength(1)
|
||||||
expect(state.preservedBindings[0]?.fqdn).toBe('edge.example.com')
|
expect(state.preservedBindings[0]?.fqdn).toBe('edge.example.com')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('при одном IP пула отделяет второй A в extraFqdn узла', () => {
|
||||||
|
const drafts = [
|
||||||
|
aRecord('dns.shnt.top', ['130.49.213.176']),
|
||||||
|
aRecord('ndns.shnt.top', ['130.49.213.176']),
|
||||||
|
]
|
||||||
|
|
||||||
|
const state = hydrateAddressBlock(drafts, ['130.49.213.176'])
|
||||||
|
|
||||||
|
expect(state.commonFqdns).toEqual(['dns.shnt.top'])
|
||||||
|
expect(state.nodes).toEqual([
|
||||||
|
{ ip: '130.49.213.176', extraFqdns: ['ndns.shnt.top'] },
|
||||||
|
])
|
||||||
|
expect(state.preservedBindings).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('кладёт несколько extra A на один IP в extraFqdns, включая wildcard', () => {
|
||||||
|
const drafts = [
|
||||||
|
aRecord('dns.shnt.top', ['130.49.213.176']),
|
||||||
|
aRecord('ndns.shnt.top', ['130.49.213.176']),
|
||||||
|
aRecord('*.mdns.shnt.top', ['130.49.213.176']),
|
||||||
|
]
|
||||||
|
|
||||||
|
const state = hydrateAddressBlock(drafts, ['130.49.213.176'])
|
||||||
|
|
||||||
|
expect(state.commonFqdns).toEqual(['dns.shnt.top'])
|
||||||
|
expect(state.nodes).toEqual([
|
||||||
|
{
|
||||||
|
ip: '130.49.213.176',
|
||||||
|
extraFqdns: ['ndns.shnt.top', '*.mdns.shnt.top'],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
expect(state.preservedBindings).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('поднимает веса и приоритеты с общего FQDN', () => {
|
||||||
|
const drafts = [
|
||||||
|
aRecord('gt.rkns.top', ['130.49.213.153', '93.115.203.183'], {
|
||||||
|
target_ip_weights: { '130.49.213.153': 3, '93.115.203.183': 1 },
|
||||||
|
target_ip_priorities: { '130.49.213.153': 2, '93.115.203.183': 1 },
|
||||||
|
}),
|
||||||
|
aRecord('nsgt.rkns.top', ['130.49.213.153']),
|
||||||
|
]
|
||||||
|
const state = hydrateAddressBlock(drafts, ['130.49.213.153', '93.115.203.183'])
|
||||||
|
expect(state.target_ip_weights).toEqual({
|
||||||
|
'130.49.213.153': 3,
|
||||||
|
'93.115.203.183': 1,
|
||||||
|
})
|
||||||
|
expect(state.target_ip_priorities).toEqual({
|
||||||
|
'130.49.213.153': 2,
|
||||||
|
'93.115.203.183': 1,
|
||||||
|
})
|
||||||
|
expect(state.nodes[0]?.extraFqdns).toEqual(['nsgt.rkns.top'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('лечит урезанный общий FQDN (legacy toggle) как common, не preserved', () => {
|
||||||
|
const pool = ['130.49.213.153', '130.49.213.176', '93.115.203.183']
|
||||||
|
const drafts = [
|
||||||
|
// corrupted common — missing one pool IP
|
||||||
|
aRecord('gw.pngs.top', ['130.49.213.153', '130.49.213.176']),
|
||||||
|
aRecord('gt.rkns.top', pool),
|
||||||
|
aRecord('nsgt.rkns.top', ['130.49.213.176']),
|
||||||
|
aRecord('rutg.rkns.top', ['93.115.203.183']),
|
||||||
|
]
|
||||||
|
const state = hydrateAddressBlock(drafts, pool)
|
||||||
|
|
||||||
|
expect(state.commonFqdns).toEqual(['gw.pngs.top', 'gt.rkns.top'])
|
||||||
|
expect(state.nodes).toEqual([
|
||||||
|
{ ip: '130.49.213.153', extraFqdns: [] },
|
||||||
|
{ ip: '130.49.213.176', extraFqdns: ['nsgt.rkns.top'] },
|
||||||
|
{ ip: '93.115.203.183', extraFqdns: ['rutg.rkns.top'] },
|
||||||
|
])
|
||||||
|
expect(state.preservedBindings).toEqual([])
|
||||||
|
|
||||||
|
const payload = toDomainsPayload(state, primaryMeta)
|
||||||
|
expect(payload.map((item) => item.fqdn)).toEqual([
|
||||||
|
'gw.pngs.top',
|
||||||
|
'gt.rkns.top',
|
||||||
|
'nsgt.rkns.top',
|
||||||
|
'rutg.rkns.top',
|
||||||
|
])
|
||||||
|
expect(payload[0]?.target_ips).toEqual(pool)
|
||||||
|
expect(new Set(payload.map((item) => item.fqdn.toLowerCase())).size).toBe(4)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('не дублирует FQDN при повторном binding в drafts', () => {
|
||||||
|
const pool = ['1.1.1.1', '2.2.2.2']
|
||||||
|
const state = hydrateAddressBlock(
|
||||||
|
[aRecord('gw.example.com', ['1.1.1.1']), aRecord('gw.example.com', pool)],
|
||||||
|
pool,
|
||||||
|
)
|
||||||
|
expect(state.commonFqdns).toEqual(['gw.example.com'])
|
||||||
|
expect(state.nodes.every((node) => node.extraFqdns.length === 0)).toBe(true)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('toDomainsPayload', () => {
|
describe('toDomainsPayload', () => {
|
||||||
@@ -122,6 +218,40 @@ describe('toDomainsPayload', () => {
|
|||||||
expect(second.nodes).toEqual(first.nodes)
|
expect(second.nodes).toEqual(first.nodes)
|
||||||
expect(second.preservedBindings).toEqual([])
|
expect(second.preservedBindings).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('круг hydrate → payload → hydrate сохраняет extra FQDN при одном IP', () => {
|
||||||
|
const drafts = [
|
||||||
|
aRecord('dns.shnt.top', ['130.49.213.176']),
|
||||||
|
aRecord('ndns.shnt.top', ['130.49.213.176']),
|
||||||
|
]
|
||||||
|
const first = hydrateAddressBlock(drafts, ['130.49.213.176'])
|
||||||
|
expect(first.commonFqdns).toEqual(['dns.shnt.top'])
|
||||||
|
expect(first.nodes).toEqual([
|
||||||
|
{ ip: '130.49.213.176', extraFqdns: ['ndns.shnt.top'] },
|
||||||
|
])
|
||||||
|
const rebound = toAddressBindings(first, primaryMeta)
|
||||||
|
const second = hydrateAddressBlock(rebound, ['130.49.213.176'])
|
||||||
|
|
||||||
|
expect(second.commonFqdns).toEqual(first.commonFqdns)
|
||||||
|
expect(second.nodes).toEqual(first.nodes)
|
||||||
|
expect(second.preservedBindings).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('круг hydrate → payload → hydrate сохраняет несколько extra и wildcard при одном IP', () => {
|
||||||
|
const drafts = [
|
||||||
|
aRecord('dns.shnt.top', ['130.49.213.176']),
|
||||||
|
aRecord('ndns.shnt.top', ['130.49.213.176']),
|
||||||
|
aRecord('*.mdns.shnt.top', ['130.49.213.176']),
|
||||||
|
]
|
||||||
|
const first = hydrateAddressBlock(drafts, ['130.49.213.176'])
|
||||||
|
expect(first.nodes[0]?.extraFqdns).toEqual(['ndns.shnt.top', '*.mdns.shnt.top'])
|
||||||
|
const rebound = toAddressBindings(first, primaryMeta)
|
||||||
|
const second = hydrateAddressBlock(rebound, ['130.49.213.176'])
|
||||||
|
|
||||||
|
expect(second.commonFqdns).toEqual(first.commonFqdns)
|
||||||
|
expect(second.nodes).toEqual(first.nodes)
|
||||||
|
expect(second.preservedBindings).toEqual([])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('removeAddressNode', () => {
|
describe('removeAddressNode', () => {
|
||||||
@@ -137,7 +267,7 @@ describe('removeAddressNode', () => {
|
|||||||
|
|
||||||
const next = removeAddressNode(state, '10.0.0.1')
|
const next = removeAddressNode(state, '10.0.0.1')
|
||||||
|
|
||||||
expect(next.nodes).toEqual([{ ip: '10.0.0.2', extraFqdn: '' }])
|
expect(next.nodes).toEqual([{ ip: '10.0.0.2', extraFqdns: [] }])
|
||||||
expect(next.preservedBindings).toHaveLength(1)
|
expect(next.preservedBindings).toHaveLength(1)
|
||||||
expect(next.preservedBindings[0]?.target_ips).toEqual(['9.9.9.9'])
|
expect(next.preservedBindings[0]?.target_ips).toEqual(['9.9.9.9'])
|
||||||
})
|
})
|
||||||
@@ -146,7 +276,7 @@ describe('removeAddressNode', () => {
|
|||||||
describe('addAddressNode / addCommonFqdn', () => {
|
describe('addAddressNode / addCommonFqdn', () => {
|
||||||
it('не добавляет дубликат IP', () => {
|
it('не добавляет дубликат IP', () => {
|
||||||
const withIp = addAddressNode(
|
const withIp = addAddressNode(
|
||||||
{ ...emptyAddressBlock(), nodes: [{ ip: '1.1.1.1', extraFqdn: '' }] },
|
{ ...emptyAddressBlock(), nodes: [{ ip: '1.1.1.1', extraFqdns: [] }] },
|
||||||
'1.1.1.1',
|
'1.1.1.1',
|
||||||
)
|
)
|
||||||
expect(withIp.nodes).toHaveLength(1)
|
expect(withIp.nodes).toHaveLength(1)
|
||||||
@@ -159,6 +289,42 @@ describe('addAddressNode / addCommonFqdn', () => {
|
|||||||
)
|
)
|
||||||
expect(state.commonFqdns).toEqual(['gt.rkns.top'])
|
expect(state.commonFqdns).toEqual(['gt.rkns.top'])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('добавляет extra FQDN к IP и отклоняет дубликат', () => {
|
||||||
|
const withIp = addAddressNode(emptyAddressBlock(), '1.1.1.1')
|
||||||
|
const withExtra = addExtraFqdn(withIp, '1.1.1.1', 'mdns.shnt.top')
|
||||||
|
expect(withExtra.nodes[0]?.extraFqdns).toEqual(['mdns.shnt.top'])
|
||||||
|
expect(addExtraFqdn(withExtra, '1.1.1.1', 'MDNS.shnt.top')).toBe(withExtra)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('patchAddressIpMeta', () => {
|
||||||
|
it('меняет вес одного IP и не трогает extraFqdns', () => {
|
||||||
|
const state = {
|
||||||
|
...addAddressNode(addAddressNode(emptyAddressBlock(), '1.1.1.1'), '2.2.2.2'),
|
||||||
|
nodes: [
|
||||||
|
{ ip: '1.1.1.1', extraFqdns: ['msk.example.com'] },
|
||||||
|
{ ip: '2.2.2.2', extraFqdns: [] },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
const next = patchAddressIpMeta(state, '1.1.1.1', { weight: 7 })
|
||||||
|
expect(next.target_ip_weights).toEqual({ '1.1.1.1': 7, '2.2.2.2': 1 })
|
||||||
|
expect(next.target_ip_priorities).toEqual(state.target_ip_priorities)
|
||||||
|
expect(next.nodes).toEqual(state.nodes)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clamp веса и приоритета в 1–100', () => {
|
||||||
|
const state = addAddressNode(emptyAddressBlock(), '10.0.0.1')
|
||||||
|
expect(patchAddressIpMeta(state, '10.0.0.1', { weight: 0 }).target_ip_weights['10.0.0.1']).toBe(1)
|
||||||
|
expect(
|
||||||
|
patchAddressIpMeta(state, '10.0.0.1', { priority: 999 }).target_ip_priorities['10.0.0.1'],
|
||||||
|
).toBe(100)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('игнорирует IP вне пула', () => {
|
||||||
|
const state = addAddressNode(emptyAddressBlock(), '10.0.0.1')
|
||||||
|
expect(patchAddressIpMeta(state, '8.8.8.8', { weight: 5 })).toBe(state)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('CNAME / preservedBindings', () => {
|
describe('CNAME / preservedBindings', () => {
|
||||||
@@ -174,7 +340,7 @@ describe('CNAME / preservedBindings', () => {
|
|||||||
cname,
|
cname,
|
||||||
]
|
]
|
||||||
const state = hydrateAddressBlock(drafts, ['1.1.1.1', '2.2.2.2'])
|
const state = hydrateAddressBlock(drafts, ['1.1.1.1', '2.2.2.2'])
|
||||||
expect(state.nodes[0]?.extraFqdn).toBe('msk.rkns.top')
|
expect(state.nodes[0]?.extraFqdns).toEqual(['msk.rkns.top'])
|
||||||
expect(state.preservedBindings).toHaveLength(1)
|
expect(state.preservedBindings).toHaveLength(1)
|
||||||
|
|
||||||
const payload = toDomainsPayload(state, primaryMeta)
|
const payload = toDomainsPayload(state, primaryMeta)
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export interface ServiceBindingDraft {
|
|||||||
|
|
||||||
export interface AddressNode {
|
export interface AddressNode {
|
||||||
ip: string
|
ip: string
|
||||||
extraFqdn: string
|
extraFqdns: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AddressBlockState {
|
export interface AddressBlockState {
|
||||||
@@ -113,6 +113,10 @@ function sameIpSet(left: string[], right: string[]): boolean {
|
|||||||
return right.every((ip) => set.has(ip.trim()))
|
return right.every((ip) => set.has(ip.trim()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fqdnKey(value: string): string {
|
||||||
|
return value.trim().toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
export function toBindingDrafts(service: ServiceView): ServiceBindingDraft[] {
|
export function toBindingDrafts(service: ServiceView): ServiceBindingDraft[] {
|
||||||
return (service.domains ?? []).map((binding) => ({
|
return (service.domains ?? []).map((binding) => ({
|
||||||
fqdn: bindingToFqdn(binding),
|
fqdn: bindingToFqdn(binding),
|
||||||
@@ -145,6 +149,36 @@ function isFullPoolA(draft: ServiceBindingDraft, pool: string[]): boolean {
|
|||||||
return draft.record_type === 'A' && sameIpSet(draft.target_ips, pool)
|
return draft.record_type === 'A' && sameIpSet(draft.target_ips, pool)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** UI contract: common = 2+ IPs all in pool (full or corrupted subset after old toggles). */
|
||||||
|
function isCommonPoolA(draft: ServiceBindingDraft, poolSet: Set<string>): boolean {
|
||||||
|
if (draft.record_type !== 'A') return false
|
||||||
|
const ips = draft.target_ips.map((ip) => ip.trim()).filter(Boolean)
|
||||||
|
if (ips.length < 2) return false
|
||||||
|
return ips.every((ip) => poolSet.has(ip))
|
||||||
|
}
|
||||||
|
|
||||||
|
function takeAsCommon(
|
||||||
|
draft: ServiceBindingDraft,
|
||||||
|
fqdn: string,
|
||||||
|
commonFqdns: string[],
|
||||||
|
seenCommon: Set<string>,
|
||||||
|
weights: Record<string, number>,
|
||||||
|
priorities: Record<string, number>,
|
||||||
|
): { weights: Record<string, number>; priorities: Record<string, number> } {
|
||||||
|
const key = fqdnKey(fqdn)
|
||||||
|
if (fqdn && key && !seenCommon.has(key)) {
|
||||||
|
seenCommon.add(key)
|
||||||
|
commonFqdns.push(draft.fqdn)
|
||||||
|
}
|
||||||
|
if (Object.keys(weights).length === 0) {
|
||||||
|
return {
|
||||||
|
weights: { ...draft.target_ip_weights },
|
||||||
|
priorities: { ...draft.target_ip_priorities },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { weights, priorities }
|
||||||
|
}
|
||||||
|
|
||||||
export function hydrateAddressBlock(
|
export function hydrateAddressBlock(
|
||||||
drafts: ServiceBindingDraft[],
|
drafts: ServiceBindingDraft[],
|
||||||
pool: string[] = [],
|
pool: string[] = [],
|
||||||
@@ -161,27 +195,73 @@ export function hydrateAddressBlock(
|
|||||||
: uniqueIps(...(multiIpTargets.length > 0 ? multiIpTargets : allAIps))
|
: uniqueIps(...(multiIpTargets.length > 0 ? multiIpTargets : allAIps))
|
||||||
const poolSet = new Set(ips)
|
const poolSet = new Set(ips)
|
||||||
const commonFqdns: string[] = []
|
const commonFqdns: string[] = []
|
||||||
const claimed = new Set<string>()
|
const seenCommon = new Set<string>()
|
||||||
const extraByIp = new Map<string, string>()
|
const seenExtra = new Set<string>()
|
||||||
|
const extraByIp = new Map<string, string[]>()
|
||||||
const preservedBindings: ServiceBindingDraft[] = []
|
const preservedBindings: ServiceBindingDraft[] = []
|
||||||
let weights: Record<string, number> = {}
|
let weights: Record<string, number> = {}
|
||||||
let priorities: Record<string, number> = {}
|
let priorities: Record<string, number> = {}
|
||||||
|
const splitSinglePool =
|
||||||
|
ips.length === 1 &&
|
||||||
|
drafts.filter((draft) => isFullPoolA(draft, ips) || isCommonPoolA(draft, poolSet))
|
||||||
|
.length > 1
|
||||||
|
let assignedFirstSinglePoolCommon = false
|
||||||
|
|
||||||
|
function pushExtra(ip: string, fqdn: string) {
|
||||||
|
const key = fqdnKey(fqdn)
|
||||||
|
if (!key || seenExtra.has(key) || seenCommon.has(key)) return
|
||||||
|
seenExtra.add(key)
|
||||||
|
const list = extraByIp.get(ip) ?? []
|
||||||
|
list.push(fqdn)
|
||||||
|
extraByIp.set(ip, list)
|
||||||
|
}
|
||||||
|
|
||||||
|
function promoteToCommon(draft: ServiceBindingDraft, fqdn: string) {
|
||||||
|
const key = fqdnKey(fqdn)
|
||||||
|
if (key && seenExtra.has(key)) {
|
||||||
|
seenExtra.delete(key)
|
||||||
|
for (const [ip, list] of extraByIp) {
|
||||||
|
extraByIp.set(
|
||||||
|
ip,
|
||||||
|
list.filter((item) => fqdnKey(item) !== key),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const next = takeAsCommon(
|
||||||
|
draft,
|
||||||
|
fqdn,
|
||||||
|
commonFqdns,
|
||||||
|
seenCommon,
|
||||||
|
weights,
|
||||||
|
priorities,
|
||||||
|
)
|
||||||
|
weights = next.weights
|
||||||
|
priorities = next.priorities
|
||||||
|
}
|
||||||
|
|
||||||
for (const draft of drafts) {
|
for (const draft of drafts) {
|
||||||
const fqdn = draft.fqdn.trim()
|
const fqdn = draft.fqdn.trim()
|
||||||
if (isFullPoolA(draft, ips)) {
|
if (splitSinglePool && (isFullPoolA(draft, ips) || isCommonPoolA(draft, poolSet))) {
|
||||||
if (fqdn) commonFqdns.push(draft.fqdn)
|
if (!assignedFirstSinglePoolCommon) {
|
||||||
if (Object.keys(weights).length === 0) {
|
assignedFirstSinglePoolCommon = true
|
||||||
weights = { ...draft.target_ip_weights }
|
promoteToCommon(draft, fqdn)
|
||||||
priorities = { ...draft.target_ip_priorities }
|
continue
|
||||||
}
|
}
|
||||||
|
const ip = draft.target_ips[0]?.trim() ?? ''
|
||||||
|
if (ip && poolSet.has(ip) && fqdn) {
|
||||||
|
pushExtra(ip, draft.fqdn)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Full pool OR multi-IP subset of pool → common (heals orphaned toggle damage).
|
||||||
|
if (isFullPoolA(draft, ips) || isCommonPoolA(draft, poolSet)) {
|
||||||
|
promoteToCommon(draft, fqdn)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (draft.record_type === 'A' && draft.target_ips.length === 1) {
|
if (draft.record_type === 'A' && draft.target_ips.length === 1) {
|
||||||
const ip = draft.target_ips[0]?.trim() ?? ''
|
const ip = draft.target_ips[0]?.trim() ?? ''
|
||||||
if (ip && poolSet.has(ip) && fqdn && !claimed.has(ip)) {
|
if (ip && poolSet.has(ip) && fqdn) {
|
||||||
claimed.add(ip)
|
pushExtra(ip, draft.fqdn)
|
||||||
extraByIp.set(ip, draft.fqdn)
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -192,7 +272,7 @@ export function hydrateAddressBlock(
|
|||||||
commonFqdns,
|
commonFqdns,
|
||||||
nodes: ips.map((ip) => ({
|
nodes: ips.map((ip) => ({
|
||||||
ip,
|
ip,
|
||||||
extraFqdn: extraByIp.get(ip) ?? '',
|
extraFqdns: extraByIp.get(ip) ?? [],
|
||||||
})),
|
})),
|
||||||
preservedBindings,
|
preservedBindings,
|
||||||
target_ip_weights: weights,
|
target_ip_weights: weights,
|
||||||
@@ -237,28 +317,66 @@ export function addAddressNode(state: AddressBlockState, ip: string): AddressBlo
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
nodes: [...state.nodes, { ip: trimmed, extraFqdn: '' }],
|
nodes: [...state.nodes, { ip: trimmed, extraFqdns: [] }],
|
||||||
target_ip_weights: { ...state.target_ip_weights, [trimmed]: 1 },
|
target_ip_weights: { ...state.target_ip_weights, [trimmed]: 1 },
|
||||||
target_ip_priorities: { ...state.target_ip_priorities, [trimmed]: 1 },
|
target_ip_priorities: { ...state.target_ip_priorities, [trimmed]: 1 },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function fqdnKey(value: string): string {
|
const LB_META_MIN = 1
|
||||||
return value.trim().toLowerCase()
|
const LB_META_MAX = 100
|
||||||
|
|
||||||
|
function clampLbMeta(value: number): number {
|
||||||
|
if (!Number.isFinite(value)) return LB_META_MIN
|
||||||
|
return Math.min(LB_META_MAX, Math.max(LB_META_MIN, Math.round(value)))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function patchAddressIpMeta(
|
||||||
|
state: AddressBlockState,
|
||||||
|
ip: string,
|
||||||
|
meta: { weight?: number; priority?: number },
|
||||||
|
): AddressBlockState {
|
||||||
|
if (!state.nodes.some((node) => node.ip === ip)) return state
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
target_ip_weights:
|
||||||
|
meta.weight === undefined
|
||||||
|
? state.target_ip_weights
|
||||||
|
: { ...state.target_ip_weights, [ip]: clampLbMeta(meta.weight) },
|
||||||
|
target_ip_priorities:
|
||||||
|
meta.priority === undefined
|
||||||
|
? state.target_ip_priorities
|
||||||
|
: { ...state.target_ip_priorities, [ip]: clampLbMeta(meta.priority) },
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function addressHasFqdn(state: AddressBlockState, fqdn: string): boolean {
|
export function addressHasFqdn(state: AddressBlockState, fqdn: string): boolean {
|
||||||
const key = fqdnKey(fqdn)
|
const key = fqdnKey(fqdn)
|
||||||
if (!key) return false
|
if (!key) return false
|
||||||
if (state.commonFqdns.some((item) => fqdnKey(item) === key)) return true
|
if (state.commonFqdns.some((item) => fqdnKey(item) === key)) return true
|
||||||
if (state.nodes.some((node) => fqdnKey(node.extraFqdn) === key)) return true
|
if (state.nodes.some((node) => node.extraFqdns.some((item) => fqdnKey(item) === key))) {
|
||||||
return false
|
return true
|
||||||
|
}
|
||||||
|
return state.preservedBindings.some((item) => fqdnKey(item.fqdn) === key)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function addCommonFqdn(state: AddressBlockState, fqdn: string): AddressBlockState {
|
export function addCommonFqdn(state: AddressBlockState, fqdn: string): AddressBlockState {
|
||||||
const trimmed = fqdn.trim()
|
const trimmed = fqdn.trim()
|
||||||
if (!trimmed || addressHasFqdn(state, trimmed)) return state
|
if (!trimmed) return state
|
||||||
return { ...state, commonFqdns: [...state.commonFqdns, trimmed] }
|
const key = fqdnKey(trimmed)
|
||||||
|
if (state.commonFqdns.some((item) => fqdnKey(item) === key)) return state
|
||||||
|
if (state.nodes.some((node) => node.extraFqdns.some((item) => fqdnKey(item) === key))) {
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
// Promote out of invisible preserved (corrupted / CNAME-adjacent duplicates).
|
||||||
|
const preservedBindings = state.preservedBindings.filter(
|
||||||
|
(item) => fqdnKey(item.fqdn) !== key,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
commonFqdns: [...state.commonFqdns, trimmed],
|
||||||
|
preservedBindings,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function removeCommonFqdn(state: AddressBlockState, index: number): AddressBlockState {
|
export function removeCommonFqdn(state: AddressBlockState, index: number): AddressBlockState {
|
||||||
@@ -279,6 +397,62 @@ export function updateCommonFqdn(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function addExtraFqdn(
|
||||||
|
state: AddressBlockState,
|
||||||
|
ip: string,
|
||||||
|
fqdn: string,
|
||||||
|
): AddressBlockState {
|
||||||
|
const trimmed = fqdn.trim()
|
||||||
|
if (!trimmed) return state
|
||||||
|
const key = fqdnKey(trimmed)
|
||||||
|
if (state.commonFqdns.some((item) => fqdnKey(item) === key)) return state
|
||||||
|
if (state.nodes.some((node) => node.extraFqdns.some((item) => fqdnKey(item) === key))) {
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
if (!state.nodes.some((node) => node.ip === ip)) return state
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
preservedBindings: state.preservedBindings.filter((item) => fqdnKey(item.fqdn) !== key),
|
||||||
|
nodes: state.nodes.map((node) =>
|
||||||
|
node.ip === ip ? { ...node, extraFqdns: [...node.extraFqdns, trimmed] } : node,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeExtraFqdn(
|
||||||
|
state: AddressBlockState,
|
||||||
|
ip: string,
|
||||||
|
index: number,
|
||||||
|
): AddressBlockState {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
nodes: state.nodes.map((node) =>
|
||||||
|
node.ip === ip
|
||||||
|
? { ...node, extraFqdns: node.extraFqdns.filter((_, i) => i !== index) }
|
||||||
|
: node,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateExtraFqdn(
|
||||||
|
state: AddressBlockState,
|
||||||
|
ip: string,
|
||||||
|
index: number,
|
||||||
|
fqdn: string,
|
||||||
|
): AddressBlockState {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
nodes: state.nodes.map((node) =>
|
||||||
|
node.ip === ip
|
||||||
|
? {
|
||||||
|
...node,
|
||||||
|
extraFqdns: node.extraFqdns.map((item, i) => (i === index ? fqdn : item)),
|
||||||
|
}
|
||||||
|
: node,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function toAddressBindings(
|
export function toAddressBindings(
|
||||||
state: AddressBlockState,
|
state: AddressBlockState,
|
||||||
primary: AddressPrimaryMeta,
|
primary: AddressPrimaryMeta,
|
||||||
@@ -308,21 +482,29 @@ export function toAddressBindings(
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const node of state.nodes) {
|
for (const node of state.nodes) {
|
||||||
const extraFqdn = node.extraFqdn.trim()
|
for (const raw of node.extraFqdns) {
|
||||||
if (!extraFqdn) continue
|
const extraFqdn = raw.trim()
|
||||||
drafts.push({
|
if (!extraFqdn) continue
|
||||||
fqdn: extraFqdn,
|
drafts.push({
|
||||||
record_type: 'A',
|
fqdn: extraFqdn,
|
||||||
target_ips: [node.ip],
|
record_type: 'A',
|
||||||
target_cname: '',
|
target_ips: [node.ip],
|
||||||
lb_mode: primary.lb_mode,
|
target_cname: '',
|
||||||
health: { ...primary.health },
|
lb_mode: primary.lb_mode,
|
||||||
target_ip_weights: { [node.ip]: weights[node.ip] ?? 1 },
|
health: { ...primary.health },
|
||||||
target_ip_priorities: { [node.ip]: priorities[node.ip] ?? 1 },
|
target_ip_weights: { [node.ip]: weights[node.ip] ?? 1 },
|
||||||
})
|
target_ip_priorities: { [node.ip]: priorities[node.ip] ?? 1 },
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
drafts.push(...state.preservedBindings)
|
const seen = new Set(drafts.map((item) => fqdnKey(item.fqdn)).filter(Boolean))
|
||||||
|
for (const preserved of state.preservedBindings) {
|
||||||
|
const key = fqdnKey(preserved.fqdn)
|
||||||
|
if (!key || seen.has(key)) continue
|
||||||
|
seen.add(key)
|
||||||
|
drafts.push(preserved)
|
||||||
|
}
|
||||||
return drafts
|
return drafts
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ export const serviceGroupsQueryOptions = () =>
|
|||||||
}
|
}
|
||||||
return parsed.data
|
return parsed.data
|
||||||
},
|
},
|
||||||
|
refetchInterval: 10_000,
|
||||||
|
staleTime: 5_000,
|
||||||
})
|
})
|
||||||
|
|
||||||
export const servicesQueryOptions = () =>
|
export const servicesQueryOptions = () =>
|
||||||
@@ -101,6 +103,8 @@ export const serviceViewQueryOptions = (id: number) =>
|
|||||||
const data = await api.get<unknown>(`/api/v1/services/${id}`)
|
const data = await api.get<unknown>(`/api/v1/services/${id}`)
|
||||||
return serviceViewSchema.parse(data)
|
return serviceViewSchema.parse(data)
|
||||||
},
|
},
|
||||||
|
refetchInterval: 10_000,
|
||||||
|
staleTime: 5_000,
|
||||||
})
|
})
|
||||||
|
|
||||||
export const serviceHealthLogQueryOptions = (id: number) =>
|
export const serviceHealthLogQueryOptions = (id: number) =>
|
||||||
@@ -110,6 +114,8 @@ export const serviceHealthLogQueryOptions = (id: number) =>
|
|||||||
const data = await api.get<unknown>(`/api/v1/services/${id}/health-log`)
|
const data = await api.get<unknown>(`/api/v1/services/${id}/health-log`)
|
||||||
return z.object({ items: z.array(healthProbeLogSchema) }).parse(data)
|
return z.object({ items: z.array(healthProbeLogSchema) }).parse(data)
|
||||||
},
|
},
|
||||||
|
refetchInterval: 10_000,
|
||||||
|
staleTime: 5_000,
|
||||||
})
|
})
|
||||||
|
|
||||||
export const serviceFailoverLogQueryOptions = (id: number) =>
|
export const serviceFailoverLogQueryOptions = (id: number) =>
|
||||||
|
|||||||
@@ -32,9 +32,11 @@ import {
|
|||||||
ServiceHealthMonitor,
|
ServiceHealthMonitor,
|
||||||
} from '@/components/reui-kit'
|
} from '@/components/reui-kit'
|
||||||
import { api } from '@/lib/api-client'
|
import { api } from '@/lib/api-client'
|
||||||
|
import { hasSharedPool, uniqueIpCount } from '@/lib/failover-events'
|
||||||
import {
|
import {
|
||||||
enabledHealthProviders,
|
enabledHealthProviders,
|
||||||
providerHealthStatuses,
|
providerHealthStatuses,
|
||||||
|
resolveServiceDisplayHealth,
|
||||||
} from '@/lib/health-log'
|
} from '@/lib/health-log'
|
||||||
import type { ServiceView, UpdateServiceConfigInput } from '@/lib/schemas'
|
import type { ServiceView, UpdateServiceConfigInput } from '@/lib/schemas'
|
||||||
import {
|
import {
|
||||||
@@ -115,6 +117,22 @@ function ServiceDetailPage() {
|
|||||||
})),
|
})),
|
||||||
[service?.domains],
|
[service?.domains],
|
||||||
)
|
)
|
||||||
|
const uniqueEnabledIps = useMemo(
|
||||||
|
() =>
|
||||||
|
(service?.ips ?? []).filter((ip) => service?.ip_enabled[ip] !== false),
|
||||||
|
[service?.ips, service?.ip_enabled],
|
||||||
|
)
|
||||||
|
const displayHealth = useMemo(
|
||||||
|
() =>
|
||||||
|
resolveServiceDisplayHealth(
|
||||||
|
service?.health_status,
|
||||||
|
service?.ip_health ?? [],
|
||||||
|
logItems,
|
||||||
|
),
|
||||||
|
[service?.health_status, service?.ip_health, logItems],
|
||||||
|
)
|
||||||
|
const showPoolPanel =
|
||||||
|
uniqueEnabledIps.length >= 2 && hasSharedPool(failoverBindings)
|
||||||
const nodes = (nodesQuery.data as OverviewPayload['nodes']) ?? []
|
const nodes = (nodesQuery.data as OverviewPayload['nodes']) ?? []
|
||||||
|
|
||||||
const [editOpen, setEditOpen] = useState(false)
|
const [editOpen, setEditOpen] = useState(false)
|
||||||
@@ -250,8 +268,11 @@ function ServiceDetailPage() {
|
|||||||
description="Domain → Service → Node → Health → Failover"
|
description="Domain → Service → Node → Health → Failover"
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<LbModeTile mode={service.lb_mode} />
|
<LbModeTile
|
||||||
<HealthCheckBadge status={service.health_status} />
|
mode={service.lb_mode}
|
||||||
|
hasPool={uniqueIpCount(service.ips) >= 2}
|
||||||
|
/>
|
||||||
|
<HealthCheckBadge status={displayHealth} />
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger
|
<TooltipTrigger
|
||||||
render={
|
render={
|
||||||
@@ -278,20 +299,20 @@ function ServiceDetailPage() {
|
|||||||
id: 'status',
|
id: 'status',
|
||||||
icon: <ActivityIcon />,
|
icon: <ActivityIcon />,
|
||||||
label: 'Статус',
|
label: 'Статус',
|
||||||
value: service.health_status === 'up' ? 'OK' : service.health_status,
|
value: displayHealth === 'up' ? 'OK' : displayHealth,
|
||||||
variant:
|
variant:
|
||||||
service.health_status === 'down'
|
displayHealth === 'down'
|
||||||
? 'destructive'
|
? 'destructive'
|
||||||
: service.health_status === 'degraded'
|
: displayHealth === 'degraded'
|
||||||
? 'warning'
|
? 'warning'
|
||||||
: 'default',
|
: 'default',
|
||||||
iconClassName:
|
iconClassName:
|
||||||
service.health_status === 'down'
|
displayHealth === 'down'
|
||||||
? 'text-destructive'
|
? 'text-destructive'
|
||||||
: service.health_status === 'degraded'
|
: displayHealth === 'degraded'
|
||||||
? 'text-warning'
|
? 'text-warning'
|
||||||
: 'text-success',
|
: 'text-success',
|
||||||
hint: <HealthCheckBadge status={service.health_status} size="xs" />,
|
hint: <HealthCheckBadge status={displayHealth} size="xs" />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'fqdn',
|
id: 'fqdn',
|
||||||
@@ -305,21 +326,33 @@ function ServiceDetailPage() {
|
|||||||
icon: <NetworkIcon />,
|
icon: <NetworkIcon />,
|
||||||
label: 'IP',
|
label: 'IP',
|
||||||
value: String(service.ips.length),
|
value: String(service.ips.length),
|
||||||
hint: `${service.active_ips.length} в пуле`,
|
hint: showPoolPanel
|
||||||
|
? `${service.active_ips.length} в пуле`
|
||||||
|
: uniqueEnabledIps.length <= 1
|
||||||
|
? 'без балансировки'
|
||||||
|
: service.ips.join(', ') || 'нет',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'pool',
|
id: 'pool',
|
||||||
icon: <ServerIcon />,
|
icon: <ServerIcon />,
|
||||||
label: 'Активный пул',
|
label: showPoolPanel ? 'Активный пул' : 'Адреса',
|
||||||
value: String((overview?.active_addresses ?? service.active_ips).length),
|
value: String(
|
||||||
hint: (overview?.active_addresses ?? service.active_ips).join(', ') || 'нет',
|
(overview?.active_addresses ?? service.active_ips).length,
|
||||||
|
),
|
||||||
|
hint:
|
||||||
|
(overview?.active_addresses ?? service.active_ips).join(', ') ||
|
||||||
|
'нет',
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<section
|
<section
|
||||||
aria-label="Мониторинг"
|
aria-label="Мониторинг"
|
||||||
className="grid min-w-0 items-start gap-2 @3xl:grid-cols-2"
|
className={
|
||||||
|
showPoolPanel
|
||||||
|
? 'grid min-w-0 items-start gap-2 @3xl:grid-cols-2'
|
||||||
|
: 'grid min-w-0 items-start gap-2'
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<ServiceHealthMonitor
|
<ServiceHealthMonitor
|
||||||
items={logItems}
|
items={logItems}
|
||||||
@@ -327,12 +360,16 @@ function ServiceDetailPage() {
|
|||||||
statuses={providerStatuses}
|
statuses={providerStatuses}
|
||||||
isLoading={logQuery.isLoading}
|
isLoading={logQuery.isLoading}
|
||||||
/>
|
/>
|
||||||
<ServiceFailoverPanel
|
{showPoolPanel ? (
|
||||||
ipHealth={service.ip_health}
|
<ServiceFailoverPanel
|
||||||
bindings={failoverBindings}
|
lbMode={service.lb_mode}
|
||||||
history={failoverHistory}
|
hasPool={uniqueIpCount(service.ips) >= 2}
|
||||||
probes={logItems}
|
ipHealth={service.ip_health}
|
||||||
/>
|
bindings={failoverBindings}
|
||||||
|
history={failoverHistory}
|
||||||
|
probes={logItems}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{service.ips.length === 0 && service.domains.length === 0 ? (
|
{service.ips.length === 0 && service.domains.length === 0 ? (
|
||||||
|
|||||||
+4
-3
@@ -44,9 +44,10 @@ health-check работают на двух уровнях:
|
|||||||
- **Change Domain** — перенос привязок между зонами `POST /api/v1/services/:id/change-domain`.
|
- **Change Domain** — перенос привязок между зонами `POST /api/v1/services/:id/change-domain`.
|
||||||
|
|
||||||
Режимы LB: `round_robin`, `failover`, `weighted`. В Cloudflare free `weighted`
|
Режимы LB: `round_robin`, `failover`, `weighted`. В Cloudflare free `weighted`
|
||||||
работает как `round_robin` (одна A на IP). `unknown` **не** считается healthy и
|
работает как `round_robin` (одна A на IP). В A-пул попадает всё, кроме **Down**
|
||||||
не попадает в пул, пока нет успешных проб; восстановление — `UNHEALTHY → CHECKING → HEALTHY`
|
(`unknown` / checking / Slow возвращаются в DNS на первой успешной пробе).
|
||||||
после `HEALTH_SUCCESS_RECOVERIES` (default 2). Пороги и cron движка задаются в
|
Бейдж Healthy — `UNHEALTHY → CHECKING → HEALTHY` после `HEALTH_SUCCESS_RECOVERIES`
|
||||||
|
(default 2). Пороги и cron движка задаются в
|
||||||
**Настройки → Health-check** (env — fallback, пока значения не сохранены в UI).
|
**Настройки → Health-check** (env — fallback, пока значения не сохранены в UI).
|
||||||
|
|
||||||
### Источники проб: Local, Cloudflare Worker, Globalping
|
### Источники проб: Local, Cloudflare Worker, Globalping
|
||||||
|
|||||||
@@ -2341,6 +2341,113 @@ export function listIpHealthByServiceIds(
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseIpHealthState(status: string | null): IpHealthState | undefined {
|
||||||
|
if (
|
||||||
|
status === "up" ||
|
||||||
|
status === "down" ||
|
||||||
|
status === "degraded" ||
|
||||||
|
status === "unknown"
|
||||||
|
) {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bestAliveIpState(statuses: readonly IpHealthState[]): IpHealthState {
|
||||||
|
if (statuses.some((status) => status === "up")) return "up";
|
||||||
|
if (statuses.some((status) => status === "degraded")) return "degraded";
|
||||||
|
if (statuses.some((status) => status === "down")) return "down";
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Latest probe per service+IP+provider from health_probe_log, then any-up per IP.
|
||||||
|
* Display overlay — hysteresis in ip_health_status is unchanged (DNS).
|
||||||
|
*/
|
||||||
|
export function listLatestLiveHealthByServiceIds(
|
||||||
|
db: Db,
|
||||||
|
serviceIds: number[],
|
||||||
|
): Map<number, ServiceIpHealthRow[]> {
|
||||||
|
const result = new Map<number, ServiceIpHealthRow[]>();
|
||||||
|
if (serviceIds.length === 0) return result;
|
||||||
|
const idList = sql.join(
|
||||||
|
serviceIds.map((id) => sql`${id}`),
|
||||||
|
sql`, `,
|
||||||
|
);
|
||||||
|
const rows = db.all<{
|
||||||
|
service_id: number;
|
||||||
|
ip: string;
|
||||||
|
provider: string | null;
|
||||||
|
status: string | null;
|
||||||
|
latency_ms: number | null;
|
||||||
|
last_error: string | null;
|
||||||
|
colo: string | null;
|
||||||
|
checked_at: string | null;
|
||||||
|
}>(sql`
|
||||||
|
SELECT sb.service_id AS service_id,
|
||||||
|
l.ip AS ip,
|
||||||
|
l.provider AS provider,
|
||||||
|
l.status AS status,
|
||||||
|
l.latency_ms AS latency_ms,
|
||||||
|
l.error AS last_error,
|
||||||
|
l.colo AS colo,
|
||||||
|
l.checked_at AS checked_at
|
||||||
|
FROM health_probe_log l
|
||||||
|
INNER JOIN service_bindings sb
|
||||||
|
ON l.scope = 'binding' AND l.ref_id = sb.id
|
||||||
|
INNER JOIN (
|
||||||
|
SELECT sb2.service_id AS service_id,
|
||||||
|
l2.ip AS ip,
|
||||||
|
l2.provider AS provider,
|
||||||
|
MAX(l2.id) AS max_id
|
||||||
|
FROM health_probe_log l2
|
||||||
|
INNER JOIN service_bindings sb2
|
||||||
|
ON l2.scope = 'binding' AND l2.ref_id = sb2.id
|
||||||
|
WHERE sb2.service_id IN (${idList})
|
||||||
|
GROUP BY sb2.service_id, l2.ip, l2.provider
|
||||||
|
) latest
|
||||||
|
ON latest.max_id = l.id
|
||||||
|
`);
|
||||||
|
|
||||||
|
const byServiceIp = new Map<
|
||||||
|
string,
|
||||||
|
{ serviceId: number; ip: string; probes: ServiceIpHealthRow[] }
|
||||||
|
>();
|
||||||
|
for (const row of rows) {
|
||||||
|
const status = parseIpHealthState(row.status);
|
||||||
|
if (!status) continue;
|
||||||
|
const key = `${row.service_id}\0${row.ip}`;
|
||||||
|
const probe: ServiceIpHealthRow = {
|
||||||
|
ip: row.ip,
|
||||||
|
status,
|
||||||
|
latency_ms: row.latency_ms,
|
||||||
|
last_checked_at: row.checked_at,
|
||||||
|
last_error: row.last_error,
|
||||||
|
provider: normalizeStatusProvider(row.provider),
|
||||||
|
colo: row.colo,
|
||||||
|
};
|
||||||
|
const bucket = byServiceIp.get(key);
|
||||||
|
if (bucket) bucket.probes.push(probe);
|
||||||
|
else {
|
||||||
|
byServiceIp.set(key, {
|
||||||
|
serviceId: row.service_id,
|
||||||
|
ip: row.ip,
|
||||||
|
probes: [probe],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const { serviceId, ip, probes } of byServiceIp.values()) {
|
||||||
|
const status = bestAliveIpState(probes.map((probe) => probe.status));
|
||||||
|
const preferred =
|
||||||
|
probes.find((probe) => probe.status === status) ?? probes[0]!;
|
||||||
|
const list = result.get(serviceId) ?? [];
|
||||||
|
list.push({ ...preferred, ip, status });
|
||||||
|
result.set(serviceId, list);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
export function mergeHealthAggregates(
|
export function mergeHealthAggregates(
|
||||||
parts: Array<HealthAggregate | undefined | null>,
|
parts: Array<HealthAggregate | undefined | null>,
|
||||||
): HealthAggregate {
|
): HealthAggregate {
|
||||||
|
|||||||
Vendored
+10
@@ -2247,6 +2247,11 @@ declare const cfdmBindingSyncItemSchema: z.ZodObject<{
|
|||||||
hostname: z.ZodString;
|
hostname: z.ZodString;
|
||||||
ips: z.ZodArray<z.ZodString>;
|
ips: z.ZodArray<z.ZodString>;
|
||||||
cnameTarget: z.ZodOptional<z.ZodString>;
|
cnameTarget: z.ZodOptional<z.ZodString>;
|
||||||
|
lbMode: z.ZodOptional<z.ZodEnum<{
|
||||||
|
round_robin: "round_robin";
|
||||||
|
failover: "failover";
|
||||||
|
weighted: "weighted";
|
||||||
|
}>>;
|
||||||
deleted: z.ZodOptional<z.ZodBoolean>;
|
deleted: z.ZodOptional<z.ZodBoolean>;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
declare const cfdmSyncBindingsBodySchema: z.ZodObject<{
|
declare const cfdmSyncBindingsBodySchema: z.ZodObject<{
|
||||||
@@ -2260,6 +2265,11 @@ declare const cfdmSyncBindingsBodySchema: z.ZodObject<{
|
|||||||
hostname: z.ZodString;
|
hostname: z.ZodString;
|
||||||
ips: z.ZodArray<z.ZodString>;
|
ips: z.ZodArray<z.ZodString>;
|
||||||
cnameTarget: z.ZodOptional<z.ZodString>;
|
cnameTarget: z.ZodOptional<z.ZodString>;
|
||||||
|
lbMode: z.ZodOptional<z.ZodEnum<{
|
||||||
|
round_robin: "round_robin";
|
||||||
|
failover: "failover";
|
||||||
|
weighted: "weighted";
|
||||||
|
}>>;
|
||||||
deleted: z.ZodOptional<z.ZodBoolean>;
|
deleted: z.ZodOptional<z.ZodBoolean>;
|
||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
fullSync: z.ZodOptional<z.ZodBoolean>;
|
fullSync: z.ZodOptional<z.ZodBoolean>;
|
||||||
|
|||||||
Vendored
+7
-1
@@ -19,7 +19,10 @@ var CERT_MONITORING_VALUES = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
// src/validators.ts
|
// src/validators.ts
|
||||||
var NAME_RE = /^(@|\*|[a-zA-Z0-9_]([a-zA-Z0-9_-]*[a-zA-Z0-9_])?(\.[a-zA-Z0-9_]([a-zA-Z0-9_-]*[a-zA-Z0-9_])?)*)$/;
|
var LABEL_RE = "[a-zA-Z0-9_](?:[a-zA-Z0-9_-]*[a-zA-Z0-9_])?";
|
||||||
|
var NAME_RE = new RegExp(
|
||||||
|
`^(@|\\*|(\\*\\.)?${LABEL_RE}(?:\\.${LABEL_RE})*)$`
|
||||||
|
);
|
||||||
var IPV4_RE = /^((25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(25[0-5]|2[0-4]\d|[01]?\d\d?)$/;
|
var IPV4_RE = /^((25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(25[0-5]|2[0-4]\d|[01]?\d\d?)$/;
|
||||||
var IPV6_RE = /^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}$/;
|
var IPV6_RE = /^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}$/;
|
||||||
var ALLOWED_TYPES = ["A", "AAAA", "CNAME", "TXT", "MX", "NS", "SRV", "CAA"];
|
var ALLOWED_TYPES = ["A", "AAAA", "CNAME", "TXT", "MX", "NS", "SRV", "CAA"];
|
||||||
@@ -106,6 +109,7 @@ function dnsNameToSubdomainLabel(recordName, zoneName) {
|
|||||||
const prefix = rn.slice(0, rn.length - zoneSuffix.length);
|
const prefix = rn.slice(0, rn.length - zoneSuffix.length);
|
||||||
return prefix || "@";
|
return prefix || "@";
|
||||||
}
|
}
|
||||||
|
if (rn.startsWith("*.")) return rn;
|
||||||
if (!rn.includes(".")) return rn;
|
if (!rn.includes(".")) return rn;
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -846,6 +850,8 @@ var cfdmBindingSyncItemSchema = z3.object({
|
|||||||
ips: z3.array(z3.string()),
|
ips: z3.array(z3.string()),
|
||||||
/** CNAME-цель (FQDN), если binding — CNAME; для матчинга в VPS Tracker. */
|
/** CNAME-цель (FQDN), если binding — CNAME; для матчинга в VPS Tracker. */
|
||||||
cnameTarget: z3.string().optional(),
|
cnameTarget: z3.string().optional(),
|
||||||
|
/** HA-режим binding (fallback — service group). Optional для старых payload. */
|
||||||
|
lbMode: z3.enum(["round_robin", "failover", "weighted"]).optional(),
|
||||||
deleted: z3.boolean().optional()
|
deleted: z3.boolean().optional()
|
||||||
});
|
});
|
||||||
var cfdmSyncBindingsBodySchema = z3.object({
|
var cfdmSyncBindingsBodySchema = z3.object({
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ export const cfdmBindingSyncItemSchema = z.object({
|
|||||||
ips: z.array(z.string()),
|
ips: z.array(z.string()),
|
||||||
/** CNAME-цель (FQDN), если binding — CNAME; для матчинга в VPS Tracker. */
|
/** CNAME-цель (FQDN), если binding — CNAME; для матчинга в VPS Tracker. */
|
||||||
cnameTarget: z.string().optional(),
|
cnameTarget: z.string().optional(),
|
||||||
|
/** HA-режим binding (fallback — service group). Optional для старых payload. */
|
||||||
|
lbMode: z.enum(["round_robin", "failover", "weighted"]).optional(),
|
||||||
deleted: z.boolean().optional(),
|
deleted: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export function dnsNameToSubdomainLabel(
|
|||||||
return prefix || "@";
|
return prefix || "@";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (rn.startsWith("*.")) return rn;
|
||||||
if (!rn.includes(".")) return rn;
|
if (!rn.includes(".")) return rn;
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -5,8 +5,11 @@ import {
|
|||||||
} from "./constants.js";
|
} from "./constants.js";
|
||||||
import type { ServiceGroup } from "./types.js";
|
import type { ServiceGroup } from "./types.js";
|
||||||
|
|
||||||
const NAME_RE =
|
const LABEL_RE = "[a-zA-Z0-9_](?:[a-zA-Z0-9_-]*[a-zA-Z0-9_])?";
|
||||||
/^(@|\*|[a-zA-Z0-9_]([a-zA-Z0-9_-]*[a-zA-Z0-9_])?(\.[a-zA-Z0-9_]([a-zA-Z0-9_-]*[a-zA-Z0-9_])?)*)$/;
|
/** Apex `@`, zone `*`, labels, or nested wildcard (`*.ndns`, `*.ndns.shnt.top`). */
|
||||||
|
const NAME_RE = new RegExp(
|
||||||
|
`^(@|\\*|(\\*\\.)?${LABEL_RE}(?:\\.${LABEL_RE})*)$`,
|
||||||
|
);
|
||||||
const IPV4_RE =
|
const IPV4_RE =
|
||||||
/^((25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(25[0-5]|2[0-4]\d|[01]?\d\d?)$/;
|
/^((25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(25[0-5]|2[0-4]\d|[01]?\d\d?)$/;
|
||||||
const IPV6_RE = /^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}$/;
|
const IPV6_RE = /^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}$/;
|
||||||
|
|||||||
@@ -23,6 +23,13 @@ describe("normalizeDnsRecordName", () => {
|
|||||||
expect(normalizeDnsRecordName("@", ZONE)).toBe("rkns.top");
|
expect(normalizeDnsRecordName("@", ZONE)).toBe("rkns.top");
|
||||||
expect(normalizeDnsRecordName("rkns.top", ZONE)).toBe("rkns.top");
|
expect(normalizeDnsRecordName("rkns.top", ZONE)).toBe("rkns.top");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("normalizes nested wildcard relative name to FQDN", () => {
|
||||||
|
expect(normalizeDnsRecordName("*.mdns", ZONE)).toBe("*.mdns.rkns.top");
|
||||||
|
expect(normalizeDnsRecordName("*.mdns.rkns.top", ZONE)).toBe(
|
||||||
|
"*.mdns.rkns.top",
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("dnsRecordNamesMatch", () => {
|
describe("dnsRecordNamesMatch", () => {
|
||||||
@@ -34,10 +41,19 @@ describe("dnsRecordNamesMatch", () => {
|
|||||||
it("does not match different hosts", () => {
|
it("does not match different hosts", () => {
|
||||||
expect(dnsRecordNamesMatch("de", "mhome.rkns.top", ZONE)).toBe(false);
|
expect(dnsRecordNamesMatch("de", "mhome.rkns.top", ZONE)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("matches nested wildcard relative name and FQDN", () => {
|
||||||
|
expect(dnsRecordNamesMatch("*.mdns", "*.mdns.rkns.top", ZONE)).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("dnsNameToSubdomainLabel", () => {
|
describe("dnsNameToSubdomainLabel", () => {
|
||||||
it("extracts label from FQDN", () => {
|
it("extracts label from FQDN", () => {
|
||||||
expect(dnsNameToSubdomainLabel("de.rkns.top", ZONE)).toBe("de");
|
expect(dnsNameToSubdomainLabel("de.rkns.top", ZONE)).toBe("de");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps nested wildcard relative names", () => {
|
||||||
|
expect(dnsNameToSubdomainLabel("*.mdns", ZONE)).toBe("*.mdns");
|
||||||
|
expect(dnsNameToSubdomainLabel("*.mdns.rkns.top", ZONE)).toBe("*.mdns");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { validateDnsRecord, ValidationError } from "../src/validators.js";
|
||||||
|
|
||||||
|
function assertValidName(name: string): void {
|
||||||
|
expect(() =>
|
||||||
|
validateDnsRecord("A", name, "1.2.3.4", 1, false),
|
||||||
|
).not.toThrow();
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertInvalidName(name: string): void {
|
||||||
|
expect(() => validateDnsRecord("A", name, "1.2.3.4", 1, false)).toThrow(
|
||||||
|
ValidationError,
|
||||||
|
);
|
||||||
|
expect(() => validateDnsRecord("A", name, "1.2.3.4", 1, false)).toThrow(
|
||||||
|
`invalid record name: ${name}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("validateDnsRecord name", () => {
|
||||||
|
it("accepts apex and zone wildcard", () => {
|
||||||
|
assertValidName("@");
|
||||||
|
assertValidName("*");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts regular labels and FQDN", () => {
|
||||||
|
assertValidName("ndns");
|
||||||
|
assertValidName("ndns.shnt.top");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts nested wildcard relative name and FQDN", () => {
|
||||||
|
assertValidName("*.ndns");
|
||||||
|
assertValidName("*.ndns.shnt.top");
|
||||||
|
assertValidName("*.mdns");
|
||||||
|
assertValidName("*.mdns.rkns.top");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects wildcard not as leftmost label", () => {
|
||||||
|
assertInvalidName("foo.*.bar");
|
||||||
|
assertInvalidName("ndns.*");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user