feat(services): считать сервис живым при одном OK и писать историю failover
quality / commitlint (push) Skipped
quality / changes (push) Successful in 11s
quality / docker-check (push) Skipped
CD / update-wiki (push) Successful in 5s
quality / web (push) Successful in 1m11s
quality / api (push) Successful in 1m1s
CD / quality (push) Successful in 2m30s
CD / publish (push) Successful in 2m4s

Сервис up, если жив хотя бы один IP. Failover показывает Down по FQDN и журнал add/remove A-записей.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-08-20 15:49:17 +07:00
co-authored by Cursor
parent 7938d2f707
commit fa7d2b6df3
23 changed files with 1030 additions and 107 deletions
+93 -10
View File
@@ -1,8 +1,10 @@
import { describe, expect, it } from 'vitest'
import {
failoverEventCopy,
isFailoverEventStatus,
toFailoverEvents,
type FailoverBindingPool,
type FailoverHealthInput,
} from '@/lib/failover-events'
@@ -18,6 +20,24 @@ function row(
}
}
const mskHip: FailoverBindingPool[] = [
{
fqdn: 'gt.rkns.top',
configured: ['130.49.213.153', '93.115.203.183'],
active: ['93.115.203.183'],
},
{
fqdn: 'nsgt.rkns.top',
configured: ['130.49.213.153'],
active: ['130.49.213.153'],
},
{
fqdn: 'rutg.rkns.top',
configured: ['93.115.203.183'],
active: ['93.115.203.183'],
},
]
describe('toFailoverEvents', () => {
it('инцидент только при binding down, не при unhealthy ноды', () => {
expect(isFailoverEventStatus('down')).toBe(true)
@@ -27,7 +47,7 @@ describe('toFailoverEvents', () => {
expect(isFailoverEventStatus('unhealthy')).toBe(false)
})
it('без DNS-пула ничего не показывает — нельзя врать про вывод', () => {
it('Down всегда виден, даже без A-записей', () => {
const events = toFailoverEvents([
row({
ip: '130.49.213.153',
@@ -36,7 +56,19 @@ describe('toFailoverEvents', () => {
last_error: 'fetch failed',
}),
])
expect(events).toEqual([])
expect(events).toEqual([
{
id: '130.49.213.153',
address: '130.49.213.153',
status: 'down',
kind: 'last-resort',
fqdns: [],
consecutiveFailures: 9,
lastFailureReason: 'fetch failed',
lastCheckAt: null,
},
])
expect(failoverEventCopy(events[0]!)).toBe('Down, в A-записях last-resort')
})
it('OK вне пула не инцидент (standby)', () => {
@@ -45,7 +77,13 @@ describe('toFailoverEvents', () => {
row({ ip: '10.0.0.1', status: 'up' }),
row({ ip: '130.49.213.153', status: 'up' }),
],
['10.0.0.1'],
[
{
fqdn: 'pool.example.com',
configured: ['10.0.0.1', '130.49.213.153'],
active: ['10.0.0.1'],
},
],
)
expect(events).toEqual([])
})
@@ -57,12 +95,18 @@ describe('toFailoverEvents', () => {
row({ ip: '10.0.0.2', status: 'unknown' }),
row({ ip: '10.0.0.3', status: 'degraded' }),
],
['10.0.0.1'],
[
{
fqdn: 'pool.example.com',
configured: ['10.0.0.1', '10.0.0.2', '10.0.0.3'],
active: ['10.0.0.1'],
},
],
)
expect(events).toEqual([])
})
it('down в DNS-пуле не инцидент (last-resort / ещё в A-записи)', () => {
it('Down на доп. FQDN last-resort, снятая с общего пула', () => {
const events = toFailoverEvents(
[
row({
@@ -70,15 +114,46 @@ describe('toFailoverEvents', () => {
status: 'down',
consecutive_failures: 9,
last_error: 'fetch failed',
last_checked_at: '2026-08-20 07:00:00',
}),
row({ ip: '10.0.0.3', status: 'down' }),
row({ ip: '93.115.203.183', status: 'up' }),
],
['130.49.213.153', '10.0.0.2'],
mskHip,
)
expect(events.map((event) => event.address)).toEqual(['10.0.0.3'])
expect(events).toEqual([
{
id: '130.49.213.153',
address: '130.49.213.153',
status: 'down',
kind: 'removed',
fqdns: ['gt.rkns.top'],
consecutiveFailures: 9,
lastFailureReason: 'fetch failed',
lastCheckAt: '2026-08-20 07:00:00',
},
])
expect(failoverEventCopy(events[0]!)).toBe('Снята с gt.rkns.top')
})
it('down без A-записи — реальный вывод из пула', () => {
it('Down только last-resort на своём FQDN', () => {
const events = toFailoverEvents(
[row({ ip: '130.49.213.153', status: 'down' })],
[
{
fqdn: 'nsgt.rkns.top',
configured: ['130.49.213.153'],
active: ['130.49.213.153'],
},
],
)
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',
)
})
it('down без A-записи на configured FQDN — снятие', () => {
const events = toFailoverEvents(
[
row({
@@ -89,13 +164,21 @@ describe('toFailoverEvents', () => {
last_checked_at: '2026-08-20 07:00:00',
}),
],
['10.0.0.1'],
[
{
fqdn: 'pool.example.com',
configured: ['10.0.0.1', '130.49.213.153'],
active: ['10.0.0.1'],
},
],
)
expect(events).toEqual([
{
id: '130.49.213.153',
address: '130.49.213.153',
status: 'down',
kind: 'removed',
fqdns: ['pool.example.com'],
consecutiveFailures: 9,
lastFailureReason: 'fetch failed',
lastCheckAt: '2026-08-20 07:00:00',
+40 -11
View File
@@ -1,7 +1,11 @@
export type FailoverEventKind = 'removed' | 'last-resort'
export interface FailoverEvent {
id: string
address: string
status: string
kind: FailoverEventKind
fqdns: string[]
consecutiveFailures: number
lastFailureReason: string | null
lastCheckAt?: string | null
@@ -16,31 +20,56 @@ export interface FailoverHealthInput {
last_checked_at?: string | null
}
export interface FailoverBindingPool {
fqdn: string
configured: readonly string[]
active: readonly string[]
}
/** Инцидент только при down. up / degraded / unknown — не вывод из пула. */
export function isFailoverEventStatus(status: string): boolean {
return status === 'down'
}
export function failoverEventCopy(event: FailoverEvent): string {
if (event.kind === 'removed') {
return event.fqdns.length > 0
? `Снята с ${event.fqdns.join(', ')}`
: 'Снята с DNS'
}
return event.fqdns.length > 0
? `Down, в A-записях last-resort на ${event.fqdns.join(', ')}`
: 'Down, в A-записях last-resort'
}
/**
* Инцидент failover = binding health down и адреса нет в DNS-пуле.
* OK / unknown / degraded вне пула — standby, не инцидент.
* Down в activeAddresses (last-resort) — не «снята с DNS».
* Текущие Down всегда в панели. Per-FQDN: снята с hostname или last-resort.
* Standby (up / degraded / unknown) — не инцидент.
*/
export function toFailoverEvents(
ipHealth: readonly FailoverHealthInput[],
activeAddresses: readonly string[] = [],
bindings: readonly FailoverBindingPool[] = [],
): FailoverEvent[] {
const pool = new Set(activeAddresses)
if (pool.size === 0) return []
return ipHealth
.filter((row) => isFailoverEventStatus(row.status) && !pool.has(row.ip))
.map((row) => ({
return ipHealth.filter((row) => isFailoverEventStatus(row.status)).map((row) => {
const removedFqdns: string[] = []
const lastResortFqdns: string[] = []
for (const binding of bindings) {
const configured = binding.configured.includes(row.ip)
const active = binding.active.includes(row.ip)
if (configured && !active) removedFqdns.push(binding.fqdn)
else if (configured && active) lastResortFqdns.push(binding.fqdn)
}
const kind: FailoverEventKind =
removedFqdns.length > 0 ? 'removed' : 'last-resort'
return {
id: row.ip,
address: row.ip,
status: row.status,
kind,
fqdns: kind === 'removed' ? removedFqdns : lastResortFqdns,
consecutiveFailures: row.consecutive_failures ?? 0,
lastFailureReason: row.last_error ?? null,
lastCheckAt: row.last_checked_at ?? null,
}))
}
})
}
+12 -2
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import {
bestAliveHealthStatus,
collapseStatusChanges,
enabledHealthProviders,
providerHealthStatuses,
@@ -73,7 +74,7 @@ describe('enabledHealthProviders', () => {
})
describe('providerHealthStatuses', () => {
it('uses worst latest-per-ip status', () => {
it('uses any-up among latest-per-ip statuses', () => {
const items = [
probe({ id: 1, ip: '1.1.1.1', status: 'up', checked_at: '2026-01-01T00:02:00Z' }),
probe({ id: 2, ip: '2.2.2.2', status: 'down', checked_at: '2026-01-01T00:01:00Z' }),
@@ -84,7 +85,7 @@ describe('providerHealthStatuses', () => {
checked_at: '2026-01-01T00:00:00Z',
}),
]
expect(providerHealthStatuses(items, ['local']).local).toBe('down')
expect(providerHealthStatuses(items, ['local']).local).toBe('up')
})
})
@@ -95,3 +96,12 @@ describe('worstHealthStatus', () => {
expect(worstHealthStatus([])).toBe('unknown')
})
})
describe('bestAliveHealthStatus', () => {
it('is up if any IP is up', () => {
expect(bestAliveHealthStatus(['up', 'down'])).toBe('up')
expect(bestAliveHealthStatus(['degraded', 'down'])).toBe('degraded')
expect(bestAliveHealthStatus(['down'])).toBe('down')
expect(bestAliveHealthStatus([])).toBe('unknown')
})
})
+13 -2
View File
@@ -98,7 +98,18 @@ export function worstHealthStatus(statuses: readonly HealthLogStatus[]): HealthL
)
}
/** Latest probe per IP for a provider, then worst among those IPs. */
/** Any up → up; else degraded, then down, then unknown. */
export function bestAliveHealthStatus(
statuses: readonly HealthLogStatus[],
): HealthLogStatus {
if (statuses.length === 0) return 'unknown'
if (statuses.some((status) => status === 'up')) return 'up'
if (statuses.some((status) => status === 'degraded')) return 'degraded'
if (statuses.some((status) => status === 'down')) return 'down'
return 'unknown'
}
/** Latest probe per IP for a provider, then any-up among those IPs. */
export function providerHealthStatuses(
items: readonly HealthLogProbe[],
providers: readonly HealthCheckProvider[],
@@ -120,7 +131,7 @@ export function providerHealthStatuses(
const statuses = [...latestByIp.values()]
.filter((item) => item.provider === provider)
.map((item) => item.status)
result[provider] = worstHealthStatus(statuses)
result[provider] = bestAliveHealthStatus(statuses)
}
return result
+13
View File
@@ -84,6 +84,7 @@ export const serviceDomainBindingSchema = z
health_check_aggregate: z.enum(['any', 'all', 'majority']).catch('majority'),
cert_monitoring: z.enum(['auto', 'required', 'skipped']).default('auto'),
sync_status: z.string().nullable().default(null),
active_ips: z.array(z.string()).default([]),
})
.transform((binding) => ({
...binding,
@@ -125,6 +126,18 @@ export const healthProbeLogSchema = z.object({
checked_at: z.string(),
})
export const failoverLogSchema = z.object({
id: z.number(),
service_id: z.number(),
binding_id: z.number(),
fqdn: z.string(),
ip: z.string(),
action: z.enum(['added', 'removed']),
created_at: z.string(),
})
export type FailoverLogEntry = z.infer<typeof failoverLogSchema>
export const serviceViewSchema = serviceSchema.extend({
subdomain: z.string().default(''),
enabled: z.coerce.boolean().default(false),