feat(services): сделать multi-FQDN привязки first-class в UI
Build and Push CFDM Docker Image / build-and-push (push) Successful in 2m9s
Build and Push CFDM Docker Image / create-release (push) Skipped
Build and Push CFDM Docker Image / update-wiki (push) Successful in 7s

Исправить prune при domains: [], показать все FQDN в каталоге/kanban, улучшить sheet привязок и панель на домене; убрать мёртвый код.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-07-30 01:29:08 +07:00
co-authored by Cursor
parent 859ad23006
commit 3e4cacab42
18 changed files with 469 additions and 511 deletions
+85 -84
View File
@@ -1172,97 +1172,95 @@ export async function updateConfig(
let removedBindingIds: number[] = [];
if (req.domains) {
if (req.domains.length > 0) {
for (const input of req.domains) {
const fqdn = input.fqdn.trim();
if (!fqdn) continue;
const targetCname = bindingTargetCname(input);
const targetIps = bindingTargetIps(input);
if (!targetCname) {
validateTargetIpsInPool(targetIps, ips);
} else if (targetIps.length > 0) {
throw AppError.validation(
`укажите либо IP, либо CNAME для ${fqdn}`,
);
}
const { zoneName, hostname } = parseFqdn(fqdn, knownZones);
const domainId = await resolveDomainId(db, cf, zoneName);
const binding =
repos.findBinding(db, id, domainId, hostname) ??
repos.insertBinding(db, domainId, id, hostname, null);
keptBindingIds.push(binding.id);
const targetIpWeights = input.target_ip_weights ?? {};
const targetIpPriorities = input.target_ip_priorities ?? {};
const bindingIpEntries = (targetCname ? [] : targetIps).map((ip) => ({
ip,
weight: targetIpWeights[ip] ?? 1,
priority: targetIpPriorities[ip] ?? 1,
}));
repos.replaceBindingIpsWithMeta(db, binding.id, bindingIpEntries);
repos.setBindingCnameTarget(db, binding.id, targetCname);
if (
input.lb_mode !== undefined ||
input.health_check_enabled !== undefined ||
input.health_check_type !== undefined ||
input.health_check_port !== undefined ||
input.health_check_path !== undefined ||
input.health_check_expected_status !== undefined ||
input.health_check_interval_sec !== undefined ||
input.health_check_timeout_ms !== undefined ||
input.health_check_verify_tls !== undefined
) {
repos.updateBindingLbConfig(db, binding.id, {
lb_mode: input.lb_mode,
health_check_enabled: input.health_check_enabled,
health_check_type: input.health_check_type,
health_check_port: input.health_check_port,
health_check_path: input.health_check_path,
health_check_expected_status: input.health_check_expected_status,
health_check_interval_sec: input.health_check_interval_sec,
health_check_timeout_ms: input.health_check_timeout_ms,
health_check_verify_tls: input.health_check_verify_tls,
});
}
if (pushDns) {
let effectiveIps = targetIps;
const refreshedBinding = repos.getBinding(db, binding.id);
if (refreshedBinding.health_check_enabled) {
const activeIps = computeActiveIps(db, "binding", binding.id);
if (activeIps.length > 0) {
effectiveIps = activeIps;
}
}
await syncBindingDns(
db,
cf,
binding.id,
domainId,
hostname,
effectiveIps,
targetCname,
);
}
for (const input of req.domains) {
const fqdn = input.fqdn.trim();
if (!fqdn) continue;
const targetCname = bindingTargetCname(input);
const targetIps = bindingTargetIps(input);
if (!targetCname) {
validateTargetIpsInPool(targetIps, ips);
} else if (targetIps.length > 0) {
throw AppError.validation(
`укажите либо IP, либо CNAME для ${fqdn}`,
);
}
const removed = repos.bindingsToRemove(db, id, keptBindingIds);
removedBindingIds = removed.map((binding) => binding.id);
for (const binding of removed) {
await cleanupBindingDns(
const { zoneName, hostname } = parseFqdn(fqdn, knownZones);
const domainId = await resolveDomainId(db, cf, zoneName);
const binding =
repos.findBinding(db, id, domainId, hostname) ??
repos.insertBinding(db, domainId, id, hostname, null);
keptBindingIds.push(binding.id);
const targetIpWeights = input.target_ip_weights ?? {};
const targetIpPriorities = input.target_ip_priorities ?? {};
const bindingIpEntries = (targetCname ? [] : targetIps).map((ip) => ({
ip,
weight: targetIpWeights[ip] ?? 1,
priority: targetIpPriorities[ip] ?? 1,
}));
repos.replaceBindingIpsWithMeta(db, binding.id, bindingIpEntries);
repos.setBindingCnameTarget(db, binding.id, targetCname);
if (
input.lb_mode !== undefined ||
input.health_check_enabled !== undefined ||
input.health_check_type !== undefined ||
input.health_check_port !== undefined ||
input.health_check_path !== undefined ||
input.health_check_expected_status !== undefined ||
input.health_check_interval_sec !== undefined ||
input.health_check_timeout_ms !== undefined ||
input.health_check_verify_tls !== undefined
) {
repos.updateBindingLbConfig(db, binding.id, {
lb_mode: input.lb_mode,
health_check_enabled: input.health_check_enabled,
health_check_type: input.health_check_type,
health_check_port: input.health_check_port,
health_check_path: input.health_check_path,
health_check_expected_status: input.health_check_expected_status,
health_check_interval_sec: input.health_check_interval_sec,
health_check_timeout_ms: input.health_check_timeout_ms,
health_check_verify_tls: input.health_check_verify_tls,
});
}
if (pushDns) {
let effectiveIps = targetIps;
const refreshedBinding = repos.getBinding(db, binding.id);
if (refreshedBinding.health_check_enabled) {
const activeIps = computeActiveIps(db, "binding", binding.id);
if (activeIps.length > 0) {
effectiveIps = activeIps;
}
}
await syncBindingDns(
db,
cf,
binding.id,
binding.domain_id,
binding.hostname,
domainId,
hostname,
effectiveIps,
targetCname,
);
}
repos.deleteBindingsExcept(db, id, keptBindingIds);
}
const removed = repos.bindingsToRemove(db, id, keptBindingIds);
removedBindingIds = removed.map((binding) => binding.id);
for (const binding of removed) {
await cleanupBindingDns(
db,
cf,
binding.id,
binding.domain_id,
binding.hostname,
);
}
repos.deleteBindingsExcept(db, id, keptBindingIds);
} else if (ipsUpdated) {
const bindings = repos.listBindingsByService(db, id);
for (const binding of bindings) {
@@ -1278,8 +1276,11 @@ export async function updateConfig(
}
service = repos.getService(db, id);
const remainingBindings = repos.listBindingsByService(db, id);
if (shouldPushDns(db, service)) {
await syncServiceBindingsToDns(db, cf, id);
if (remainingBindings.length > 0) {
await syncServiceBindingsToDns(db, cf, id);
}
await syncGroupDomainForService(db, cf, id);
} else if (
req.domains &&
@@ -0,0 +1,81 @@
import { describe, expect, it } from "vitest";
import { createMemoryDb, repos, runMigrations } from "@cfdm/db";
import type { CloudflareClient } from "../src/lib/cf-client.js";
import { updateConfig } from "../src/services/service-config-service.js";
function mockCf(): CloudflareClient {
return {
listDnsRecords: async () => [],
createDnsRecord: async () => ({
id: "cf-new",
type: "A",
name: "api.example.com",
content: "10.0.0.1",
ttl: 1,
proxied: false,
}),
updateDnsRecord: async () => ({
id: "cf-upd",
type: "A",
name: "api.example.com",
content: "10.0.0.1",
ttl: 1,
proxied: false,
}),
deleteDnsRecord: async () => undefined,
verifyToken: async () => true,
listZones: async () => [],
} as unknown as CloudflareClient;
}
function setupDb() {
const { db, sqlite } = createMemoryDb();
runMigrations(sqlite);
return db;
}
describe("service bindings prune", () => {
it("updateConfig with domains: [] removes all bindings", async () => {
const db = setupDb();
const cf = mockCf();
const domain = repos.createDomain(db, null, "example.com", "cf-zone-example");
const service = repos.createService(db, "Web", "web");
repos.replaceServiceIps(db, service.id, ["10.0.0.1"]);
const binding = repos.insertBinding(db, domain.id, service.id, "api", null);
repos.replaceBindingIps(db, binding.id, ["10.0.0.1"]);
expect(repos.listBindingsByService(db, service.id)).toHaveLength(1);
const view = await updateConfig(db, cf, service.id, {
ips: ["10.0.0.1"],
domains: [],
});
expect(repos.listBindingsByService(db, service.id)).toHaveLength(0);
expect(view.domains).toEqual([]);
});
it("updateConfig keeps multiple FQDN bindings", async () => {
const db = setupDb();
const cf = mockCf();
repos.createDomain(db, null, "a.example", "cf-zone-a");
repos.createDomain(db, null, "b.example", "cf-zone-b");
const service = repos.createService(db, "Edge", "edge");
repos.replaceServiceIps(db, service.id, ["10.0.0.2"]);
const view = await updateConfig(db, cf, service.id, {
ips: ["10.0.0.2"],
domains: [
{ fqdn: "api.a.example", target_ips: ["10.0.0.2"] },
{ fqdn: "www.b.example", target_ips: ["10.0.0.2"] },
],
});
expect(view.domains).toHaveLength(2);
const bindings = repos.listBindingsByService(db, service.id);
expect(bindings).toHaveLength(2);
expect(bindings.map((b) => b.hostname).sort()).toEqual(["api", "www"]);
});
});