Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b078fa0a03 | ||
|
|
75575a3243 |
@@ -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";
|
||||||
@@ -69,6 +75,46 @@ function isMissingCfDnsRecord(error: unknown): boolean {
|
|||||||
return /record does not exist|81044/i.test(message);
|
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(
|
async function markSynced(
|
||||||
db: Db,
|
db: Db,
|
||||||
domainId: number,
|
domainId: number,
|
||||||
@@ -99,6 +145,11 @@ async function markSynced(
|
|||||||
return repos.getDnsRecord(db, domainId, record.id);
|
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,
|
||||||
@@ -106,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,
|
||||||
@@ -116,13 +168,24 @@ 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(
|
||||||
|
remote,
|
||||||
|
domain.zone_name,
|
||||||
|
record.record_type,
|
||||||
|
record.name,
|
||||||
|
record.content,
|
||||||
|
);
|
||||||
|
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);
|
: await cf.createDnsRecord(cfZoneId, payload);
|
||||||
return markSynced(db, domainId, record, cfRec);
|
return markSynced(db, domainId, record, cfRec);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Stale cf_record_id after manual CF edits / prior buggy sync — recreate.
|
// Race: cached id vanished mid-flight — recreate by identity.
|
||||||
if (record.cf_record_id && isMissingCfDnsRecord(e)) {
|
if (isMissingCfDnsRecord(e)) {
|
||||||
try {
|
try {
|
||||||
const created = await cf.createDnsRecord(cfZoneId, payload);
|
const created = await cf.createDnsRecord(cfZoneId, payload);
|
||||||
return markSynced(db, domainId, record, created);
|
return markSynced(db, domainId, record, created);
|
||||||
@@ -225,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,
|
||||||
@@ -244,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);
|
||||||
@@ -272,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -363,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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -546,6 +546,34 @@ function bestAliveDisplayStatus(statuses: readonly string[]): IpHealthState {
|
|||||||
return "unknown";
|
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[],
|
||||||
@@ -567,6 +595,9 @@ 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 live = liveByIp.get(ip);
|
||||||
const status = overlayLiveHealth(row?.status, live?.status);
|
const status = overlayLiveHealth(row?.status, live?.status);
|
||||||
@@ -584,17 +615,26 @@ function attachServiceHealth(
|
|||||||
colo: extras?.colo ?? null,
|
colo: extras?.colo ?? null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
const displayStatus = bestAliveDisplayStatus(ip_health.map((row) => row.status));
|
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 =
|
const latencyRow =
|
||||||
ip_health.find((row) => row.status === displayStatus && row.latency_ms != null) ??
|
ip_health.find((row) => row.status === displayStatus && row.latency_ms != null) ??
|
||||||
ip_health.find((row) => row.latency_ms != null);
|
ip_health.find((row) => row.latency_ms != null);
|
||||||
return {
|
return {
|
||||||
...view,
|
...view,
|
||||||
health_status: overlayLiveHealth(health?.health_status, displayStatus),
|
health_status:
|
||||||
|
monitoredStatuses.length > 0
|
||||||
|
? overlayLiveHealth(health?.health_status, displayStatus)
|
||||||
|
: "unknown",
|
||||||
health_latency_ms:
|
health_latency_ms:
|
||||||
displayStatus !== "unknown"
|
monitoredStatuses.length > 0 && displayStatus !== "unknown"
|
||||||
? (latencyRow?.latency_ms ?? null)
|
? (latencyRow?.latency_ms ?? null)
|
||||||
: (health?.health_latency_ms ?? null),
|
: null,
|
||||||
ip_health,
|
ip_health,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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",
|
||||||
@@ -450,6 +452,7 @@ describe("CNAME health mapped onto service IPs", () => {
|
|||||||
repos.replaceBindingIpsWithMeta(db, binding.id, [
|
repos.replaceBindingIpsWithMeta(db, binding.id, [
|
||||||
{ ip: "2.59.161.102", weight: 1, priority: 1 },
|
{ ip: "2.59.161.102", weight: 1, priority: 1 },
|
||||||
]);
|
]);
|
||||||
|
repos.updateBindingLbConfig(db, binding.id, { health_check_enabled: true });
|
||||||
repos.upsertIpHealthStatus(
|
repos.upsertIpHealthStatus(
|
||||||
db,
|
db,
|
||||||
"binding",
|
"binding",
|
||||||
@@ -478,4 +481,74 @@ describe("CNAME health mapped onto service IPs", () => {
|
|||||||
expect(view.ip_health[0]?.status).toBe("up");
|
expect(view.ip_health[0]?.status).toBe("up");
|
||||||
expect(view.ip_health[0]?.latency_ms).toBe(63);
|
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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -273,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}
|
||||||
|
|||||||
Reference in New Issue
Block a user