diff --git a/apps/api/src/services/routing/index.ts b/apps/api/src/services/routing/index.ts index 5e9e70f..0167728 100644 --- a/apps/api/src/services/routing/index.ts +++ b/apps/api/src/services/routing/index.ts @@ -1,5 +1,6 @@ import type { LbMode } from "@cfdm/shared"; import { failoverDesired } from "./failover.js"; +import { isSharedPool } from "./pool.js"; import { roundRobinDesired } from "./round-robin.js"; import type { LbIpRow, LbTargetConfig } from "./types.js"; import { weightedDesired } from "./weighted.js"; @@ -7,6 +8,7 @@ import { weightedDesired } from "./weighted.js"; export type { LbIpRow, LbTargetConfig } from "./types.js"; export { isHealthy } from "./health.js"; export { withBindingLock } from "./binding-lock.js"; +export { isSharedPool, shouldRecordFailoverDnsDiff } from "./pool.js"; export { WEIGHTED_DNS_TTL, WEIGHTED_SLOT_MS, weightedDesired } from "./weighted.js"; export function selectActiveIpsByMode( @@ -24,6 +26,21 @@ export function selectActiveIpsByMode( return roundRobinDesired(rows); } +export function resolveDesiredAIps( + config: LbTargetConfig, + rows: LbIpRow[], + fallbackIps: readonly string[], + nowMs = Date.now(), +): string[] { + const fallback = [...fallbackIps]; + if (!isSharedPool(fallback)) 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 { if (mode === "failover") return "Failover"; if (mode === "weighted") return "Weighted"; diff --git a/apps/api/src/services/routing/pool.ts b/apps/api/src/services/routing/pool.ts new file mode 100644 index 0000000..c300577 --- /dev/null +++ b/apps/api/src/services/routing/pool.ts @@ -0,0 +1,18 @@ +import type { LbMode } from "@cfdm/shared"; + +/** Shared pool FQDN — two or more A targets. Dedicated extra-FQDN has one IP. */ +export function isSharedPool(ips: readonly string[]): boolean { + return ips.length >= 2; +} + +export function shouldRecordFailoverDnsDiff(input: { + configuredIps: readonly string[]; + lbMode: LbMode; + added: readonly string[]; + removed: readonly string[]; + downIps: ReadonlySet; +}): boolean { + if (!isSharedPool(input.configuredIps)) return false; + if (input.lbMode !== "weighted") return true; + return [...input.added, ...input.removed].some((ip) => input.downIps.has(ip)); +} diff --git a/apps/api/src/services/service-config-service.ts b/apps/api/src/services/service-config-service.ts index 2d0c1d3..16e1b53 100644 --- a/apps/api/src/services/service-config-service.ts +++ b/apps/api/src/services/service-config-service.ts @@ -30,7 +30,10 @@ import { syncServiceToVpsTracker } from "./vps-tracker-sync.js"; import { fireEnsureHealthWorker, DEFAULT_HEALTH_FALLBACKS } from "./health/health-worker-deploy.js"; import { isHealthy, + isSharedPool, + resolveDesiredAIps, selectActiveIpsByMode, + shouldRecordFailoverDnsDiff, withBindingLock, WEIGHTED_DNS_TTL, type LbIpRow, @@ -38,12 +41,12 @@ import { } from "./routing/index.js"; export type { LbIpRow, LbTargetConfig }; -export { selectActiveIpsByMode }; +export { resolveDesiredAIps, selectActiveIpsByMode, shouldRecordFailoverDnsDiff }; const AUTO_DNS_TTL = 1; -function ttlForLbMode(mode: LbMode): number { - return mode === "weighted" ? WEIGHTED_DNS_TTL : AUTO_DNS_TTL; +function ttlForBinding(mode: LbMode, ipCount: number): number { + return mode === "weighted" && ipCount >= 2 ? WEIGHTED_DNS_TTL : AUTO_DNS_TTL; } export function failoverARecordDiff( @@ -72,6 +75,22 @@ function recordFailoverDnsDiff( const { added, removed } = failoverARecordDiff(existingA, desiredIps); if (added.length === 0 && removed.length === 0) return; 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, { serviceId: binding.service_id, bindingId, @@ -271,33 +290,17 @@ function getGroupLbState( }; } -function computeActiveIps( - db: Db, - scope: HealthCheckScope, - refId: number, -): string[] { - const state = - scope === "binding" - ? getBindingLbState(db, refId) - : getGroupLbState(db, refId); - return selectActiveIpsByMode(state.config, state.rows); -} - function desiredAIps( db: Db, scope: HealthCheckScope, refId: number, fallbackIps: string[], ): string[] { - const config = + const state = scope === "binding" - ? getBindingLbState(db, refId).config - : getGroupLbState(db, refId).config; - if (config.lb_mode === "weighted" || config.health_check_enabled) { - const activeIps = computeActiveIps(db, scope, refId); - if (activeIps.length > 0) return activeIps; - } - return fallbackIps; + ? getBindingLbState(db, refId) + : getGroupLbState(db, refId); + return resolveDesiredAIps(state.config, state.rows, fallbackIps); } async function collectKnownZones( @@ -349,7 +352,7 @@ async function buildView(db: Db, serviceId: number): Promise { const { config, rows } = getBindingLbState(db, binding.id); const bindingActiveIps = targetCname ? [] - : selectActiveIpsByMode(config, rows); + : resolveDesiredAIps(config, rows, targetIps); return { binding_id: binding.id, @@ -638,6 +641,7 @@ async function syncBindingDns( } const binding = repos.getBinding(db, bindingId); + const configuredIps = repos.listBindingIps(db, bindingId); await syncBindingADns( db, cf, @@ -645,7 +649,7 @@ async function syncBindingDns( domainId, hostname, desiredIps, - ttlForLbMode(binding.lb_mode), + ttlForBinding(binding.lb_mode, configuredIps.length), ); } @@ -1208,7 +1212,7 @@ async function syncGroupDomainDns( domainId, hostname, desiredIps, - ttlForLbMode(group.lb_mode), + ttlForBinding(group.lb_mode, fallbackIps.length), ); } @@ -1702,6 +1706,7 @@ export async function reconcileDnsForTarget( if (cnameTarget) return; const ips = repos.listServiceIps(db, service.id); const targetIps = repos.listBindingIps(db, binding.id); + if (!isSharedPool(targetIps)) return; validateTargetIpsInPool(targetIps, ips); const desiredIps = desiredAIps(db, "binding", refId, targetIps); await syncBindingDns( @@ -1735,6 +1740,7 @@ export async function reconcileWeightedDns( 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); @@ -1743,7 +1749,7 @@ export async function reconcileWeightedDns( const service = repos.getService(db, latest.service_id); if (!shouldPushDns(db, service)) return; const targetIps = repos.listBindingIps(db, latest.id); - if (targetIps.length === 0) return; + if (!isSharedPool(targetIps)) return; const ips = repos.listServiceIps(db, service.id); validateTargetIpsInPool(targetIps, ips); const desiredIps = desiredAIps(db, "binding", latest.id, targetIps); diff --git a/apps/api/test/lb-reconcile.test.ts b/apps/api/test/lb-reconcile.test.ts index 43824b0..047e775 100644 --- a/apps/api/test/lb-reconcile.test.ts +++ b/apps/api/test/lb-reconcile.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from "vitest"; import { + resolveDesiredAIps, selectActiveIpsByMode, + shouldRecordFailoverDnsDiff, type LbIpRow, type LbTargetConfig, } from "../src/services/service-config-service.js"; @@ -144,3 +146,75 @@ describe("selectActiveIpsByMode", () => { 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("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 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); + }); +}); diff --git a/apps/web/src/components/failover-timeline.tsx b/apps/web/src/components/failover-timeline.tsx index f7da948..3690a5d 100644 --- a/apps/web/src/components/failover-timeline.tsx +++ b/apps/web/src/components/failover-timeline.tsx @@ -63,8 +63,8 @@ export function FailoverTimeline({ return ( diff --git a/apps/web/src/components/reui-kit/service-failover-panel.tsx b/apps/web/src/components/reui-kit/service-failover-panel.tsx index f31867f..f4a70be 100644 --- a/apps/web/src/components/reui-kit/service-failover-panel.tsx +++ b/apps/web/src/components/reui-kit/service-failover-panel.tsx @@ -13,7 +13,7 @@ import { type HealthLogProbe, type HealthLogStatus, } from '@/lib/health-log' -import type { FailoverLogEntry } from '@/lib/schemas' +import type { FailoverLogEntry, ServiceView } from '@/lib/schemas' import { Badge } from '@/components/reui/badge' import { Frame, @@ -28,6 +28,23 @@ import { AlertTitle, } from '@/components/reui/alert' +type LbMode = ServiceView['lb_mode'] + +const PANEL_COPY: Record = { + 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 { const mod10 = count % 10 const mod100 = count % 100 @@ -38,8 +55,17 @@ function failoverCountLabel(count: number): string { 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/empty-state-12 * Docs: https://reui.io/docs/components/base/frame @@ -48,16 +74,19 @@ function failoverCountLabel(count: number): string { * Docs: https://reui.io/docs/components/base/alert */ export function ServiceFailoverPanel({ + lbMode = 'round_robin', ipHealth, bindings, history, probes = [], }: { + lbMode?: LbMode ipHealth: readonly FailoverHealthInput[] bindings: readonly FailoverBindingPool[] history: readonly FailoverLogEntry[] probes?: readonly HealthLogProbe[] }) { + const copy = PANEL_COPY[lbMode] ?? PANEL_COPY.round_robin const liveByIp = latestHealthByIp(probes) const overlayHealth = ipHealth.map((row) => { const live = liveByIp.get(row.ip) @@ -77,14 +106,13 @@ export function ServiceFailoverPanel({ }) const events = toFailoverEvents(overlayHealth, bindings) const mergedHistory = mergeFailoverHistory(history, probes, bindings) - const removedCount = events.filter((event) => event.kind === 'removed').length return ( - Failover + {copy.title} {events.length > 0 ? ( {events.length} @@ -95,20 +123,14 @@ export function ServiceFailoverPanel({ )} - - Текущие Down и история: кто вышел из пула и кто вернулся - + {copy.description} {events.length > 0 ? ( ) : null} diff --git a/apps/web/src/lib/failover-events.test.ts b/apps/web/src/lib/failover-events.test.ts index 3384379..b297395 100644 --- a/apps/web/src/lib/failover-events.test.ts +++ b/apps/web/src/lib/failover-events.test.ts @@ -137,6 +137,7 @@ describe('toFailoverEvents', () => { }, ]) expect(failoverEventCopy(events[0]!)).toBe('Снята с gt.rkns.top') + expect(events[0]?.fqdns).not.toContain('nsgt.rkns.top') }) it('Down только last-resort на своём FQDN', () => { @@ -151,10 +152,8 @@ describe('toFailoverEvents', () => { ], ) expect(events[0]?.kind).toBe('last-resort') - expect(events[0]?.fqdns).toEqual(['nsgt.rkns.top']) - expect(failoverEventCopy(events[0]!)).toBe( - 'Down, в A-записях last-resort на nsgt.rkns.top', - ) + expect(events[0]?.fqdns).toEqual([]) + expect(failoverEventCopy(events[0]!)).toBe('Down, в A-записях last-resort') }) it('down без A-записи на configured FQDN — снятие', () => { @@ -279,7 +278,9 @@ describe('mergeFailoverHistory', () => { 'removed:probe', ]) 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', () => { @@ -291,4 +292,20 @@ describe('mergeFailoverHistory', () => { expect(history).toHaveLength(1) expect(history[0]?.source).toBe('dns') }) + + 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([]) + }) }) diff --git a/apps/web/src/lib/failover-events.ts b/apps/web/src/lib/failover-events.ts index 7880d11..63bf995 100644 --- a/apps/web/src/lib/failover-events.ts +++ b/apps/web/src/lib/failover-events.ts @@ -44,6 +44,11 @@ export interface FailoverBindingPool { active: readonly string[] } +/** Shared pool FQDN — two or more A targets. Dedicated extra-FQDN has one IP. */ +export function isSharedPoolBinding(binding: FailoverBindingPool): boolean { + return binding.configured.length >= 2 +} + /** Инцидент только при down. up / degraded / unknown — не вывод из пула. */ export function isFailoverEventStatus(status: string): boolean { return status === 'down' @@ -72,6 +77,7 @@ export function toFailoverEvents( const removedFqdns: string[] = [] const lastResortFqdns: string[] = [] for (const binding of bindings) { + if (!isSharedPoolBinding(binding)) continue const configured = binding.configured.includes(row.ip) const active = binding.active.includes(row.ip) if (configured && !active) removedFqdns.push(binding.fqdn) @@ -98,7 +104,18 @@ function fqdnsForIp( ip: string, bindings: readonly FailoverBindingPool[], ): 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 { @@ -201,7 +218,9 @@ export function mergeFailoverHistory( bindings: readonly FailoverBindingPool[] = [], ): FailoverHistoryItem[] { const merged = [ - ...dns.map(dnsHistoryItem), + ...dns + .filter((item) => isPoolFqdn(item.fqdn, bindings)) + .map(dnsHistoryItem), ...toIpAliveTransitions(probes).map((transition) => probeHistoryItem(transition, bindings), ),