fix(services): снимать Down только с общего FQDN

Персональные A не крутить и не трогать при падении. Панель называется по режиму балансировки.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Denozordec
2026-08-20 17:51:49 +07:00
co-authored by Cursor
parent 3d0ea33baf
commit 2b150fa3a6
8 changed files with 221 additions and 48 deletions
+17
View File
@@ -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";
+18
View File
@@ -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<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));
}
+33 -27
View File
@@ -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<ServiceView> {
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);
+74
View File
@@ -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);
});
});
@@ -63,8 +63,8 @@ export function FailoverTimeline({
return (
<EmptyState
icon={ShieldCheckIcon}
title="Нет инцидентов Failover"
description="Нет Down и нет выходов из пула"
title="Нет инцидентов"
description="Нет Down и нет выходов из общего пула"
stackedIcon={false}
centered={false}
/>
@@ -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<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 {
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 (
<Frame stacked spacing="sm" className="min-w-0 w-full">
<FramePanel className="flex flex-col gap-3">
<FrameHeader className="gap-1 px-0 py-0">
<FrameTitle className="flex flex-wrap items-center gap-2">
Failover
{copy.title}
{events.length > 0 ? (
<Badge variant="destructive-light" size="xs" radius="full">
{events.length}
@@ -95,20 +123,14 @@ export function ServiceFailoverPanel({
</Badge>
)}
</FrameTitle>
<FrameDescription>
Текущие Down и история: кто вышел из пула и кто вернулся
</FrameDescription>
<FrameDescription>{copy.description}</FrameDescription>
</FrameHeader>
{events.length > 0 ? (
<Alert variant="destructive">
<UnplugIcon aria-hidden="true" />
<AlertTitle>{failoverCountLabel(events.length)}</AlertTitle>
<AlertDescription>
{removedCount > 0
? 'Сняты с FQDN или остались last-resort'
: 'Остались в A-записях как last-resort'}
</AlertDescription>
<AlertDescription>{alertDescription(events)}</AlertDescription>
</Alert>
) : null}
+22 -5
View File
@@ -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([])
})
})
+21 -2
View File
@@ -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),
),