fix(health): разворачивать CNAME до IP в статусе таблицы
Пробы CNAME больше не ключуются hostname: цель разворачивается до origin/пула, а уже сохранённый статус по CNAME копируется на IP сервиса. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -18,6 +18,7 @@ import {
|
||||
SYNC_PENDING_PUSH,
|
||||
SYNC_SYNCED,
|
||||
dnsRecordNamesMatch,
|
||||
isIpLiteral,
|
||||
normalizeDnsRecordName,
|
||||
} from "@cfdm/shared";
|
||||
import type { CloudflareClient } from "../lib/cf-client.js";
|
||||
@@ -344,6 +345,64 @@ async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
|
||||
};
|
||||
}
|
||||
|
||||
const HEALTH_RANK: Record<string, number> = {
|
||||
down: 3,
|
||||
degraded: 2,
|
||||
unknown: 1,
|
||||
up: 0,
|
||||
};
|
||||
|
||||
function cnameLookupKeys(value: string, zoneName?: string | null): string[] {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return [];
|
||||
const noDot = trimmed.replace(/\.+$/, "");
|
||||
const lower = noDot.toLowerCase();
|
||||
const keys = new Set([trimmed, noDot, lower]);
|
||||
if (zoneName && !lower.includes(".")) {
|
||||
keys.add(`${lower}.${zoneName.trim().toLowerCase().replace(/\.+$/, "")}`);
|
||||
}
|
||||
return [...keys];
|
||||
}
|
||||
|
||||
type ServiceHealthRow = {
|
||||
ip: string;
|
||||
status: IpHealthState;
|
||||
latency_ms: number | null;
|
||||
last_checked_at: string | null;
|
||||
last_error: string | null;
|
||||
provider: ServiceView["ip_health"][number]["provider"];
|
||||
colo: string | null;
|
||||
};
|
||||
|
||||
/** Health rows keyed by CNAME hostname (legacy probes) applied to service IPs. */
|
||||
function fallbackCnameHealth(
|
||||
rows: ServiceHealthRow[],
|
||||
view: ServiceView,
|
||||
): ServiceHealthRow | undefined {
|
||||
const cnameKeys = new Set<string>();
|
||||
for (const domain of view.domains ?? []) {
|
||||
const cname = domain.target_cname?.trim();
|
||||
if (!cname) continue;
|
||||
for (const key of cnameLookupKeys(cname, domain.zone_name)) {
|
||||
cnameKeys.add(key);
|
||||
}
|
||||
}
|
||||
const hostnameRows = rows.filter((row) => !isIpLiteral(row.ip));
|
||||
if (hostnameRows.length === 0) return undefined;
|
||||
const matched =
|
||||
cnameKeys.size === 0
|
||||
? hostnameRows
|
||||
: hostnameRows.filter((row) =>
|
||||
cnameLookupKeys(row.ip).some((key) => cnameKeys.has(key)),
|
||||
);
|
||||
const candidates = matched.length > 0 ? matched : hostnameRows;
|
||||
return candidates.reduce((worst, row) =>
|
||||
(HEALTH_RANK[row.status] ?? 0) > (HEALTH_RANK[worst.status] ?? 0)
|
||||
? row
|
||||
: worst,
|
||||
);
|
||||
}
|
||||
|
||||
function attachServiceHealth(
|
||||
db: Db,
|
||||
views: ServiceView[],
|
||||
@@ -353,11 +412,16 @@ function attachServiceHealth(
|
||||
const ipHealthByService = repos.listIpHealthByServiceIds(db, ids);
|
||||
return views.map((view) => {
|
||||
const health = healthByService.get(view.id);
|
||||
const byIp = new Map(
|
||||
(ipHealthByService.get(view.id) ?? []).map((row) => [row.ip, row]),
|
||||
const rows = ipHealthByService.get(view.id) ?? [];
|
||||
const byIp = new Map(rows.map((row) => [row.ip, row]));
|
||||
const cnameFallback = fallbackCnameHealth(rows, view);
|
||||
const aRecordIps = new Set(
|
||||
(view.domains ?? []).flatMap((domain) =>
|
||||
domain.target_cname?.trim() ? [] : (domain.target_ips ?? []),
|
||||
),
|
||||
);
|
||||
const ip_health = (view.ips ?? []).map((ip) => {
|
||||
const row = byIp.get(ip);
|
||||
const row = byIp.get(ip) ?? (aRecordIps.has(ip) ? undefined : cnameFallback);
|
||||
return {
|
||||
ip,
|
||||
status: row?.status ?? ("unknown" as const),
|
||||
|
||||
@@ -235,4 +235,95 @@ describe("health-check state derivation via runAllChecks", () => {
|
||||
expect(targets).toHaveLength(1);
|
||||
expect(targets[0]?.verify_tls).toBe(true);
|
||||
});
|
||||
|
||||
it("unwraps CNAME target to origin A record IPs", async () => {
|
||||
const { createMemoryDb, repos, runMigrations } = await import("@cfdm/db");
|
||||
const { db, sqlite } = createMemoryDb();
|
||||
runMigrations(sqlite);
|
||||
|
||||
const domain = repos.createDomain(db, null, "rkns.top", "zone-id");
|
||||
repos.insertDnsRecord(
|
||||
db,
|
||||
domain.id,
|
||||
"A",
|
||||
"ihome",
|
||||
"2.59.161.102",
|
||||
1,
|
||||
false,
|
||||
null,
|
||||
"synced",
|
||||
"cf",
|
||||
null,
|
||||
);
|
||||
const service = repos.createService(db, "RW Sub", "rw-sub");
|
||||
const binding = repos.insertBinding(db, domain.id, service.id, "s", null);
|
||||
repos.setBindingCnameTarget(db, binding.id, "ihome.rkns.top");
|
||||
repos.updateBindingLbConfig(db, binding.id, {
|
||||
health_check_enabled: true,
|
||||
health_check_type: "tcp",
|
||||
health_check_port: 443,
|
||||
});
|
||||
|
||||
const targets = repos.listHealthCheckTargets(db);
|
||||
expect(targets).toHaveLength(1);
|
||||
expect(targets[0]?.ip).toBe("2.59.161.102");
|
||||
expect(targets[0]?.hostname).toBe("s.rkns.top");
|
||||
});
|
||||
|
||||
it("unwraps CNAME target to service IP pool when origin DNS is empty", async () => {
|
||||
const { createMemoryDb, repos, runMigrations } = await import("@cfdm/db");
|
||||
const { db, sqlite } = createMemoryDb();
|
||||
runMigrations(sqlite);
|
||||
|
||||
const domain = repos.createDomain(db, null, "rkns.top", "zone-id");
|
||||
const service = repos.createService(db, "RW Sub", "rw-sub");
|
||||
repos.replaceServiceIps(db, service.id, ["2.59.161.102"]);
|
||||
const binding = repos.insertBinding(db, domain.id, service.id, "s", null);
|
||||
repos.setBindingCnameTarget(db, binding.id, "ihome.rkns.top");
|
||||
repos.updateBindingLbConfig(db, binding.id, {
|
||||
health_check_enabled: true,
|
||||
health_check_type: "tcp",
|
||||
health_check_port: 443,
|
||||
});
|
||||
|
||||
const targets = repos.listHealthCheckTargets(db);
|
||||
expect(targets).toHaveLength(1);
|
||||
expect(targets[0]?.ip).toBe("2.59.161.102");
|
||||
expect(targets[0]?.hostname).toBe("s.rkns.top");
|
||||
});
|
||||
});
|
||||
|
||||
describe("CNAME health mapped onto service IPs", () => {
|
||||
it("getView copies CNAME-keyed health onto the service IP row", 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 Sub", "rw-sub");
|
||||
repos.replaceServiceIps(db, service.id, ["2.59.161.102"]);
|
||||
const binding = repos.insertBinding(db, domain.id, service.id, "s", null);
|
||||
repos.setBindingCnameTarget(db, binding.id, "ihome.rkns.top");
|
||||
repos.upsertIpHealthStatus(
|
||||
db,
|
||||
"binding",
|
||||
binding.id,
|
||||
"ihome.rkns.top",
|
||||
"up",
|
||||
12,
|
||||
0,
|
||||
null,
|
||||
);
|
||||
|
||||
const view = await getView(db, service.id);
|
||||
expect(view.health_status).toBe("up");
|
||||
expect(view.ip_health).toEqual([
|
||||
expect.objectContaining({
|
||||
ip: "2.59.161.102",
|
||||
status: "up",
|
||||
latency_ms: 12,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user