Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4fbffe000 | ||
|
|
e755974273 | ||
|
|
3e46681263 | ||
|
|
5bdb1f1f93 | ||
|
|
240d83fc61 | ||
|
|
bda45d633e | ||
|
|
f8e0520817 | ||
|
|
924f111e48 | ||
|
|
7b0f09e292 | ||
|
|
574e92830b | ||
|
|
3b79d834cd | ||
|
|
aba18e15b7 | ||
|
|
53420b49a1 | ||
|
|
55ddce43f7 | ||
|
|
bcb1c48997 |
No files matched your search
@@ -322,6 +322,68 @@ jobs:
|
||||
go build -o "$out/$name" "./$d"
|
||||
done
|
||||
|
||||
postgres-integration:
|
||||
needs: [changes]
|
||||
if: needs.changes.outputs.go == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
env:
|
||||
POSTGRES_USER: evobgp
|
||||
POSTGRES_PASSWORD: evobgp_ci
|
||||
POSTGRES_DB: evobgp_test
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U evobgp -d evobgp_test"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 12
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
|
||||
with:
|
||||
go-version: "1.24"
|
||||
cache: false
|
||||
- name: Export cache paths
|
||||
run: sh scripts/ci/export-cache-env.sh
|
||||
- id: go-hash
|
||||
run: echo "key=$(sha256sum go.sum | awk '{print $1}')" >> "$GITHUB_OUTPUT"
|
||||
- uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||
with:
|
||||
path: |
|
||||
${{ env.GOMODCACHE }}
|
||||
${{ env.GOCACHE }}
|
||||
key: go-${{ runner.os }}-1.24-postgres-${{ steps.go-hash.outputs.key }}
|
||||
restore-keys: |
|
||||
go-${{ runner.os }}-1.24-postgres-
|
||||
- name: Run migrations and verify PostgreSQL integration tests executed
|
||||
env:
|
||||
GOFLAGS: -mod=readonly
|
||||
# Job and the postgres service are sibling containers on act_runner.
|
||||
# 127.0.0.1 is the job itself; the service is reachable by its name.
|
||||
EVOBGP_TEST_DATABASE_URL: postgres://evobgp:evobgp_ci@postgres:5432/evobgp_test?sslmode=disable
|
||||
run: bash scripts/ci/postgres-integration.sh
|
||||
|
||||
legacy-compatibility:
|
||||
needs: [changes]
|
||||
if: needs.changes.outputs.go == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
legacy_version: ["2.56.2", "1.0.0"]
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
|
||||
with:
|
||||
go-version: "1.24"
|
||||
cache: false
|
||||
- name: Build pinned legacy executables and test current control plane
|
||||
env:
|
||||
EVOBGP_LEGACY_VERSION: ${{ matrix.legacy_version }}
|
||||
run: bash scripts/ci/legacy-node-compatibility.sh
|
||||
|
||||
bird2:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [changes, go]
|
||||
@@ -335,6 +397,10 @@ jobs:
|
||||
needs.changes.outputs.docker_go == 'true')
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
|
||||
with:
|
||||
go-version: "1.24"
|
||||
cache: false
|
||||
- name: Install bird2 (репозиторий Ubuntu runner, как в образе evobgp-bird2)
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
@@ -358,6 +424,11 @@ jobs:
|
||||
echo "==> $conf"
|
||||
bird -c "$WS/$conf" -p
|
||||
done
|
||||
- name: Parse staged birddeploy generation with real BIRD
|
||||
env:
|
||||
GOFLAGS: -mod=readonly
|
||||
EVOBGP_TEST_BIRD_BIN: bird
|
||||
run: go test ./internal/birddeploy -run '^TestRealBirdParseGeneration$' -count=1 -v
|
||||
|
||||
commitlint:
|
||||
if: inputs.is_pull_request
|
||||
@@ -405,6 +476,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- name: Offline mirror behavior tests
|
||||
run: bash scripts/tests/mirror-base-images.test.sh
|
||||
- uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
with:
|
||||
name: evobgp
|
||||
|
||||
@@ -32,3 +32,6 @@ deploy/compose/runtime-logs/
|
||||
.cursor/mcp.json
|
||||
.mcp.json
|
||||
.codegraph/daemon.pid
|
||||
|
||||
# Local backend resource snapshots (host metadata, not source artifacts)
|
||||
artifacts/backend-baseline/
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
speakerCapacityBars,
|
||||
} from '@/lib/metrics'
|
||||
import { runningJobCount } from '@/queries/overview'
|
||||
import { peerSessionEstablished, speakerAgentOnline } from '@/lib/live-observation'
|
||||
import type { JobRow, PeerRow, SpeakerRow } from '@/types/api'
|
||||
|
||||
type CapacityMode = 'peers' | 'speakers'
|
||||
@@ -50,8 +51,11 @@ export function DashboardNetworkCapacityCard({
|
||||
|
||||
const deltaLabel =
|
||||
mode === 'peers'
|
||||
? `${peers.filter((p) => p.enabled !== false && p.session_state === 'Established').length} установлено`
|
||||
: `${speakers.filter((s) => s.live?.agent_ok).length} в сети`
|
||||
? `${peers.filter((p) => p.enabled !== false && peerSessionEstablished(p)).length} установлено`
|
||||
: `${speakers.filter(speakerAgentOnline).length} в сети`
|
||||
const allHealthy = mode === 'peers'
|
||||
? peers.some((p) => p.enabled !== false) && peers.filter((p) => p.enabled !== false).every(peerSessionEstablished)
|
||||
: speakers.length > 0 && speakers.every(speakerAgentOnline)
|
||||
|
||||
return (
|
||||
<AnalyticsCardShell
|
||||
@@ -73,7 +77,7 @@ export function DashboardNetworkCapacityCard({
|
||||
<p className="text-3xl font-semibold tracking-tight tabular-nums">
|
||||
{loading ? '—' : `${utilization}%`}
|
||||
</p>
|
||||
<p className="text-sm text-success">{loading ? '…' : `${deltaLabel} · снимок live`}</p>
|
||||
<p className={`text-sm ${allHealthy ? 'text-success' : 'text-muted-foreground'}`}>{loading ? '…' : `${deltaLabel} · снимок live`}</p>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
recentPlatformActivity,
|
||||
} from '@/lib/metrics'
|
||||
import { runningJobCount } from '@/queries/overview'
|
||||
import { peerSessionCurrent, peerSessionEstablished, peerSessionKnown, speakerAgentOnline } from '@/lib/live-observation'
|
||||
import type { JobRow, ModuleRow, PeerRow, RevisionRow, SpeakerRow } from '@/types/api'
|
||||
|
||||
export function DashboardPlatformCard({
|
||||
@@ -35,15 +36,16 @@ export function DashboardPlatformCard({
|
||||
const enabledModules = modules.filter((m) => m.enabled !== false).length
|
||||
const peersEnabled = peers.filter((p) => p.enabled !== false).length
|
||||
const peersEstablished = peers.filter(
|
||||
(p) => p.enabled !== false && p.session_state === 'Established',
|
||||
(p) => p.enabled !== false && peerSessionEstablished(p),
|
||||
).length
|
||||
const peersMismatch = peers.filter((p) => p.session_mismatch).length
|
||||
const speakersOnline = speakers.filter((s) => s.live?.agent_ok).length
|
||||
const peersUnknown = peersEnabled - peers.filter((p) => p.enabled !== false && peerSessionKnown(p)).length
|
||||
const peersMismatch = peers.filter((p) => p.session_mismatch && peerSessionCurrent(p)).length
|
||||
const speakersOnline = speakers.filter(speakerAgentOnline).length
|
||||
const failedJobs = jobs.filter((j) =>
|
||||
['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
|
||||
).length
|
||||
const running = runningJobCount(jobs)
|
||||
const riskCount = peersMismatch + failedJobs + Math.max(0, speakers.length - speakersOnline)
|
||||
const riskCount = peersMismatch + peersUnknown + failedJobs + Math.max(0, speakers.length - speakersOnline)
|
||||
|
||||
const bgpPct =
|
||||
peersEnabled > 0 ? Math.round((peersEstablished / peersEnabled) * 100) : null
|
||||
@@ -69,7 +71,7 @@ export function DashboardPlatformCard({
|
||||
label: 'BGP готовность',
|
||||
value: loading || bgpPct === null ? '—' : `${bgpPct}%`,
|
||||
delta: {
|
||||
direction: (bgpPct !== null && bgpPct >= 90 ? 'up' : bgpPct !== null && bgpPct < 70 ? 'down' : 'neutral') as
|
||||
direction: (bgpPct !== null && bgpPct >= 90 && peersUnknown === 0 ? 'up' : bgpPct !== null && bgpPct < 70 ? 'down' : 'neutral') as
|
||||
| 'up'
|
||||
| 'down'
|
||||
| 'neutral',
|
||||
@@ -77,7 +79,7 @@ export function DashboardPlatformCard({
|
||||
bgpPct === null
|
||||
? 'нет включённых пиров'
|
||||
: `${peersEstablished} установлено`,
|
||||
tone: (bgpPct !== null && bgpPct >= 90
|
||||
tone: (bgpPct !== null && bgpPct >= 90 && peersUnknown === 0
|
||||
? 'success'
|
||||
: bgpPct !== null && bgpPct < 70
|
||||
? 'warning'
|
||||
@@ -89,7 +91,7 @@ export function DashboardPlatformCard({
|
||||
value: loading ? '—' : String(riskCount),
|
||||
delta: {
|
||||
direction: (riskCount > 0 ? 'down' : 'up') as 'up' | 'down',
|
||||
label: riskCount > 0 ? `${failedJobs} задач, ${peersMismatch} расхождений` : 'в норме',
|
||||
label: riskCount > 0 ? `${failedJobs} задач, ${peersMismatch} расхождений, ${peersUnknown} без наблюдения` : 'в норме',
|
||||
tone: (riskCount > 0 ? 'destructive' : 'success') as 'destructive' | 'success',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -16,7 +16,7 @@ export function NetworkOverviewAnalyticsCard({
|
||||
const net = aggregateNetworkMetrics(peers, speakers)
|
||||
const slices = peerSessionBreakdown(peers)
|
||||
const speakersPct =
|
||||
net.speakersTotal > 0 ? Math.round((net.speakersOnline / net.speakersTotal) * 100) : 0
|
||||
net.speakersObserved > 0 ? Math.round((net.speakersOnline / net.speakersObserved) * 100) : 0
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -36,22 +36,25 @@ export function NetworkOverviewAnalyticsCard({
|
||||
description="Распределение состояний пиров"
|
||||
slices={slices}
|
||||
centerLabel="Пиры"
|
||||
badge={net.peersMismatch > 0 ? `${net.peersMismatch} расхождений` : 'в норме'}
|
||||
badge={net.peersMismatch > 0 ? `${net.peersMismatch} расхождений`
|
||||
: net.peersObserved < net.peersEnabled ? 'есть пиры без наблюдения'
|
||||
: net.peersEnabled === 0 ? 'нет включённых пиров'
|
||||
: net.peersEstablished === net.peersObserved ? 'в норме' : 'есть неустановленные сессии'}
|
||||
/>
|
||||
<SegmentedProgressCard
|
||||
title="Спикеры"
|
||||
description="Доступность агентов"
|
||||
primary={{
|
||||
value: `${net.speakersOnline}/${net.speakersTotal}`,
|
||||
value: `${net.speakersOnline}/${net.speakersObserved}`,
|
||||
label: 'В сети',
|
||||
percent: speakersPct,
|
||||
}}
|
||||
secondary={{
|
||||
value: net.speakersTotal - net.speakersOnline,
|
||||
value: net.speakersObserved - net.speakersOnline,
|
||||
label: 'Не в сети',
|
||||
percent: 100 - speakersPct,
|
||||
percent: net.speakersObserved > 0 ? 100 - speakersPct : 0,
|
||||
}}
|
||||
footer={`Пиры установлены: ${net.peersEstablished}/${net.peersEnabled}`}
|
||||
footer={`Пиры установлены: ${net.peersEstablished}/${net.peersObserved} · без наблюдения: ${net.peersEnabled - net.peersObserved} пиров, ${net.speakersTotal - net.speakersObserved} спикеров`}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -35,6 +35,7 @@ function buildKpis({
|
||||
const enabledModules = modules.filter((m) => m.enabled !== false).length
|
||||
const network = aggregateNetworkMetrics(peers, speakers)
|
||||
const peersEnabled = network.peersEnabled
|
||||
const peersUnknown = Math.max(0, peersEnabled - network.peersObserved)
|
||||
const bgpPct =
|
||||
peersEnabled > 0 ? Math.round((network.peersEstablished / peersEnabled) * 100) : null
|
||||
const running = runningJobCount(jobs)
|
||||
@@ -42,7 +43,7 @@ function buildKpis({
|
||||
['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
|
||||
).length
|
||||
const offlineSpeakers = Math.max(0, speakers.length - network.speakersOnline)
|
||||
const riskCount = network.peersMismatch + failedJobs + offlineSpeakers
|
||||
const riskCount = network.peersMismatch + peersUnknown + failedJobs + offlineSpeakers
|
||||
const disabledModules = Math.max(0, modules.length - enabledModules)
|
||||
|
||||
return [
|
||||
@@ -84,7 +85,7 @@ function buildKpis({
|
||||
footer: (
|
||||
<Badge
|
||||
variant={
|
||||
bgpPct !== null && bgpPct >= 90
|
||||
bgpPct !== null && bgpPct >= 90 && peersUnknown === 0
|
||||
? 'success-light'
|
||||
: bgpPct !== null && bgpPct < 70
|
||||
? 'warning-light'
|
||||
@@ -94,9 +95,9 @@ function buildKpis({
|
||||
>
|
||||
{loading || bgpPct === null
|
||||
? 'нет включённых пиров'
|
||||
: bgpPct >= 90
|
||||
: bgpPct >= 90 && peersUnknown === 0
|
||||
? 'сессии в норме'
|
||||
: `${network.peersEstablished} установлено`}
|
||||
: peersUnknown > 0 ? `${peersUnknown} без наблюдения` : `${network.peersEstablished} установлено`}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
@@ -124,7 +125,7 @@ function buildKpis({
|
||||
{loading
|
||||
? '…'
|
||||
: riskCount > 0
|
||||
? `${failedJobs} задач · ${network.peersMismatch} расхождений`
|
||||
? `${failedJobs} задач · ${network.peersMismatch} расхождений · ${peersUnknown} без наблюдения`
|
||||
: 'в норме'}
|
||||
</Badge>
|
||||
),
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
speakerCapacityBars,
|
||||
} from '@/lib/metrics'
|
||||
import { runningJobCount } from '@/queries/overview'
|
||||
import { peerSessionEstablished, peerSessionKnown, speakerAgentKnown, speakerAgentOnline } from '@/lib/live-observation'
|
||||
import type { JobRow, PeerRow, SpeakerRow } from '@/types/api'
|
||||
|
||||
type CapacityMode = 'peers' | 'speakers'
|
||||
@@ -35,11 +36,14 @@ export function DashboardNetworkHealth({
|
||||
|
||||
const established =
|
||||
mode === 'peers'
|
||||
? peers.filter((p) => p.enabled !== false && p.session_state === 'Established').length
|
||||
: speakers.filter((s) => s.live?.agent_ok).length
|
||||
? peers.filter((p) => p.enabled !== false && peerSessionEstablished(p)).length
|
||||
: speakers.filter(speakerAgentOnline).length
|
||||
const total =
|
||||
mode === 'peers' ? peers.filter((p) => p.enabled !== false).length : speakers.length
|
||||
mode === 'peers' ? peers.filter((p) => p.enabled !== false && peerSessionKnown(p)).length : speakers.filter(speakerAgentKnown).length
|
||||
const offline = Math.max(0, total - established)
|
||||
const unknown = mode === 'peers'
|
||||
? peers.filter((p) => p.enabled !== false).length - total
|
||||
: speakers.length - total
|
||||
|
||||
return (
|
||||
<SegmentedProgressCard
|
||||
@@ -64,7 +68,7 @@ export function DashboardNetworkHealth({
|
||||
variant={
|
||||
loading || total === 0
|
||||
? 'outline'
|
||||
: offline === 0
|
||||
: offline === 0 && unknown === 0
|
||||
? 'success-light'
|
||||
: 'warning-light'
|
||||
}
|
||||
@@ -75,10 +79,12 @@ export function DashboardNetworkHealth({
|
||||
? '…'
|
||||
: total === 0
|
||||
? 'нет данных'
|
||||
: offline === 0
|
||||
: offline === 0 && unknown === 0
|
||||
? mode === 'peers'
|
||||
? 'все установлены'
|
||||
: 'все в сети'
|
||||
: unknown > 0
|
||||
? `${unknown} без наблюдения`
|
||||
: mode === 'peers'
|
||||
? `${offline} не установлены`
|
||||
: `${offline} офлайн`}
|
||||
@@ -93,7 +99,7 @@ export function DashboardNetworkHealth({
|
||||
footer={
|
||||
loading
|
||||
? 'Загрузка…'
|
||||
: `Активных задач: ${queued} · сегментов в графике: ${bars.length}`
|
||||
: `Активных задач: ${queued} · сегментов в графике: ${bars.length} · без наблюдения: ${unknown}`
|
||||
}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -28,8 +28,10 @@ export function DashboardNetworkPanel({
|
||||
const m = aggregateNetworkMetrics(peers, speakers)
|
||||
return (
|
||||
<div className="flex flex-col gap-2 p-3">
|
||||
<Row label="Пиры с установленной сессией" value={`${m.peersEstablished} / ${m.peersEnabled}`} />
|
||||
<Row label="Спикеры в сети" value={`${m.speakersOnline} / ${m.speakersTotal}`} />
|
||||
<Row label="Пиры с установленной сессией" value={`${m.peersEstablished} / ${m.peersObserved}`} />
|
||||
<Row label="Пиры без наблюдения" value={String(m.peersEnabled - m.peersObserved)} />
|
||||
<Row label="Спикеры в сети" value={`${m.speakersOnline} / ${m.speakersObserved}`} />
|
||||
<Row label="Спикеры без наблюдения" value={String(m.speakersTotal - m.speakersObserved)} />
|
||||
{m.peersMismatch > 0 ? (
|
||||
<Row label="Расхождения сессий" value={String(m.peersMismatch)} variant="warning" />
|
||||
) : null}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useMemo } from 'react'
|
||||
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
|
||||
|
||||
import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from '@evobgp/ui/components/chart'
|
||||
import { Frame, FrameDescription, FrameHeader, FramePanel, FrameTitle } from '@/components/reui/frame'
|
||||
import { fmtWhen } from '@/components/portal/format'
|
||||
import type { RevisionRow } from '@/types/api'
|
||||
|
||||
const chartConfig = {
|
||||
prefixes: { label: 'Префиксов', color: 'var(--chart-1)' },
|
||||
} satisfies ChartConfig
|
||||
|
||||
/** Префиксов в опубликованной конфигурации по последним ревизиям (доска «Панель»). */
|
||||
export function DashboardPrefixTrend({ revisions, loading }: { revisions: RevisionRow[]; loading?: boolean }) {
|
||||
const data = useMemo(
|
||||
() =>
|
||||
[...revisions]
|
||||
.sort((a, b) => a.created_at.localeCompare(b.created_at))
|
||||
.slice(-12)
|
||||
.map((r) => ({ id: r.id, when: fmtWhen(r.created_at), prefixes: r.materialized_prefix_count })),
|
||||
[revisions],
|
||||
)
|
||||
|
||||
return (
|
||||
<Frame className="[--frame-panel-header-py-adjust:4px] w-full min-w-0">
|
||||
<FrameHeader className="flex-col items-start gap-0.5">
|
||||
<FrameTitle>Префиксов в опубликованной конфигурации</FrameTitle>
|
||||
<FrameDescription className="text-pretty">Последние {data.length || 12} ревизий</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel className="p-3">
|
||||
{data.length < 2 ? (
|
||||
<div className="text-muted-foreground flex h-48 items-center justify-center text-sm">
|
||||
{loading ? 'Загрузка…' : 'Недостаточно ревизий для графика'}
|
||||
</div>
|
||||
) : (
|
||||
<ChartContainer config={chartConfig} className="aspect-auto h-64 w-full">
|
||||
<AreaChart data={data} margin={{ left: 4, right: 12, top: 8 }}>
|
||||
<defs>
|
||||
<linearGradient id="prefix-fill" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="var(--color-prefixes)" stopOpacity={0.35} />
|
||||
<stop offset="95%" stopColor="var(--color-prefixes)" stopOpacity={0.02} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid vertical={false} strokeDasharray="3 3" />
|
||||
<XAxis dataKey="id" tickLine={false} axisLine={false} tickMargin={8} minTickGap={24} />
|
||||
<YAxis domain={([min, max]) => [Math.floor(min * 0.98), Math.ceil(max * 1.01)]} tickLine={false} axisLine={false} width={48} tickFormatter={(v: number) => v.toLocaleString('ru-RU')} />
|
||||
<ChartTooltip content={<ChartTooltipContent labelFormatter={(_, p) => p?.[0]?.payload?.when ?? ''} />} />
|
||||
<Area dataKey="prefixes" type="monotone" stroke="var(--color-prefixes)" strokeWidth={2} fill="url(#prefix-fill)" />
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
)}
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -19,6 +19,8 @@ import { CommandPalette, PortalHeader, PortalSidebar } from '@/components/portal
|
||||
import { ChromeConfigProvider, PortalUiProvider } from '@/components/portal/chrome-context'
|
||||
import type { ChromeConfig, MonitorItem, MonitorMetric, NavItem, PaletteObject } from '@/components/portal/chrome-store'
|
||||
import { can, getClaims, isAuthEnabled, permissionForPath, redirectToPortalLogout, clearPortalToken, resetPortalHandoff } from '@/lib/auth'
|
||||
import { useLiveObservationState } from '@/hooks/use-live-observation-state'
|
||||
import { peerSessionEstablished, peerSessionKnown, peersForDisplay, snapshotUnavailable } from '@/lib/live-observation'
|
||||
import { SKIP_TO_CONTENT_CLASS } from '@/lib/ui-surface'
|
||||
import { monitoringHealthQueryOptions } from '@/queries/monitoring'
|
||||
import { networkBirdQueryOptions } from '@/queries/network'
|
||||
@@ -36,7 +38,7 @@ const FRESH: Record<string, { badge: string; text: string }> = {
|
||||
'/dashboard': { badge: 'Live', text: 'GET /v1/modules · /v1/peers · /v1/jobs · данные обновляются при открытии страницы' },
|
||||
'/modules': { badge: 'Каталог', text: 'GET /v1/modules · списки префиксов и AS' },
|
||||
'/lookup': { badge: 'Проверка', text: 'GET /v1/lookup · IP или домен по спискам и community' },
|
||||
'/network': { badge: 'BGP', text: 'GET /v1/peers · /v1/speakers · состояние сессий опрашивается живьём' },
|
||||
'/network': { badge: 'BGP', text: 'GET /v1/peers · /v1/speakers · сохранённые снимки состояния сессий' },
|
||||
'/directories': { badge: 'Справочники', text: 'BGP community и DoH-профили' },
|
||||
'/operations': { badge: 'Ревизии', text: 'GET /v1/revisions · /v1/jobs · применение и откат' },
|
||||
'/schedule': { badge: 'Задачи', text: 'Расписание обновления модулей' },
|
||||
@@ -85,18 +87,24 @@ function useChromeData(): ChromeConfig {
|
||||
const jobsQ = useQuery({ ...overviewJobsQueryOptions(), refetchInterval: 30_000 })
|
||||
const modulesQ = useQuery(overviewModulesQueryOptions())
|
||||
const birdQ = useQuery({ ...networkBirdQueryOptions(), refetchInterval: 30_000 })
|
||||
const { offline, disconnectedAt, revision } = useLiveObservationState(peersQ.data?.items ?? [], [])
|
||||
|
||||
return useMemo<ChromeConfig>(() => {
|
||||
const peers = peersQ.data?.items ?? []
|
||||
const peersUnavailable = snapshotUnavailable(peersQ.isError, offline, disconnectedAt, peersQ.dataUpdatedAt)
|
||||
const healthUnavailable = snapshotUnavailable(healthQ.isError, offline, disconnectedAt, healthQ.dataUpdatedAt)
|
||||
const birdUnavailable = snapshotUnavailable(birdQ.isError, offline, disconnectedAt, birdQ.dataUpdatedAt)
|
||||
const peers = peersForDisplay(peersQ.data?.items ?? [], peersUnavailable)
|
||||
const jobs = jobsQ.data?.items ?? []
|
||||
const modules = modulesQ.data?.items ?? []
|
||||
const net = aggregateNetworkMetrics(peers, [])
|
||||
const failedJobs = jobs.filter((j) => ['failed', 'error', 'cancelled'].includes(j.status.toLowerCase())).length
|
||||
const peersDown = Math.max(0, net.peersEnabled - net.peersEstablished)
|
||||
const healthOk = healthQ.data?.ok ?? false
|
||||
const peersDown = Math.max(0, net.peersObserved - net.peersEstablished)
|
||||
const peersUnknown = Math.max(0, net.peersEnabled - net.peersObserved)
|
||||
const healthOk = !healthUnavailable && (healthQ.data?.ok ?? false)
|
||||
const bird = birdUnavailable ? undefined : birdQ.data
|
||||
const birdRatio =
|
||||
birdQ.data && birdQ.data.bgp_sessions_total > 0
|
||||
? Math.round((birdQ.data.bgp_established / birdQ.data.bgp_sessions_total) * 100)
|
||||
bird && bird.bgp_sessions_total > 0
|
||||
? Math.round((bird.bgp_established / bird.bgp_sessions_total) * 100)
|
||||
: null
|
||||
const bgpPct = net.peersEnabled > 0 ? Math.round((net.peersEstablished / net.peersEnabled) * 100) : 0
|
||||
|
||||
@@ -106,7 +114,7 @@ function useChromeData(): ChromeConfig {
|
||||
return !perm || can(perm)
|
||||
})
|
||||
const problems: Record<string, NavItem['problem']> = {
|
||||
network: peersDown > 0 ? { tone: 'er', label: `${peersDown} пиров не в Established` } : undefined,
|
||||
network: peersUnavailable ? { tone: 'wn', label: 'Снимок сети недоступен' } : peersDown > 0 ? { tone: 'er', label: `${peersDown} пиров не в Established` } : peersUnknown > 0 ? { tone: 'wn', label: `${peersUnknown} пиров без наблюдения` } : undefined,
|
||||
schedule: failedJobs > 0 ? { tone: 'wn', label: `Ошибок задач: ${failedJobs}` } : undefined,
|
||||
monitoring: healthQ.isSuccess && !healthOk ? { tone: 'er', label: 'API не отвечает как норма' } : undefined,
|
||||
}
|
||||
@@ -118,18 +126,25 @@ function useChromeData(): ChromeConfig {
|
||||
monitorItems.push({ id: 'health', tone: 'er', title: 'API EvoBGP не в норме', text: 'GET /health вернул ошибку', age: 'сейчас', to: '/monitoring' })
|
||||
}
|
||||
if (peersDown > 0) {
|
||||
monitorItems.push({ id: 'peers', tone: 'er', title: `Пиров не в Established: ${peersDown}`, text: `Включено ${net.peersEnabled}, поднято ${net.peersEstablished}`, age: 'сейчас', to: '/network' })
|
||||
monitorItems.push({ id: 'peers', tone: 'er', title: `Пиров не в Established: ${peersDown}`, text: `Наблюдается ${net.peersObserved}, поднято ${net.peersEstablished}`, age: 'сейчас', to: '/network' })
|
||||
}
|
||||
if (peersUnknown > 0) {
|
||||
monitorItems.push({ id: 'peers-unknown', tone: 'wn', title: `Пиров без наблюдения: ${peersUnknown}`, text: 'Состояние сессий пока неизвестно', age: 'сейчас', to: '/network' })
|
||||
}
|
||||
if (failedJobs > 0) {
|
||||
monitorItems.push({ id: 'jobs', tone: 'wn', title: `Ошибок задач: ${failedJobs}`, text: 'Проверьте расписание и журнал ревизий', age: 'недавно', to: '/schedule' })
|
||||
}
|
||||
if (monitorItems.length === 0) {
|
||||
monitorItems.push({ id: 'ok', tone: 'ok', title: 'Всё в норме', text: 'API, BGP-сессии и задачи без проблем', age: 'сейчас', to: '/monitoring' })
|
||||
if (healthUnavailable || peersUnavailable || birdUnavailable) {
|
||||
monitorItems.push({ id: 'unavailable', tone: 'wn', title: 'Данные мониторинга недоступны', text: 'Проверьте соединение и повторите запрос', age: 'сейчас', to: '/monitoring' })
|
||||
} else {
|
||||
monitorItems.push({ id: 'ok', tone: 'ok', title: 'Всё в норме', text: 'API, BGP-сессии и задачи без проблем', age: 'сейчас', to: '/monitoring' })
|
||||
}
|
||||
}
|
||||
|
||||
const metrics: MonitorMetric[] = [
|
||||
{ l: 'Состояние API', v: healthOk ? 'норма' : '—', p: healthOk ? 100 : 0, tone: healthOk ? 'success' : 'warning' },
|
||||
{ l: 'BGP пиры', v: `${bgpPct}%`, p: bgpPct, tone: bgpPct >= 90 ? 'success' : 'warning' },
|
||||
{ l: 'BGP пиры', v: net.peersObserved === 0 && net.peersEnabled > 0 ? '—' : `${bgpPct}%`, p: bgpPct, tone: peersUnknown === 0 && bgpPct >= 90 ? 'success' : 'warning' },
|
||||
{ l: 'BIRD сессии', v: birdRatio === null ? '—' : `${birdRatio}%`, p: birdRatio ?? 0, tone: birdRatio === 100 ? 'success' : 'warning' },
|
||||
{ l: 'Ошибки задач', v: String(failedJobs), p: Math.min(100, failedJobs * 10), tone: failedJobs > 0 ? 'warning' : 'success' },
|
||||
]
|
||||
@@ -152,7 +167,7 @@ function useChromeData(): ChromeConfig {
|
||||
group: 'BGP-пиры',
|
||||
label: p.name ?? p.neighbor,
|
||||
hint: `${p.neighbor}${p.remote_asn ? ` · AS${p.remote_asn}` : ''}`,
|
||||
dot: p.session_state === 'Established' ? 'ok' : 'er',
|
||||
dot: !peerSessionKnown(p) ? 'mu' : peerSessionEstablished(p) ? 'ok' : 'er',
|
||||
to: '/network',
|
||||
})),
|
||||
]
|
||||
@@ -165,7 +180,7 @@ function useChromeData(): ChromeConfig {
|
||||
securityTo: '/settings',
|
||||
onLogout: logout,
|
||||
fresh: freshFor,
|
||||
monitor: { items: monitorItems, metrics, updated: 'обновлено сейчас', footerTo: '/monitoring', footerLabel: 'Мониторинг' },
|
||||
monitor: { items: monitorItems, metrics, updated: healthUnavailable || peersUnavailable || birdUnavailable ? 'данные недоступны' : 'обновлено сейчас', footerTo: '/monitoring', footerLabel: 'Мониторинг' },
|
||||
palette: {
|
||||
actions: [
|
||||
{ id: 'act-module', label: 'Создать модуль', hint: 'M', icon: PlusIcon, to: '/modules/new' },
|
||||
@@ -177,7 +192,9 @@ function useChromeData(): ChromeConfig {
|
||||
example: 'Подсказка: «пиры не в Established»',
|
||||
},
|
||||
}
|
||||
}, [healthQ.data, healthQ.isSuccess, peersQ.data, jobsQ.data, modulesQ.data, birdQ.data])
|
||||
// Recompute cached metrics when the server-provided observation deadline passes.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [healthQ.data, healthQ.isSuccess, healthQ.isError, healthQ.dataUpdatedAt, peersQ.data, peersQ.isError, peersQ.dataUpdatedAt, jobsQ.data, modulesQ.data, birdQ.data, birdQ.isError, birdQ.dataUpdatedAt, offline, disconnectedAt, revision])
|
||||
}
|
||||
|
||||
/** Оболочка EvoBGP: боковое меню, шапка, монитор, палитра ⌘K — блоки общего макета. */
|
||||
@@ -215,7 +232,7 @@ export function PortalShell({ children }: { children: ReactNode }) {
|
||||
<PortalSidebar />
|
||||
<SidebarInset id="main-content" className="min-w-0">
|
||||
<PortalHeader />
|
||||
<div className="mx-auto flex w-full min-w-0 max-w-[1480px] flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">{children}</div>
|
||||
<div className="flex w-full min-w-0 flex-1 flex-col gap-5 p-4 md:p-6">{children}</div>
|
||||
</SidebarInset>
|
||||
<CommandPalette />
|
||||
</SidebarProvider>
|
||||
|
||||
@@ -17,8 +17,8 @@ import type { ModuleRow } from '@/types/api'
|
||||
|
||||
const MODULE_TABS = [
|
||||
{ id: 'all', label: 'Все' },
|
||||
{ id: 'enabled', label: 'Вкл' },
|
||||
{ id: 'disabled', label: 'Выкл' },
|
||||
{ id: 'enabled', label: 'Включённые' },
|
||||
{ id: 'disabled', label: 'Выключенные' },
|
||||
]
|
||||
|
||||
const TYPE_OPTIONS = [
|
||||
@@ -66,7 +66,7 @@ function tabFilter(item: ModuleRow, tabId: string): boolean {
|
||||
export function ModulesListGrid({
|
||||
items,
|
||||
isLoading = false,
|
||||
title = 'Все модули',
|
||||
title = 'Маршрутные модули',
|
||||
actions,
|
||||
}: {
|
||||
items: ModuleRow[]
|
||||
@@ -130,7 +130,7 @@ export function ModulesListGrid({
|
||||
return (
|
||||
<ResourcePage
|
||||
title={title}
|
||||
description="Маршрутные списки"
|
||||
description="Поиск по имени · фильтр по типу · клик открывает модуль"
|
||||
tabs={MODULE_TABS}
|
||||
tabFilter={tabFilter}
|
||||
filterFields={filterFields}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { observationHint, observationQuality, peerSessionCurrent, speakerAgentKnown } from '@/lib/live-observation'
|
||||
import { bgpSessionStateRu, speakerOnlineLabel } from '@/lib/ui-labels'
|
||||
import type { PeerRow, SpeakerRow } from '@/types/api'
|
||||
|
||||
export function PeerObservationBadge({ peer }: { peer: PeerRow }) {
|
||||
const current = peerSessionCurrent(peer)
|
||||
const state = current ? peer.session_state : null
|
||||
const quality = peer.live_observation && observationQuality(peer.live_observation)
|
||||
const lastKnown = peer.last_known_session_state
|
||||
const hint = [
|
||||
observationHint(peer.live_observation),
|
||||
!current && lastKnown ? `последнее: ${bgpSessionStateRu(lastKnown)}` : undefined,
|
||||
].filter(Boolean).join(' · ')
|
||||
return (
|
||||
<StatusBadge
|
||||
status={current && state ? state : quality === 'stale' ? 'stale' : 'unknown'}
|
||||
label={state ? bgpSessionStateRu(state) : quality === 'stale' ? 'Устарело' : 'Нет данных'}
|
||||
hint={hint || undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function AgentObservationBadge({ speaker }: { speaker: SpeakerRow }) {
|
||||
const observation = speaker.live?.agent_observation
|
||||
const quality = observation && observationQuality(observation)
|
||||
const ok = speaker.live?.agent_ok
|
||||
const reportedUnhealthy = observation?.error_code === 'reported_unhealthy' && ok === false && speakerAgentKnown(speaker)
|
||||
const current = !observation || quality === 'ok' || reportedUnhealthy
|
||||
const label = reportedUnhealthy
|
||||
? 'Агент сообщил об ошибке'
|
||||
: !current
|
||||
? quality === 'stale' ? 'Устарело' : 'Нет данных'
|
||||
: ok == null ? 'Нет данных' : speakerOnlineLabel(ok)
|
||||
const status = !current
|
||||
? quality === 'stale' ? 'stale' : 'unknown'
|
||||
: ok == null ? 'unknown' : ok ? 'ok' : 'error'
|
||||
return <StatusBadge status={status} label={label} hint={observationHint(observation)} />
|
||||
}
|
||||
@@ -21,6 +21,7 @@ export function NetworkKpi({
|
||||
loading?: boolean
|
||||
}) {
|
||||
const net = aggregateNetworkMetrics(peers, speakers)
|
||||
const peersHealthy = net.peersEnabled > 0 && net.peersObserved === net.peersEnabled && net.peersEstablished === net.peersObserved
|
||||
const birdHealthy = bird?.healthy
|
||||
const birdSessions =
|
||||
bird != null ? `${bird.bgp_established} / ${bird.bgp_sessions_total}` : '—'
|
||||
@@ -29,12 +30,12 @@ export function NetworkKpi({
|
||||
{
|
||||
id: 'peers-established',
|
||||
icon: <Share2 aria-hidden />,
|
||||
iconClassName: 'text-success',
|
||||
iconClassName: peersHealthy ? 'text-success' : 'text-warning',
|
||||
label: 'Пиры установлены',
|
||||
value: loading ? '—' : `${net.peersEstablished}/${net.peersEnabled}`,
|
||||
value: loading ? '—' : `${net.peersEstablished}/${net.peersObserved}`,
|
||||
footer: (
|
||||
<Badge variant="success-light" size="sm">
|
||||
{loading ? '…' : `${net.peersTotal} в каталоге`}
|
||||
<Badge variant={peersHealthy ? 'success-light' : 'warning-light'} size="sm">
|
||||
{loading ? '…' : `${net.peersEnabled - net.peersObserved} без наблюдения`}
|
||||
</Badge>
|
||||
),
|
||||
to: '/network',
|
||||
@@ -45,17 +46,17 @@ export function NetworkKpi({
|
||||
icon: <ServerCog aria-hidden />,
|
||||
iconClassName: 'text-info',
|
||||
label: 'Спикеры в сети',
|
||||
value: loading ? '—' : `${net.speakersOnline}/${net.speakersTotal}`,
|
||||
value: loading ? '—' : `${net.speakersOnline}/${net.speakersObserved}`,
|
||||
footer: (
|
||||
<Badge
|
||||
variant={
|
||||
net.speakersOnline === net.speakersTotal && net.speakersTotal > 0
|
||||
net.speakersOnline === net.speakersObserved && net.speakersObserved > 0
|
||||
? 'success-light'
|
||||
: 'warning-light'
|
||||
}
|
||||
size="sm"
|
||||
>
|
||||
{loading ? '…' : 'в сети'}
|
||||
{loading ? '…' : `${net.speakersTotal - net.speakersObserved} без наблюдения`}
|
||||
</Badge>
|
||||
),
|
||||
to: '/network',
|
||||
@@ -70,7 +71,7 @@ export function NetworkKpi({
|
||||
variant: net.peersMismatch > 0 ? 'warning' : 'default',
|
||||
footer: (
|
||||
<Badge variant={net.peersMismatch > 0 ? 'warning-light' : 'outline'} size="sm">
|
||||
{loading ? '…' : net.peersMismatch > 0 ? 'проверить' : 'в норме'}
|
||||
{loading ? '…' : net.peersMismatch > 0 ? 'проверить' : net.peersObserved === 0 && net.peersEnabled > 0 ? 'нет наблюдения' : 'в норме'}
|
||||
</Badge>
|
||||
),
|
||||
to: '/network',
|
||||
|
||||
@@ -2,10 +2,10 @@ import { Share2 } from 'lucide-react'
|
||||
|
||||
import { CategoryBadge } from '@/components/category-badge'
|
||||
import { DataGridMonoCell, DataGridNameCell } from '@/components/data-grid-cell'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { PeerObservationBadge } from '@/components/network/live-observation-badge'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import type { DataGridColumnDef } from '@/components/reui-kit'
|
||||
import { bgpSessionStateRu } from '@/lib/ui-labels'
|
||||
import { peerSessionEstablished, peerSessionKnown } from '@/lib/live-observation'
|
||||
import type { PeerRow } from '@/types/api'
|
||||
|
||||
export const peerColumns: DataGridColumnDef<PeerRow>[] = [
|
||||
@@ -41,10 +41,7 @@ export const peerColumns: DataGridColumnDef<PeerRow>[] = [
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Состояние" />,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-1">
|
||||
<StatusBadge
|
||||
status={row.original.session_state ?? '—'}
|
||||
label={bgpSessionStateRu(row.original.session_state)}
|
||||
/>
|
||||
<PeerObservationBadge peer={row.original} />
|
||||
{row.original.session_mismatch ? (
|
||||
<CategoryBadge tone="warning">расхождение</CategoryBadge>
|
||||
) : null}
|
||||
@@ -61,7 +58,7 @@ export function getPeerFilterFieldValue(item: PeerRow, field: string): unknown {
|
||||
case 'neighbor':
|
||||
return item.neighbor
|
||||
case 'session_state':
|
||||
return item.session_state
|
||||
return peerSessionKnown(item) ? item.session_state : undefined
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
@@ -70,7 +67,7 @@ export function getPeerFilterFieldValue(item: PeerRow, field: string): unknown {
|
||||
export function peerTabFilter(item: PeerRow, tabId: string): boolean {
|
||||
if (tabId === 'disabled') return item.enabled === false
|
||||
const enabled = item.enabled !== false
|
||||
if (tabId === 'established') return enabled && item.session_state === 'Established'
|
||||
if (tabId === 'pending') return enabled && item.session_state !== 'Established'
|
||||
if (tabId === 'established') return enabled && peerSessionEstablished(item)
|
||||
if (tabId === 'pending') return enabled && peerSessionKnown(item) && !peerSessionEstablished(item)
|
||||
return true
|
||||
}
|
||||
@@ -2,11 +2,11 @@ import { ServerCog } from 'lucide-react'
|
||||
|
||||
import { CategoryBadge } from '@/components/category-badge'
|
||||
import { DataGridMonoCell, DataGridNameCell } from '@/components/data-grid-cell'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { AgentObservationBadge } from '@/components/network/live-observation-badge'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import type { DataGridColumnDef } from '@/components/reui-kit'
|
||||
import { speakerOnlineLabel, speakerRoleRu } from '@/lib/ui-labels'
|
||||
import { observationHint, speakerAgentKnown, speakerAgentOnline, speakerBgpCounts } from '@/lib/live-observation'
|
||||
import { speakerRoleRu } from '@/lib/ui-labels'
|
||||
import type { SpeakerRow } from '@/types/api'
|
||||
|
||||
export const speakerColumns: DataGridColumnDef<SpeakerRow>[] = [
|
||||
@@ -32,20 +32,7 @@ export const speakerColumns: DataGridColumnDef<SpeakerRow>[] = [
|
||||
id: 'agent',
|
||||
enableSorting: false,
|
||||
header: 'Агент',
|
||||
cell: ({ row }) => {
|
||||
const live = row.original.live
|
||||
if (live?.agent_ok === true) {
|
||||
return <StatusBadge status="ok" label={speakerOnlineLabel(true)} />
|
||||
}
|
||||
if (live?.agent_ok === false) {
|
||||
return <StatusBadge status="error" label={speakerOnlineLabel(false)} />
|
||||
}
|
||||
return (
|
||||
<Badge variant="outline" size="sm" radius="full">
|
||||
—
|
||||
</Badge>
|
||||
)
|
||||
},
|
||||
cell: ({ row }) => <AgentObservationBadge speaker={row.original} />,
|
||||
meta: { headerTitle: 'Агент' },
|
||||
},
|
||||
{
|
||||
@@ -53,12 +40,16 @@ export const speakerColumns: DataGridColumnDef<SpeakerRow>[] = [
|
||||
enableSorting: false,
|
||||
header: 'BGP',
|
||||
cell: ({ row }) => {
|
||||
const live = row.original.live
|
||||
if (!live) return '—'
|
||||
const count = speakerBgpCounts(row.original)
|
||||
return (
|
||||
<DataGridMonoCell className="text-xs">
|
||||
{live.bgp_established ?? 0} / {live.bgp_sessions_total ?? 0}
|
||||
</DataGridMonoCell>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<DataGridMonoCell className="text-xs">{count ?? '—'}</DataGridMonoCell>
|
||||
{row.original.live?.bgp_observation ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{observationHint(row.original.live.bgp_observation)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: { headerTitle: 'BGP' },
|
||||
@@ -77,7 +68,7 @@ export function getSpeakerFilterFieldValue(item: SpeakerRow, field: string): unk
|
||||
}
|
||||
|
||||
export function speakerTabFilter(item: SpeakerRow, tabId: string): boolean {
|
||||
if (tabId === 'online') return item.live?.agent_ok === true
|
||||
if (tabId === 'offline') return item.live?.agent_ok !== true
|
||||
if (tabId === 'online') return speakerAgentOnline(item)
|
||||
if (tabId === 'offline') return speakerAgentKnown(item) && !speakerAgentOnline(item)
|
||||
return true
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
TimelineSeparator,
|
||||
TimelineTitle,
|
||||
} from '@/components/reui/timeline'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { jobStatusRu, jobTriggerRu } from '@/lib/ui-labels'
|
||||
import type { JobRow } from '@/types/api'
|
||||
|
||||
@@ -30,11 +31,46 @@ function resultSubtitle(job: JobRow): string | undefined {
|
||||
return undefined
|
||||
}
|
||||
|
||||
function applyCounts(job: JobRow) {
|
||||
const value = job.meta?.apply_summary
|
||||
if (!value || typeof value !== 'object') return undefined
|
||||
const summary = value as Record<string, unknown>
|
||||
const count = (key: string) => typeof summary[key] === 'number' ? summary[key] as number : undefined
|
||||
return {
|
||||
published: count('published_count'),
|
||||
applied: count('applied_count'),
|
||||
unconfirmed: count('unconfirmed_count'),
|
||||
failed: count('failed_count'),
|
||||
}
|
||||
}
|
||||
|
||||
function dispatchConfirmations(job: JobRow): string[] {
|
||||
const dispatch = job.meta?.node_dispatch
|
||||
if (!dispatch || typeof dispatch !== 'object') return []
|
||||
const results = (dispatch as Record<string, unknown>).results
|
||||
if (!Array.isArray(results)) return []
|
||||
return results.flatMap((item) => {
|
||||
if (!item || typeof item !== 'object') return []
|
||||
const result = item as Record<string, unknown>
|
||||
const speaker = typeof result.speaker_id === 'string' ? result.speaker_id.slice(0, 8) : 'Спикер'
|
||||
const label = result.confirmation === 'agent_reported'
|
||||
? 'агент подтвердил'
|
||||
: result.confirmation === 'local_applied'
|
||||
? 'применено локально'
|
||||
: result.confirmation === 'unconfirmed'
|
||||
? 'без подтверждения агента'
|
||||
: 'подтверждение не указано'
|
||||
return [`${speaker}: ${label}`]
|
||||
})
|
||||
}
|
||||
|
||||
export function JobTimeline({ job }: { job: JobRow }) {
|
||||
const trigger = jobTriggerRu(job.meta?.trigger)
|
||||
const completed = job.finished_at ? 3 : job.started_at ? 2 : 1
|
||||
const failed = ['failed', 'error'].includes(job.status.toLowerCase())
|
||||
const subtitle = resultSubtitle(job)
|
||||
const counts = applyCounts(job)
|
||||
const confirmations = dispatchConfirmations(job)
|
||||
|
||||
return (
|
||||
<div className="py-4 ps-3 pe-2">
|
||||
@@ -65,7 +101,26 @@ export function JobTimeline({ job }: { job: JobRow }) {
|
||||
</TimelineHeader>
|
||||
<TimelineIndicator />
|
||||
<TimelineSeparator />
|
||||
{subtitle ? <TimelineContent>{subtitle}</TimelineContent> : null}
|
||||
{subtitle || counts || confirmations.length > 0 ? (
|
||||
<TimelineContent>
|
||||
<div className="flex flex-col gap-2">
|
||||
{subtitle ? <span>{subtitle}</span> : null}
|
||||
{counts ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{counts.published != null ? <Badge variant="info-light" size="sm">Опубликовано: {counts.published}</Badge> : null}
|
||||
{counts.applied != null ? <Badge variant="success-light" size="sm">Подтверждено: {counts.applied}</Badge> : null}
|
||||
{counts.unconfirmed != null && counts.unconfirmed > 0 ? <Badge variant="warning-light" size="sm">Без подтверждения агента: {counts.unconfirmed}</Badge> : null}
|
||||
{counts.failed != null && counts.failed > 0 ? <Badge variant="destructive-light" size="sm">Ошибок: {counts.failed}</Badge> : null}
|
||||
</div>
|
||||
) : null}
|
||||
{confirmations.length > 0 ? (
|
||||
<ul className="flex flex-col gap-1 text-xs text-muted-foreground">
|
||||
{confirmations.map((confirmation, index) => <li key={`${index}-${confirmation}`}>{confirmation}</li>)}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
</TimelineContent>
|
||||
) : null}
|
||||
</TimelineItem>
|
||||
</Timeline>
|
||||
</div>
|
||||
|
||||
@@ -2,22 +2,19 @@ import type { ReactNode } from 'react'
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: string
|
||||
description?: string
|
||||
description?: ReactNode
|
||||
actions?: ReactNode
|
||||
}
|
||||
|
||||
/** Заголовок страницы как на досках макета: название 20px, описание 14px muted, действия справа. */
|
||||
export function PageHeader({ title, description, actions }: PageHeaderProps) {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-2 md:flex-row md:items-start md:justify-between">
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
|
||||
{description ? (
|
||||
<p className="text-muted-foreground max-w-prose text-sm break-words">{description}</p>
|
||||
) : null}
|
||||
<header className="flex min-w-0 flex-wrap items-end justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<h1 className="text-xl font-semibold tracking-[-0.01em]">{title}</h1>
|
||||
{description ? <p className="text-muted-foreground text-sm text-pretty">{description}</p> : null}
|
||||
</div>
|
||||
{actions ? (
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">{actions}</div>
|
||||
) : null}
|
||||
</div>
|
||||
{actions ? <div className="flex shrink-0 flex-wrap items-center justify-end gap-2">{actions}</div> : null}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -382,7 +382,7 @@ export function CodeBlock({ label, children }: { label: string; children: string
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-muted-foreground text-xs">{label}</span>
|
||||
<pre className="bg-muted/40 overflow-x-auto rounded-md border p-3 font-mono text-xs leading-relaxed">{children}</pre>
|
||||
<pre className="bg-muted/40 overflow-x-auto [scrollbar-width:none] rounded-md border p-3 font-mono text-xs leading-relaxed">{children}</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -401,7 +401,7 @@ export function Segmented({
|
||||
}) {
|
||||
return (
|
||||
<Tabs value={String(value)} onValueChange={(v) => onChange(Number(v))} aria-label={label} className="max-w-full">
|
||||
<TabsList className="max-w-full justify-start overflow-x-auto">
|
||||
<TabsList className="max-w-full justify-start overflow-x-auto [scrollbar-width:none]">
|
||||
{options.map((o, i) => (
|
||||
<TabsTrigger key={o} value={String(i)}>
|
||||
{o}
|
||||
@@ -424,7 +424,7 @@ export function PendingChip({ mark }: { mark: 'new' | 'dirty' | 'deleted' }) {
|
||||
/** Вкладки страницы как на досках: линия-подчёркивание, число — мелким индексом. */
|
||||
export function PageTabsList({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<TabsList variant="line" className="h-10! w-full justify-start gap-0.5 overflow-x-auto">
|
||||
<TabsList variant="line" className="h-10! w-full justify-start gap-0.5 overflow-x-auto [scrollbar-width:none]">
|
||||
{children}
|
||||
</TabsList>
|
||||
)
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import { Columns3Icon } from 'lucide-react'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import { Separator } from '@evobgp/ui/components/separator'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
import {
|
||||
DataGrid,
|
||||
@@ -40,6 +41,7 @@ import { DataGridTableVirtual } from '@/components/reui/data-grid/data-grid-tabl
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameFooter,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
@@ -315,7 +317,13 @@ function FrameDataGridBody<TData extends object>({
|
||||
)}
|
||||
{showPagination ? (
|
||||
<>
|
||||
<DataGridPagination className="px-5 py-3" sizes={[10, 25, 50]} />
|
||||
<Separator />
|
||||
<FrameFooter className="flex-wrap justify-between gap-2">
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
Показано {table.getRowModel().rows.length} из {data.length}
|
||||
</span>
|
||||
<DataGridPagination sizes={[10, 25, 50]} />
|
||||
</FrameFooter>
|
||||
</>
|
||||
) : null}
|
||||
</DataGrid>
|
||||
|
||||
@@ -23,12 +23,12 @@ import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll
|
||||
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
|
||||
import { DataGridTableRowExpand } from '@/components/reui/data-grid/data-grid-table'
|
||||
import { Filters } from '@/components/reui/filters/filters'
|
||||
import { SegmentedControl } from '@/components/portal/data-page'
|
||||
import { flattenFilterConditions } from '@/components/reui/filters/filters-query'
|
||||
import type { FilterField, FilterQuery } from '@/components/reui/filters/filters-types'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameFooter,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
@@ -40,7 +40,9 @@ import {
|
||||
InputGroupAddon,
|
||||
InputGroupInput,
|
||||
} from '@evobgp/ui/components/input-group'
|
||||
import { Separator } from '@evobgp/ui/components/separator'
|
||||
import { Skeleton } from '@evobgp/ui/components/skeleton'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
|
||||
import { DATA_GRID_I18N_RU } from '@/lib/reui-i18n-ru'
|
||||
import { FILTERS_LABELS_RU, FILTERS_OPERATOR_LABELS_RU } from '@/lib/filters-i18n'
|
||||
import {
|
||||
@@ -538,92 +540,75 @@ function ResourcePageFiltered<T extends object>({
|
||||
tableClassNames={kitDataGridTableClassNames}
|
||||
>
|
||||
<Frame dense variant="default" spacing="sm" className="w-full">
|
||||
{!hideHeader ? (
|
||||
<FrameHeader className="flex-row items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-col gap-px">
|
||||
<FrameHeader className="flex-row flex-wrap items-center gap-x-3 gap-y-2">
|
||||
{!hideHeader ? (
|
||||
<div className="flex min-w-0 flex-1 basis-52 flex-col gap-0.5">
|
||||
<FrameTitle className="text-balance">{title}</FrameTitle>
|
||||
{description ? (
|
||||
<FrameDescription className="flex flex-wrap items-center gap-1.5 text-xs text-pretty">
|
||||
<span>{description}</span>
|
||||
<span className="bg-input size-1 shrink-0 rounded-full" aria-hidden="true" />
|
||||
<span className="tabular-nums">{filteredData.length} записей</span>
|
||||
{selectedCount > 0 ? (
|
||||
<>
|
||||
<span className="bg-input size-1 shrink-0 rounded-full" aria-hidden="true" />
|
||||
<span>{selectedCount} выбрано</span>
|
||||
</>
|
||||
) : null}
|
||||
</FrameDescription>
|
||||
<FrameDescription className="text-xs text-pretty">{description}</FrameDescription>
|
||||
) : null}
|
||||
</div>
|
||||
{headerActions ? (
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||||
{headerActions}
|
||||
</div>
|
||||
) : null}
|
||||
</FrameHeader>
|
||||
) : null}
|
||||
) : (
|
||||
<div className="flex-1" />
|
||||
)}
|
||||
{headerActions ? <div className="flex shrink-0 flex-wrap items-center justify-end gap-2">{headerActions}</div> : null}
|
||||
{toolbarExtra}
|
||||
{showFiltersPopover ? (
|
||||
<Filters
|
||||
query={extraQuery}
|
||||
fields={extraFilterFields}
|
||||
onQueryChange={handleFiltersChange}
|
||||
variant="advanced"
|
||||
advancedMode="popover"
|
||||
size="default"
|
||||
labels={FILTERS_LABELS_RU}
|
||||
operatorLabels={FILTERS_OPERATOR_LABELS_RU}
|
||||
trigger={
|
||||
<Button type="button" variant="outline" aria-label="Фильтры">
|
||||
<FilterIcon className="size-4" aria-hidden="true" />
|
||||
Фильтры
|
||||
{extraActiveCount > 0 ? (
|
||||
<Badge size="xs" variant="secondary" radius="full">
|
||||
{extraActiveCount}
|
||||
</Badge>
|
||||
) : null}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{showSearch && primaryTextField ? (
|
||||
<InputGroup className="w-[220px] max-w-full">
|
||||
<InputGroupAddon>
|
||||
<SearchIcon aria-hidden="true" />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
value={searchText}
|
||||
onChange={(event) => handleSearchChange(event.target.value)}
|
||||
placeholder="Поиск"
|
||||
aria-label={primaryTextField.label || 'Поиск'}
|
||||
/>
|
||||
</InputGroup>
|
||||
) : null}
|
||||
{hasDirtyFilters ? (
|
||||
<Button type="button" variant="outline" onClick={handleClear}>
|
||||
<FilterXIcon className="size-4" aria-hidden="true" />
|
||||
Сбросить
|
||||
</Button>
|
||||
) : null}
|
||||
</FrameHeader>
|
||||
|
||||
<FramePanel className="p-0 shadow-none!">
|
||||
<div className="flex flex-wrap items-center gap-3 border-b px-5 py-3">
|
||||
{countedTabs.length > 0 ? (
|
||||
<SegmentedControl
|
||||
value={activeTab}
|
||||
onChange={handleTabChange}
|
||||
label="Фильтр по статусу"
|
||||
options={countedTabs.map((t) => ({ value: t.id, label: t.label, count: t.count }))}
|
||||
/>
|
||||
) : null}
|
||||
{showFiltersPopover ? (
|
||||
<Filters
|
||||
query={extraQuery}
|
||||
fields={extraFilterFields}
|
||||
onQueryChange={handleFiltersChange}
|
||||
variant="advanced"
|
||||
advancedMode="popover"
|
||||
size="default"
|
||||
labels={FILTERS_LABELS_RU}
|
||||
operatorLabels={FILTERS_OPERATOR_LABELS_RU}
|
||||
trigger={
|
||||
<Button type="button" variant="outline" aria-label="Фильтры">
|
||||
<FilterIcon className="size-4" aria-hidden="true" />
|
||||
Фильтры
|
||||
{extraActiveCount > 0 ? (
|
||||
<Badge size="xs" variant="secondary" radius="full">
|
||||
{extraActiveCount}
|
||||
</Badge>
|
||||
) : null}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{showSearch && primaryTextField ? (
|
||||
<InputGroup className="max-w-sm min-w-[220px]">
|
||||
<InputGroupAddon>
|
||||
<SearchIcon className="size-3.5" aria-hidden="true" />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
value={searchText}
|
||||
onChange={(event) => handleSearchChange(event.target.value)}
|
||||
placeholder={primaryTextField.placeholder ?? 'Поиск…'}
|
||||
aria-label={primaryTextField.label || 'Поиск'}
|
||||
/>
|
||||
</InputGroup>
|
||||
) : null}
|
||||
<span className="text-muted-foreground ml-auto text-sm tabular-nums">{filteredData.length} записей</span>
|
||||
{toolbarExtra}
|
||||
{selectedCount > 0 ? (
|
||||
<Badge size="sm" variant="secondary">
|
||||
{selectedCount} выбрано
|
||||
</Badge>
|
||||
) : null}
|
||||
{hasDirtyFilters ? (
|
||||
<Button type="button" variant="outline" onClick={handleClear}>
|
||||
<FilterXIcon className="size-4" aria-hidden="true" />
|
||||
Сбросить
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{countedTabs.length > 0 ? (
|
||||
<Tabs value={activeTab} onValueChange={handleTabChange}>
|
||||
<TabsList variant="line" aria-label="Сохранённые виды" className="h-10! w-full justify-start gap-4 overflow-x-auto [scrollbar-width:none] px-4">
|
||||
{countedTabs.map((t) => (
|
||||
<TabsTrigger key={t.id} value={t.id} className="flex-none px-1 text-[13px]">
|
||||
{t.label} · {t.count}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
) : null}
|
||||
|
||||
{virtualization ? (
|
||||
<DataGridScrollArea orientation="vertical" style={{ height }}>
|
||||
@@ -633,7 +618,14 @@ function ResourcePageFiltered<T extends object>({
|
||||
<DataGridScrollArea>{tableNode}</DataGridScrollArea>
|
||||
)}
|
||||
|
||||
<DataGridPagination className="px-5 py-3" sizes={[5, 10, 20, 50]} />
|
||||
<Separator />
|
||||
|
||||
<FrameFooter className="flex-wrap justify-between gap-2">
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
{selectedCount > 0 ? `Выбрано ${selectedCount} · ` : ''}Показано {table.getRowModel().rows.length} из {filteredData.length}
|
||||
</span>
|
||||
<DataGridPagination sizes={[5, 10, 20, 50]} />
|
||||
</FrameFooter>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
</DataGrid>
|
||||
|
||||
@@ -46,7 +46,7 @@ export function SegmentedTabs({
|
||||
orientation="horizontal"
|
||||
className={cn('w-full min-w-0 gap-4', className)}
|
||||
>
|
||||
<TabsList variant="line" className={cn('h-10! w-full min-w-0 justify-start gap-0.5 overflow-x-auto', listClassName)}>
|
||||
<TabsList variant="line" className={cn('h-10! w-full min-w-0 justify-start gap-0.5 overflow-x-auto [scrollbar-width:none]', listClassName)}>
|
||||
{items.map((item) => (
|
||||
<TabsTrigger key={item.value} value={item.value} className="flex-none shrink-0 gap-1.5 px-4 text-sm">
|
||||
{item.icon}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { act } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { DashboardNetworkHealth } from '@/components/dashboard/dashboard-network-health'
|
||||
import { useLiveObservationState } from '@/hooks/use-live-observation-state'
|
||||
import { peersForDisplay, speakerAgentKnown, speakersForDisplay } from '@/lib/live-observation'
|
||||
import type { LiveObservation, PeerRow, SpeakerRow } from '@/types/api'
|
||||
|
||||
const deadline = '2026-10-26T10:00:10.000Z'
|
||||
|
||||
function observation(quality: LiveObservation['quality'], errorCode?: LiveObservation['error_code']): LiveObservation {
|
||||
return {
|
||||
observed_at: '2026-10-26T10:00:00.000Z',
|
||||
received_at: '2026-10-26T10:00:00.000Z',
|
||||
last_success_at: '2026-10-26T10:00:00.000Z',
|
||||
expires_at: deadline,
|
||||
source: 'agent_bird_protocols',
|
||||
quality,
|
||||
error_code: errorCode,
|
||||
}
|
||||
}
|
||||
|
||||
const peer = { id: 'peer-1', enabled: true, session_state: 'Established', live_observation: observation('ok') } as PeerRow
|
||||
const unhealthySpeaker = { id: 'speaker-1', live: { agent_ok: false, agent_observation: observation('stale', 'reported_unhealthy') } } as SpeakerRow
|
||||
|
||||
function NetworkHarness() {
|
||||
useLiveObservationState([peer], [])
|
||||
// New array identities let memoized chart bars recompute at the deadline.
|
||||
return <DashboardNetworkHealth peers={[...peersForDisplay([peer], false)]} speakers={[]} jobs={[]} />
|
||||
}
|
||||
|
||||
function SpeakerHarness() {
|
||||
useLiveObservationState([], [unhealthySpeaker])
|
||||
return <span>{speakerAgentKnown(speakersForDisplay([unhealthySpeaker], false)[0]) ? 'known' : 'unknown'}</span>
|
||||
}
|
||||
|
||||
describe('live observation deadline rendering', () => {
|
||||
beforeEach(() => vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true))
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('rerenders memoized network bars when a fresh peer snapshot expires', async () => {
|
||||
vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] })
|
||||
vi.setSystemTime(new Date('2026-10-26T10:00:00.000Z'))
|
||||
const host = document.createElement('div')
|
||||
document.body.appendChild(host)
|
||||
const root = createRoot(host)
|
||||
try {
|
||||
await act(async () => root.render(<NetworkHarness />))
|
||||
expect(host.textContent).toContain('100%')
|
||||
await act(async () => vi.advanceTimersByTime(10_001))
|
||||
expect(host.textContent).toContain('0%')
|
||||
expect(host.textContent).toContain('нет данных')
|
||||
} finally {
|
||||
await act(async () => root.unmount())
|
||||
host.remove()
|
||||
}
|
||||
})
|
||||
|
||||
it('rerenders a reported unhealthy speaker at its deadline', async () => {
|
||||
vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] })
|
||||
vi.setSystemTime(new Date('2026-10-26T10:00:00.000Z'))
|
||||
const host = document.createElement('div')
|
||||
document.body.appendChild(host)
|
||||
const root = createRoot(host)
|
||||
try {
|
||||
await act(async () => root.render(<SpeakerHarness />))
|
||||
expect(host.textContent).toBe('known')
|
||||
await act(async () => vi.advanceTimersByTime(10_001))
|
||||
expect(host.textContent).toBe('unknown')
|
||||
} finally {
|
||||
await act(async () => root.unmount())
|
||||
host.remove()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { nextObservationExpiry } from '@/lib/live-observation'
|
||||
import type { PeerRow, SpeakerRow } from '@/types/api'
|
||||
|
||||
/** Rerender at the server-provided observation deadline and on browser connectivity changes. */
|
||||
export function useLiveObservationState(peers: PeerRow[], speakers: SpeakerRow[]): { offline: boolean; disconnectedAt: number | null; revision: number } {
|
||||
const [offline, setOffline] = useState(() => typeof navigator !== 'undefined' && !navigator.onLine)
|
||||
const [disconnectedAt, setDisconnectedAt] = useState<number | null>(() =>
|
||||
typeof navigator !== 'undefined' && !navigator.onLine ? Date.now() : null,
|
||||
)
|
||||
const [clockTick, setClockTick] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const update = () => {
|
||||
const disconnected = !navigator.onLine
|
||||
setOffline(disconnected)
|
||||
if (disconnected) setDisconnectedAt(Date.now())
|
||||
}
|
||||
window.addEventListener('online', update)
|
||||
window.addEventListener('offline', update)
|
||||
return () => {
|
||||
window.removeEventListener('online', update)
|
||||
window.removeEventListener('offline', update)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const next = nextObservationExpiry(peers, speakers)
|
||||
if (next === undefined) return
|
||||
const delay = Math.min(2_147_483_647, Math.max(1, next - Date.now() + 1))
|
||||
const timer = window.setTimeout(() => setClockTick((tick) => tick + 1), delay)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [peers, speakers, clockTick])
|
||||
|
||||
return { offline, disconnectedAt, revision: clockTick }
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
peerSessionEstablished,
|
||||
peerSessionKnown,
|
||||
speakerAgentKnown,
|
||||
speakerAgentOnline,
|
||||
speakerBgpCounts,
|
||||
observationQuality,
|
||||
nextObservationExpiry,
|
||||
peersForDisplay,
|
||||
speakersForDisplay,
|
||||
snapshotUnavailable,
|
||||
} from './live-observation'
|
||||
import type { LiveObservation, PeerRow, SpeakerRow } from '@/types/api'
|
||||
import { aggregateNetworkMetrics } from '@/queries/overview'
|
||||
import { deploymentProgress } from './metrics/deployment-progress'
|
||||
import { speakerCapacityBars } from './metrics/peer-capacity-bars'
|
||||
|
||||
const observation = (quality: LiveObservation['quality']): LiveObservation => ({
|
||||
observed_at: null,
|
||||
received_at: null,
|
||||
last_success_at: null,
|
||||
source: 'agent_bird_protocols',
|
||||
quality,
|
||||
})
|
||||
|
||||
describe('live observation compatibility', () => {
|
||||
it('uses legacy live fields when observation metadata is absent', () => {
|
||||
const peer = { session_state: 'Established' } as PeerRow
|
||||
const speaker = { live: { agent_ok: true, bgp_established: 2, bgp_sessions_total: 3 } } as SpeakerRow
|
||||
expect(peerSessionEstablished(peer)).toBe(true)
|
||||
expect(speakerAgentOnline(speaker)).toBe(true)
|
||||
expect(speakerBgpCounts(speaker)).toBe('2 / 3')
|
||||
})
|
||||
|
||||
it('keeps stale and missing observations separate from down or zero', () => {
|
||||
const peer = { session_state: null, last_known_session_state: 'Established', live_observation: observation('stale') } as PeerRow
|
||||
const speaker = { live: { agent_ok: false, agent_observation: observation('unknown'), bgp_established: null, bgp_sessions_total: null, bgp_observation: observation('unreachable') } } as SpeakerRow
|
||||
expect(peerSessionKnown(peer)).toBe(false)
|
||||
expect(peerSessionEstablished(peer)).toBe(false)
|
||||
expect(speakerAgentKnown(speaker)).toBe(false)
|
||||
expect(speakerAgentOnline(speaker)).toBe(false)
|
||||
expect(speakerBgpCounts(speaker)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('counts only enabled, observed peers in established metrics', () => {
|
||||
const peers = [
|
||||
{ enabled: false, session_state: 'Established' },
|
||||
{ enabled: true, session_state: 'Established', live_observation: observation('stale') },
|
||||
{ enabled: true, session_state: 'Established', live_observation: observation('ok') },
|
||||
] as PeerRow[]
|
||||
const metrics = aggregateNetworkMetrics(peers, [])
|
||||
expect(metrics.peersObserved).toBe(1)
|
||||
expect(metrics.peersEstablished).toBe(1)
|
||||
})
|
||||
|
||||
it('includes published but unconfirmed speakers in deployment denominator', () => {
|
||||
const speakers = [
|
||||
{ published_revision_id: 'rev-1', last_applied_revision_id: 'rev-1' },
|
||||
{ published_revision_id: 'rev-1', last_applied_revision_id: null },
|
||||
] as SpeakerRow[]
|
||||
expect(deploymentProgress(speakers)).toMatchObject({ mode: 'revision', synced: 1, total: 2, percent: 50 })
|
||||
expect(deploymentProgress([speakers[1]])).toMatchObject({ mode: 'revision', synced: 0, total: 1, percent: 0 })
|
||||
})
|
||||
|
||||
it('treats explicit agent health failure as a known negative result', () => {
|
||||
const speaker = { live: {
|
||||
agent_ok: false,
|
||||
agent_observation: { ...observation('stale'), error_code: 'reported_unhealthy' },
|
||||
} } as SpeakerRow
|
||||
expect(speakerAgentKnown(speaker)).toBe(true)
|
||||
expect(speakerAgentOnline(speaker)).toBe(false)
|
||||
})
|
||||
|
||||
it('masks cached fresh status after a snapshot API failure', () => {
|
||||
const peer = { session_state: 'Established', live_observation: observation('ok'), session_mismatch: true } as PeerRow
|
||||
const speaker = { live: { agent_ok: true, agent_observation: observation('ok'), bgp_established: 2, bgp_sessions_total: 2, bgp_observation: observation('ok') } } as SpeakerRow
|
||||
expect(aggregateNetworkMetrics([peer], [speaker])).toMatchObject({ peersEstablished: 1, speakersOnline: 1 })
|
||||
|
||||
const peers = peersForDisplay([peer], true)
|
||||
const speakers = speakersForDisplay([speaker], true)
|
||||
expect(aggregateNetworkMetrics(peers, speakers)).toMatchObject({ peersEstablished: 0, peersMismatch: 0, speakersOnline: 0 })
|
||||
expect(peerSessionKnown(peers[0])).toBe(false)
|
||||
expect(speakerAgentKnown(speakers[0])).toBe(false)
|
||||
expect(speakerBgpCounts(speakers[0])).toBeUndefined()
|
||||
expect(snapshotUnavailable(true, false, null, 100)).toBe(true)
|
||||
expect(snapshotUnavailable(false, true, 200, 100)).toBe(true)
|
||||
expect(snapshotUnavailable(false, false, 200, 100)).toBe(true)
|
||||
expect(snapshotUnavailable(false, false, 200, 201)).toBe(false)
|
||||
})
|
||||
|
||||
it('expires an ok observation at the server deadline', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
vi.setSystemTime(new Date('2026-10-26T10:00:00.000Z'))
|
||||
const live = { ...observation('ok'), expires_at: '2026-10-26T10:00:10.000Z' }
|
||||
const peer = { session_state: 'Established', live_observation: live } as PeerRow
|
||||
const speaker = { id: 'speaker-1', endpoint: 'speaker.example', live: { agent_ok: true, agent_observation: live, bgp_established: 2, bgp_sessions_total: 2, bgp_observation: live } } as SpeakerRow
|
||||
expect(nextObservationExpiry([peer], [speaker])).toBe(Date.parse(live.expires_at))
|
||||
expect(peerSessionEstablished(peer)).toBe(true)
|
||||
expect(speakerAgentOnline(speaker)).toBe(true)
|
||||
expect(speakerCapacityBars([speaker])).toHaveLength(1)
|
||||
|
||||
vi.setSystemTime(new Date(live.expires_at))
|
||||
expect(observationQuality(live)).toBe('stale')
|
||||
expect(peerSessionEstablished(peer)).toBe(false)
|
||||
expect(speakerAgentOnline(speaker)).toBe(false)
|
||||
expect(speakerCapacityBars([speaker])).toHaveLength(0)
|
||||
expect(nextObservationExpiry([peer], [speaker])).toBeUndefined()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,120 @@
|
||||
import type { LiveObservation, PeerRow, SpeakerRow } from '@/types/api'
|
||||
|
||||
const QUALITY_LABEL: Record<LiveObservation['quality'], string> = {
|
||||
ok: 'актуально',
|
||||
stale: 'данные устарели',
|
||||
unreachable: 'опрос недоступен',
|
||||
unknown: 'нет наблюдения',
|
||||
disabled: 'опрос выключен',
|
||||
}
|
||||
|
||||
function expired(observation: LiveObservation, now = Date.now()): boolean {
|
||||
if (!observation.expires_at) return false
|
||||
const deadline = Date.parse(observation.expires_at)
|
||||
return !Number.isFinite(deadline) || deadline <= now
|
||||
}
|
||||
|
||||
export function observationQuality(observation: LiveObservation, now = Date.now()): LiveObservation['quality'] {
|
||||
return observation.quality === 'ok' && expired(observation, now) ? 'stale' : observation.quality
|
||||
}
|
||||
|
||||
const unavailableObservation: LiveObservation = {
|
||||
observed_at: null,
|
||||
received_at: null,
|
||||
last_success_at: null,
|
||||
source: 'none',
|
||||
quality: 'unknown',
|
||||
}
|
||||
|
||||
/** A successful GET after reconnect is required before cached live values are trusted again. */
|
||||
export function snapshotUnavailable(queryFailed: boolean, offline: boolean, disconnectedAt: number | null, dataUpdatedAt: number): boolean {
|
||||
return queryFailed || offline || (disconnectedAt !== null && dataUpdatedAt <= disconnectedAt)
|
||||
}
|
||||
|
||||
/** Hide cached live values when their snapshot request fails or the browser goes offline. */
|
||||
export function peersForDisplay(peers: PeerRow[], unavailable: boolean): PeerRow[] {
|
||||
if (!unavailable) return peers
|
||||
return peers.map((peer) => ({
|
||||
...peer,
|
||||
session_state: null,
|
||||
live_observation: unavailableObservation,
|
||||
session_on_speakers: [],
|
||||
established_on_speakers: [],
|
||||
session_mismatch: false,
|
||||
}))
|
||||
}
|
||||
|
||||
export function speakersForDisplay(speakers: SpeakerRow[], unavailable: boolean): SpeakerRow[] {
|
||||
if (!unavailable) return speakers
|
||||
return speakers.map((speaker) => ({
|
||||
...speaker,
|
||||
live: speaker.live && {
|
||||
...speaker.live,
|
||||
agent_ok: undefined,
|
||||
agent_observation: unavailableObservation,
|
||||
bgp_poll_ok: undefined,
|
||||
bgp_established: null,
|
||||
bgp_sessions_total: null,
|
||||
sessions: [],
|
||||
bgp_observation: unavailableObservation,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
/** Earliest server deadline; callers can schedule a render without inventing a client TTL. */
|
||||
export function nextObservationExpiry(peers: PeerRow[], speakers: SpeakerRow[], now = Date.now()): number | undefined {
|
||||
let next: number | undefined
|
||||
const visit = (observation?: LiveObservation) => {
|
||||
if (!observation?.expires_at || (observation.quality !== 'ok' && observation.error_code !== 'reported_unhealthy')) return
|
||||
const deadline = Date.parse(observation.expires_at)
|
||||
if (Number.isFinite(deadline) && deadline > now) next = Math.min(next ?? deadline, deadline)
|
||||
}
|
||||
for (const peer of peers) visit(peer.live_observation)
|
||||
for (const speaker of speakers) {
|
||||
visit(speaker.live?.agent_observation)
|
||||
visit(speaker.live?.bgp_observation)
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
function observedTime(observation: LiveObservation): string | undefined {
|
||||
const timestamp = observation.observed_at ?? observation.received_at ?? observation.last_success_at
|
||||
if (!timestamp) return undefined
|
||||
const date = new Date(timestamp)
|
||||
return Number.isNaN(date.getTime()) ? undefined : date.toLocaleString('ru-RU')
|
||||
}
|
||||
|
||||
export function observationHint(observation?: LiveObservation): string | undefined {
|
||||
if (!observation) return undefined
|
||||
const at = observedTime(observation)
|
||||
const label = QUALITY_LABEL[observationQuality(observation)]
|
||||
return at ? `${label} · ${at}` : label
|
||||
}
|
||||
|
||||
export function peerSessionCurrent(peer: PeerRow): boolean {
|
||||
return !peer.live_observation || observationQuality(peer.live_observation) === 'ok'
|
||||
}
|
||||
|
||||
export function peerSessionEstablished(peer: PeerRow): boolean {
|
||||
return peerSessionCurrent(peer) && peer.session_state === 'Established'
|
||||
}
|
||||
|
||||
export function peerSessionKnown(peer: PeerRow): boolean {
|
||||
return peerSessionCurrent(peer) && peer.session_state != null && peer.session_state !== ''
|
||||
}
|
||||
|
||||
export function speakerAgentKnown(speaker: SpeakerRow): boolean {
|
||||
const observation = speaker.live?.agent_observation
|
||||
return (!observation || (!expired(observation) && (observationQuality(observation) === 'ok' || observation.error_code === 'reported_unhealthy'))) && speaker.live?.agent_ok != null
|
||||
}
|
||||
|
||||
export function speakerAgentOnline(speaker: SpeakerRow): boolean {
|
||||
return speakerAgentKnown(speaker) && speaker.live?.agent_ok === true
|
||||
}
|
||||
|
||||
export function speakerBgpCounts(speaker: SpeakerRow): string | undefined {
|
||||
const live = speaker.live
|
||||
if (!live || (live.bgp_observation && observationQuality(live.bgp_observation) !== 'ok')) return undefined
|
||||
if (live.bgp_established == null || live.bgp_sessions_total == null) return undefined
|
||||
return `${live.bgp_established} / ${live.bgp_sessions_total}`
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { SpeakerRow } from '@/types/api'
|
||||
import { speakerAgentOnline } from '@/lib/live-observation'
|
||||
|
||||
import type { DeploymentProgress } from './types'
|
||||
|
||||
@@ -7,9 +8,7 @@ export function deploymentProgress(speakers: SpeakerRow[]): DeploymentProgress {
|
||||
return { percent: 0, synced: 0, total: 0, mode: 'online' }
|
||||
}
|
||||
|
||||
const withRevision = speakers.filter(
|
||||
(s) => s.published_revision_id && s.last_applied_revision_id,
|
||||
)
|
||||
const withRevision = speakers.filter((s) => s.published_revision_id)
|
||||
if (withRevision.length > 0) {
|
||||
const synced = withRevision.filter(
|
||||
(s) => s.published_revision_id === s.last_applied_revision_id,
|
||||
@@ -22,7 +21,7 @@ export function deploymentProgress(speakers: SpeakerRow[]): DeploymentProgress {
|
||||
}
|
||||
}
|
||||
|
||||
const online = speakers.filter((s) => s.live?.agent_ok).length
|
||||
const online = speakers.filter(speakerAgentOnline).length
|
||||
return {
|
||||
percent: Math.round((online / speakers.length) * 100),
|
||||
synced: online,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { PeerRow, SpeakerRow } from '@/types/api'
|
||||
import { observationQuality, peerSessionEstablished, peerSessionKnown } from '@/lib/live-observation'
|
||||
|
||||
import type { CapacityBar } from './types'
|
||||
|
||||
@@ -12,25 +13,27 @@ function speakerLabel(speaker: SpeakerRow): string {
|
||||
|
||||
export function peerCapacityBars(peers: PeerRow[], max = 24): CapacityBar[] {
|
||||
return peers
|
||||
.filter((p) => p.enabled !== false)
|
||||
.filter((p) => p.enabled !== false && peerSessionKnown(p))
|
||||
.slice(0, max)
|
||||
.map((peer) => ({
|
||||
id: peer.id,
|
||||
name: peerLabel(peer),
|
||||
value: peer.session_state === 'Established' ? 100 : peer.session_state ? 40 : 10,
|
||||
value: peerSessionEstablished(peer) ? 100 : 40,
|
||||
}))
|
||||
}
|
||||
|
||||
export function speakerCapacityBars(speakers: SpeakerRow[], max = 24): CapacityBar[] {
|
||||
return speakers.slice(0, max).map((speaker) => {
|
||||
const online = speaker.live?.agent_ok === true
|
||||
return speakers.filter((speaker) =>
|
||||
(!speaker.live?.bgp_observation || observationQuality(speaker.live.bgp_observation) === 'ok') &&
|
||||
speaker.live?.bgp_established != null && speaker.live.bgp_sessions_total != null,
|
||||
).slice(0, max).map((speaker) => {
|
||||
const established = speaker.live?.bgp_established ?? 0
|
||||
const total = speaker.live?.bgp_sessions_total ?? 0
|
||||
const ratio = total > 0 ? Math.round((established / total) * 100) : online ? 100 : 15
|
||||
const ratio = total > 0 ? Math.round((established / total) * 100) : 0
|
||||
return {
|
||||
id: speaker.id,
|
||||
name: speakerLabel(speaker),
|
||||
value: online ? ratio : 10,
|
||||
value: ratio,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import type { PeerRow } from '@/types/api'
|
||||
import { peerSessionEstablished, peerSessionKnown } from '@/lib/live-observation'
|
||||
|
||||
import type { BreakdownSlice } from './types'
|
||||
|
||||
export function peerSessionBreakdown(peers: PeerRow[]): BreakdownSlice[] {
|
||||
const enabled = peers.filter((p) => p.enabled !== false)
|
||||
const established = enabled.filter((p) => p.session_state === 'Established').length
|
||||
const established = enabled.filter(peerSessionEstablished).length
|
||||
const pending = enabled.filter(
|
||||
(p) => p.session_state && p.session_state !== 'Established',
|
||||
(p) => peerSessionKnown(p) && !peerSessionEstablished(p),
|
||||
).length
|
||||
const unknown = enabled.length - established - pending
|
||||
const disabled = peers.length - enabled.length
|
||||
|
||||
const slices: BreakdownSlice[] = []
|
||||
@@ -27,6 +29,14 @@ export function peerSessionBreakdown(peers: PeerRow[]): BreakdownSlice[] {
|
||||
color: 'var(--color-warning)',
|
||||
})
|
||||
}
|
||||
if (unknown > 0) {
|
||||
slices.push({
|
||||
key: 'unknown',
|
||||
label: 'Нет наблюдения',
|
||||
count: unknown,
|
||||
color: 'var(--color-muted-foreground)',
|
||||
})
|
||||
}
|
||||
if (disabled > 0) {
|
||||
slices.push({
|
||||
key: 'disabled',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { JobRow, PeerRow, RevisionRow, SpeakerRow } from '@/types/api'
|
||||
|
||||
import { jobKindRu, jobStatusRu } from '@/lib/ui-labels'
|
||||
import { speakerAgentKnown } from '@/lib/live-observation'
|
||||
|
||||
import type { PlatformActivityItem } from './types'
|
||||
|
||||
@@ -45,11 +46,11 @@ export function recentPlatformActivity(
|
||||
})
|
||||
}
|
||||
|
||||
for (const speaker of speakers.filter((s) => s.live?.bgp_poll_error || s.live?.agent_ok === false).slice(0, 2)) {
|
||||
for (const speaker of speakers.filter((s) => s.live?.bgp_poll_error || (speakerAgentKnown(s) && s.live?.agent_ok === false)).slice(0, 2)) {
|
||||
items.push({
|
||||
id: `speaker-${speaker.id}`,
|
||||
message: `Нода недоступна: ${speaker.live?.label ?? speaker.endpoint}`,
|
||||
status: speaker.live?.agent_ok === false ? 'error' : 'warning',
|
||||
message: `Проблема опроса ноды: ${speaker.live?.label ?? speaker.endpoint}`,
|
||||
status: speakerAgentKnown(speaker) && speaker.live?.agent_ok === false ? 'error' : 'warning',
|
||||
kind: 'network',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -21,12 +21,20 @@ html[data-density='compact'] [data-slot='sidebar-inset'] > div:last-child {
|
||||
}
|
||||
|
||||
/* Видимый фокус и reduced motion — как в макете MikrotikManager v2. */
|
||||
html [data-slot='sidebar-wrapper'] :focus-visible,
|
||||
html #main-content :focus-visible {
|
||||
html [data-slot='sidebar-wrapper'] :focus-visible:not(input, textarea, select, [data-slot='input-group-control']),
|
||||
html #main-content :focus-visible:not(input, textarea, select, [data-slot='input-group-control']) {
|
||||
outline: 2px solid var(--ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Поля ввода: спокойная подсветка фокуса — тонкая рамка и мягкое кольцо вместо яркого синего контура. */
|
||||
html input:focus-visible,
|
||||
html textarea:focus-visible,
|
||||
html [data-slot='input-group']:has([data-slot='input-group-control']:focus-visible) {
|
||||
border-color: color-mix(in oklch, var(--ring) 45%, var(--input));
|
||||
--tw-ring-color: color-mix(in oklch, var(--ring) 18%, transparent);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html #main-content *,
|
||||
html [data-slot='sidebar-wrapper'] * {
|
||||
|
||||
@@ -2,6 +2,8 @@ import { queryOptions, useMutation, useQueryClient } from '@tanstack/react-query
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { apiJSON, apiMutate } from '@/lib/api-client'
|
||||
import { overviewKeys } from '@/queries/overview'
|
||||
import type { operations } from '@/types/api.gen'
|
||||
import type {
|
||||
BgpPeerCreate,
|
||||
BgpPeerPatch,
|
||||
@@ -27,6 +29,33 @@ export const networkKeys = {
|
||||
bird: () => [...networkKeys.all, 'bird'] as const,
|
||||
}
|
||||
|
||||
type TelemetryRefreshResponse = operations['requestTelemetryRefresh']['responses'][202]['content']['application/json']
|
||||
|
||||
/** Request a background poll; GET queries continue reading the latest saved snapshots. */
|
||||
export function useRequestTelemetryRefreshMutation() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: () =>
|
||||
apiMutate<TelemetryRefreshResponse>('/v1/telemetry/refresh', 'POST', undefined, {
|
||||
idempotent: false,
|
||||
}),
|
||||
onSuccess: ({ requested }) => {
|
||||
if (requested) {
|
||||
toast.success('Запрос на обновление телеметрии принят. Данные появятся после фонового опроса.')
|
||||
} else {
|
||||
toast.message('Запрос объединён с недавним обновлением или нет доступных целей.')
|
||||
}
|
||||
void qc.invalidateQueries({ queryKey: networkKeys.peers() })
|
||||
void qc.invalidateQueries({ queryKey: networkKeys.discovered() })
|
||||
void qc.invalidateQueries({ queryKey: networkKeys.speakers() })
|
||||
void qc.invalidateQueries({ queryKey: overviewKeys.peers() })
|
||||
void qc.invalidateQueries({ queryKey: overviewKeys.speakers() })
|
||||
},
|
||||
onError: (e) =>
|
||||
toast.error(e instanceof Error ? e.message : 'Не удалось запросить обновление телеметрии'),
|
||||
})
|
||||
}
|
||||
|
||||
export function networkPeersQueryOptions() {
|
||||
return queryOptions<PeersResponse>({
|
||||
queryKey: networkKeys.peers(),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { queryOptions } from '@tanstack/react-query'
|
||||
import { apiJSON } from '@/lib/api-client'
|
||||
import { peerSessionCurrent, peerSessionEstablished, peerSessionKnown, speakerAgentKnown, speakerAgentOnline } from '@/lib/live-observation'
|
||||
import type {
|
||||
JobRow,
|
||||
JobsResponse,
|
||||
@@ -78,9 +79,11 @@ export function overviewHealthQueryOptions() {
|
||||
export type NetworkMetrics = {
|
||||
peersTotal: number
|
||||
peersEnabled: number
|
||||
peersObserved: number
|
||||
peersEstablished: number
|
||||
peersMismatch: number
|
||||
speakersTotal: number
|
||||
speakersObserved: number
|
||||
speakersOnline: number
|
||||
}
|
||||
|
||||
@@ -89,15 +92,19 @@ export function aggregateNetworkMetrics(
|
||||
speakers: SpeakerRow[],
|
||||
): NetworkMetrics {
|
||||
const peersEnabled = peers.filter((p) => p.enabled !== false).length
|
||||
const peersEstablished = peers.filter((p) => p.session_state === 'Established').length
|
||||
const peersMismatch = peers.filter((p) => p.session_mismatch).length
|
||||
const speakersOnline = speakers.filter((s) => s.live?.agent_ok).length
|
||||
const peersObserved = peers.filter((p) => p.enabled !== false && peerSessionKnown(p)).length
|
||||
const peersEstablished = peers.filter((p) => p.enabled !== false && peerSessionEstablished(p)).length
|
||||
const peersMismatch = peers.filter((p) => p.session_mismatch && peerSessionCurrent(p)).length
|
||||
const speakersOnline = speakers.filter(speakerAgentOnline).length
|
||||
const speakersObserved = speakers.filter(speakerAgentKnown).length
|
||||
return {
|
||||
peersTotal: peers.length,
|
||||
peersEnabled,
|
||||
peersObserved,
|
||||
peersEstablished,
|
||||
peersMismatch,
|
||||
speakersTotal: speakers.length,
|
||||
speakersObserved,
|
||||
speakersOnline,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,7 +109,7 @@ function AccessComponent() {
|
||||
description="API-ключи плоскости управления и текущая сессия."
|
||||
actions={
|
||||
canManageKeys ? (
|
||||
<Button variant="outline" size="sm" onClick={refetchAll} disabled={refreshing}>
|
||||
<Button variant="outline" onClick={refetchAll} disabled={refreshing}>
|
||||
<RefreshCw className={refreshing ? 'animate-spin' : ''} />
|
||||
Обновить
|
||||
</Button>
|
||||
|
||||
@@ -1,20 +1,30 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useQueries, useQuery } from '@tanstack/react-query'
|
||||
import { RefreshCw } from 'lucide-react'
|
||||
import { Boxes, Play, RefreshCw, Search, Share2 } from 'lucide-react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
|
||||
import { buildDashboardKpiCards } from '@/components/dashboard/dashboard-kpi-grid'
|
||||
import { DashboardNetworkHealth } from '@/components/dashboard/dashboard-network-health'
|
||||
import { DashboardOperationsBreakdown } from '@/components/dashboard/dashboard-operations-breakdown'
|
||||
import { DashboardQuickLinks } from '@/components/dashboard/dashboard-quick-links'
|
||||
import { OpsDashboard } from '@/components/reui-kit'
|
||||
import { chartPanelGridClassName } from '@/lib/ui-surface'
|
||||
import { DashboardPrefixTrend } from '@/components/dashboard/dashboard-prefix-trend'
|
||||
import { ModulesListGrid } from '@/components/modules/modules-list-grid'
|
||||
import { ActivityFeed } from '@/components/portal/canvas-blocks'
|
||||
import { fmtWhen } from '@/components/portal/format'
|
||||
import type { Tone } from '@/components/portal/tone'
|
||||
import { KpiStatGrid } from '@/components/reui-kit'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { AppCards, type AppCardItem } from '@/components/portal/canvas-blocks'
|
||||
import { Skeleton } from '@evobgp/ui/components/skeleton'
|
||||
import { can } from '@/lib/auth'
|
||||
import { useLiveObservationState } from '@/hooks/use-live-observation-state'
|
||||
import { peersForDisplay, snapshotUnavailable, speakersForDisplay } from '@/lib/live-observation'
|
||||
import { useRequestTelemetryRefreshMutation } from '@/queries/network'
|
||||
import {
|
||||
overviewJobsQueryOptions,
|
||||
overviewModulesQueryOptions,
|
||||
overviewPeersQueryOptions,
|
||||
overviewRevisionsQueryOptions,
|
||||
overviewSpeakersQueryOptions,
|
||||
} from '@/queries/overview'
|
||||
import { settingsQueryOptions } from '@/queries/settings'
|
||||
@@ -37,7 +47,10 @@ function parseShowQuickActions(value: unknown): boolean {
|
||||
* @see https://reui.io/preview/base/chart-27
|
||||
*/
|
||||
function DashboardComponent() {
|
||||
const navigate = useNavigate()
|
||||
const [lastUpdated, setLastUpdated] = useState<Date | null>(null)
|
||||
const telemetryRefresh = useRequestTelemetryRefreshMutation()
|
||||
const canRefreshTelemetry = can('bgp:network:read')
|
||||
const settingsQ = useQuery(settingsQueryOptions())
|
||||
const showQuickActions = parseShowQuickActions(settingsQ.data?.ui_show_quick_actions)
|
||||
|
||||
@@ -50,10 +63,11 @@ function DashboardComponent() {
|
||||
],
|
||||
})
|
||||
|
||||
const revisionsQ = useQuery(overviewRevisionsQueryOptions())
|
||||
const [modulesQ, peersQ, speakersQ, jobsQ] = results
|
||||
const initialLoading =
|
||||
modulesQ.isLoading || peersQ.isLoading || speakersQ.isLoading || jobsQ.isLoading
|
||||
const refreshing = results.some((r) => r.isFetching && !r.isLoading)
|
||||
const refreshing = telemetryRefresh.isPending || results.some((r) => r.isFetching && !r.isLoading)
|
||||
|
||||
if (!lastUpdated && !initialLoading && results.every((r) => r.isSuccess || r.isError)) {
|
||||
setTimeout(() => setLastUpdated(new Date()), 0)
|
||||
@@ -61,47 +75,97 @@ function DashboardComponent() {
|
||||
|
||||
function refetchAll() {
|
||||
setLastUpdated(null)
|
||||
results.forEach((r) => r.refetch())
|
||||
void modulesQ.refetch()
|
||||
void jobsQ.refetch()
|
||||
void revisionsQ.refetch()
|
||||
if (canRefreshTelemetry) {
|
||||
telemetryRefresh.mutate()
|
||||
} else {
|
||||
void peersQ.refetch()
|
||||
void speakersQ.refetch()
|
||||
}
|
||||
}
|
||||
|
||||
const modules = modulesQ.data?.items ?? []
|
||||
const peers = peersQ.data?.items ?? []
|
||||
const speakers = speakersQ.data?.items ?? []
|
||||
const rawPeers = peersQ.data?.items ?? []
|
||||
const rawSpeakers = speakersQ.data?.items ?? []
|
||||
const { offline, disconnectedAt } = useLiveObservationState(rawPeers, rawSpeakers)
|
||||
const peersUnavailable = snapshotUnavailable(peersQ.isError, offline, disconnectedAt, peersQ.dataUpdatedAt)
|
||||
const speakersUnavailable = snapshotUnavailable(speakersQ.isError, offline, disconnectedAt, speakersQ.dataUpdatedAt)
|
||||
const peers = [...peersForDisplay(rawPeers, peersUnavailable)]
|
||||
const speakers = [...speakersForDisplay(rawSpeakers, speakersUnavailable)]
|
||||
const jobs = jobsQ.data?.items ?? []
|
||||
const kpiCards = buildDashboardKpiCards({ modules, peers, speakers, jobs })
|
||||
const activity = jobs.slice(0, 4).map((j) => {
|
||||
const failed = ['failed', 'error', 'cancelled'].includes(j.status.toLowerCase())
|
||||
const running = j.status === 'running' || j.status === 'queued'
|
||||
const tone: Tone = failed ? 'er' : running ? 'in' : 'ok'
|
||||
return {
|
||||
t: fmtWhen(j.finished_at ?? j.started_at ?? j.created_at).replace(/^сегодня /, ''),
|
||||
title: j.kind,
|
||||
text: j.error ?? `Статус: ${j.status}`,
|
||||
label: failed ? 'Error' : running ? 'Running' : 'Info',
|
||||
tone,
|
||||
}
|
||||
})
|
||||
|
||||
const quickActions: AppCardItem[] = [
|
||||
{ id: 'new-module', title: 'Создать модуль', desc: 'AS, CDN, домены или IP-диапазоны', badge: 'Модули', meta: '', icon: <Boxes />, onSelect: () => void navigate({ to: '/modules', search: { create: true } }) },
|
||||
{ id: 'lookup', title: 'Проверить IP или домен', desc: 'Есть ли адрес в списках и community', badge: 'Проверка', meta: '', icon: <Search />, onSelect: () => void navigate({ to: '/lookup', search: { q: '' } }) },
|
||||
{ id: 'add-peer', title: 'Добавить пира', desc: 'Новый BGP-сосед на спикере', badge: 'Сеть', meta: '', icon: <Share2 />, onSelect: () => void navigate({ to: '/network', search: { tab: 'peers' } }) },
|
||||
{ id: 'deploy', title: 'Деплой', desc: 'Публикация ревизии на спикеры', badge: 'Операции', badgeVariant: 'warning-light', meta: '', icon: <Play />, onSelect: () => void navigate({ to: '/operations', search: { tab: 'revisions' } }) },
|
||||
]
|
||||
|
||||
if (initialLoading) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 md:gap-6">
|
||||
<PageHeader title="Обзор" description="Метрики, модули и активность" />
|
||||
<KpiStatGrid cards={[]} isLoading skeletonCount={4} />
|
||||
<Skeleton className="h-48 w-full rounded-xl" />
|
||||
<Skeleton className="h-72 w-full rounded-xl" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<OpsDashboard
|
||||
title="Обзор"
|
||||
description={
|
||||
lastUpdated
|
||||
? `Состояние панели управления EvoBGP. Обновлено: ${lastUpdated.toLocaleTimeString('ru-RU')}`
|
||||
: 'Состояние панели управления EvoBGP.'
|
||||
}
|
||||
headerActions={
|
||||
<Button variant="outline" size="sm" onClick={refetchAll} disabled={refreshing}>
|
||||
<RefreshCw className={refreshing ? 'animate-spin' : ''} />
|
||||
Обновить
|
||||
</Button>
|
||||
}
|
||||
kpiCards={kpiCards}
|
||||
isLoading={initialLoading}
|
||||
afterKpi={showQuickActions ? <DashboardQuickLinks /> : null}
|
||||
charts={
|
||||
<div className={chartPanelGridClassName}>
|
||||
<DashboardNetworkHealth
|
||||
peers={peers}
|
||||
speakers={speakers}
|
||||
jobs={jobs}
|
||||
loading={refreshing && peers.length === 0 && speakers.length === 0}
|
||||
/>
|
||||
<DashboardOperationsBreakdown
|
||||
jobs={jobs}
|
||||
modules={modules}
|
||||
loading={refreshing && jobs.length === 0 && modules.length === 0}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-col gap-4 md:gap-6">
|
||||
<PageHeader
|
||||
title="Обзор"
|
||||
description={
|
||||
lastUpdated
|
||||
? `Метрики, модули и активность · данные проверены ${lastUpdated.toLocaleTimeString('ru-RU')}`
|
||||
: 'Метрики, модули и активность'
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" onClick={refetchAll} disabled={refreshing}>
|
||||
<RefreshCw className={refreshing ? 'animate-spin' : ''} />
|
||||
Обновить
|
||||
</Button>
|
||||
<Button onClick={() => void navigate({ to: '/operations', search: { tab: 'revisions' } })}>Применить конфигурацию</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<KpiStatGrid cards={kpiCards} aria-label="Ключевые метрики" />
|
||||
|
||||
{showQuickActions ? (
|
||||
<AppCards title="Быстрые действия" description="Частые операции в один клик" items={quickActions} />
|
||||
) : null}
|
||||
<DashboardPrefixTrend revisions={revisionsQ.data?.items ?? []} loading={revisionsQ.isLoading} />
|
||||
<ModulesListGrid items={modules.slice(0, 5)} isLoading={modulesQ.isLoading} title="Модули" />
|
||||
{activity.length > 0 ? (
|
||||
<ActivityFeed
|
||||
title="Активность"
|
||||
description="Последние события плоскости управления"
|
||||
items={activity}
|
||||
action={
|
||||
<Button variant="outline" onClick={() => void navigate({ to: '/operations', search: { tab: 'jobs' } })}>
|
||||
Весь журнал
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -97,14 +97,14 @@ function DirectoriesComponent() {
|
||||
}
|
||||
|
||||
const addCommunityButton = canWrite ? (
|
||||
<Button size="sm" type="button" onClick={openCreateCommunity}>
|
||||
<Button type="button" onClick={openCreateCommunity}>
|
||||
<Plus />
|
||||
Добавить
|
||||
</Button>
|
||||
) : null
|
||||
|
||||
const addDohButton = canWrite ? (
|
||||
<Button size="sm" type="button" onClick={openCreateDoh}>
|
||||
<Button type="button" onClick={openCreateDoh}>
|
||||
<Plus />
|
||||
Добавить
|
||||
</Button>
|
||||
@@ -118,7 +118,7 @@ function DirectoriesComponent() {
|
||||
actions={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
||||
onClick={() => {
|
||||
void communitiesQ.refetch()
|
||||
void dohQ.refetch()
|
||||
|
||||
@@ -69,19 +69,19 @@ function ModuleDetailComponent() {
|
||||
|
||||
const headerActions = (
|
||||
<>
|
||||
<Button variant="outline" size="sm" render={<Link to="/modules" />}>
|
||||
<Button variant="outline" render={<Link to="/modules" />}>
|
||||
<ArrowLeft />
|
||||
К списку
|
||||
</Button>
|
||||
{canWrite && mod ? (
|
||||
<Button variant="outline" size="sm" onClick={() => setEditOpen(true)}>
|
||||
<Button variant="outline" onClick={() => setEditOpen(true)}>
|
||||
<Pencil />
|
||||
Редактировать
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
||||
onClick={() => void refreshAll()}
|
||||
disabled={detail.isFetching}
|
||||
>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Plus, RefreshCw } from 'lucide-react'
|
||||
import { BoxesIcon, CircleAlertIcon, PlusIcon, PowerIcon, RefreshCwIcon } from 'lucide-react'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
|
||||
@@ -8,6 +8,10 @@ import { ProjectsEmptyState } from '@/components/patterns/projects-empty-state'
|
||||
import { ModuleCreateDialog } from '@/components/modules/module-create-dialog'
|
||||
import { ModulesListGrid } from '@/components/modules/modules-list-grid'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { PageTab, PageTabsList } from '@/components/portal/bits'
|
||||
import { kpi } from '@/components/portal/kpi'
|
||||
import { KpiStrip } from '@/components/portal/kpi-strip'
|
||||
import { Tabs, TabsContent } from '@evobgp/ui/components/tabs'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { TableSkeleton } from '@/components/skeletons'
|
||||
import { sessionCanWriteModules } from '@/lib/auth'
|
||||
@@ -52,15 +56,16 @@ function ModulesListComponent() {
|
||||
description="Маршрутные списки: AS, CDN, домены, IP-диапазоны"
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => query.refetch()}
|
||||
disabled={query.isFetching}
|
||||
>
|
||||
<RefreshCw className={query.isFetching ? 'animate-spin' : ''} />
|
||||
<Button variant="outline" onClick={() => query.refetch()} disabled={query.isFetching}>
|
||||
<RefreshCwIcon className={query.isFetching ? 'animate-spin' : ''} />
|
||||
Обновить
|
||||
</Button>
|
||||
{canWrite ? (
|
||||
<Button onClick={() => setCreateOpen(true)}>
|
||||
<PlusIcon />
|
||||
Создать модуль
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
@@ -80,20 +85,31 @@ function ModulesListComponent() {
|
||||
skeleton={<TableSkeleton rows={6} cols={5} />}
|
||||
onRetry={() => query.refetch()}
|
||||
>
|
||||
{(items) => (
|
||||
<ModulesListGrid
|
||||
items={items}
|
||||
isLoading={query.isFetching && !query.isLoading}
|
||||
actions={
|
||||
canWrite ? (
|
||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||
<Plus />
|
||||
Создать
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{(items) => {
|
||||
const enabled = items.filter((m) => m.enabled !== false).length
|
||||
const never = items.filter((m) => !m.last_refreshed_at).length
|
||||
return (
|
||||
<>
|
||||
<KpiStrip
|
||||
aria-label="Показатели"
|
||||
items={[
|
||||
kpi('all', 'Модулей', String(items.length), { icon: BoxesIcon, badge: 'в арендаторе' }),
|
||||
kpi('on', 'Включено', String(enabled), { icon: PowerIcon, tone: 'ok', badge: 'участвуют в ревизии' }),
|
||||
kpi('off', 'Выключено', String(items.length - enabled), { icon: PowerIcon, badge: 'не в ревизии' }),
|
||||
kpi('never', 'Не обновлялись', String(never), { icon: CircleAlertIcon, tone: never ? 'wn' : 'neutral', badge: 'нет первого refresh' }),
|
||||
]}
|
||||
/>
|
||||
<Tabs defaultValue="list" className="min-w-0">
|
||||
<PageTabsList>
|
||||
<PageTab value="list" label="Модули" count={items.length} />
|
||||
</PageTabsList>
|
||||
<TabsContent value="list" className="min-w-0">
|
||||
<ModulesListGrid items={items} isLoading={query.isFetching && !query.isLoading} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
</QueryState>
|
||||
|
||||
<ModuleCreateDialog
|
||||
|
||||
@@ -66,7 +66,7 @@ function MonitoringComponent() {
|
||||
: ''
|
||||
}`}
|
||||
actions={
|
||||
<Button variant="outline" size="sm" onClick={refetchAll} disabled={refreshing}>
|
||||
<Button variant="outline" onClick={refetchAll} disabled={refreshing}>
|
||||
<RefreshCw className={refreshing ? 'animate-spin' : ''} />
|
||||
Обновить
|
||||
</Button>
|
||||
|
||||
@@ -9,11 +9,15 @@ import { NetworkKpi } from '@/components/network/network-kpi'
|
||||
import { NetworkPeersCard } from '@/components/network/network-peers-card'
|
||||
import { NetworkSpeakersCard } from '@/components/network/network-speakers-card'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { SignalAlert } from '@/components/portal/bits'
|
||||
import { useLiveObservationState } from '@/hooks/use-live-observation-state'
|
||||
import { peersForDisplay, snapshotUnavailable, speakersForDisplay } from '@/lib/live-observation'
|
||||
import {
|
||||
networkBirdQueryOptions,
|
||||
networkDiscoveredPeersQueryOptions,
|
||||
networkPeersQueryOptions,
|
||||
networkSpeakersQueryOptions,
|
||||
useRequestTelemetryRefreshMutation,
|
||||
} from '@/queries/network'
|
||||
|
||||
type NetworkTab = 'peers' | 'discovered' | 'speakers'
|
||||
@@ -49,41 +53,48 @@ function NetworkComponent() {
|
||||
})
|
||||
const speakersQ = useQuery({ ...networkSpeakersQueryOptions(), refetchInterval: 30_000 })
|
||||
const birdQ = useQuery({ ...networkBirdQueryOptions(), refetchInterval: 30_000 })
|
||||
const telemetryRefresh = useRequestTelemetryRefreshMutation()
|
||||
|
||||
const refreshing =
|
||||
peersQ.isFetching ||
|
||||
discoveredQ.isFetching ||
|
||||
speakersQ.isFetching ||
|
||||
birdQ.isFetching
|
||||
const peers = peersQ.data?.items ?? []
|
||||
const rawPeers = peersQ.data?.items ?? []
|
||||
const discovered = discoveredQ.data?.items ?? []
|
||||
const speakers = speakersQ.data?.items ?? []
|
||||
const rawSpeakers = speakersQ.data?.items ?? []
|
||||
const { offline, disconnectedAt } = useLiveObservationState(rawPeers, rawSpeakers)
|
||||
const peers = [...peersForDisplay(rawPeers, snapshotUnavailable(peersQ.isError, offline, disconnectedAt, peersQ.dataUpdatedAt))]
|
||||
const speakers = [...speakersForDisplay(rawSpeakers, snapshotUnavailable(speakersQ.isError, offline, disconnectedAt, speakersQ.dataUpdatedAt))]
|
||||
const overviewLoading = peersQ.isLoading || speakersQ.isLoading
|
||||
|
||||
function refetchAll() {
|
||||
void peersQ.refetch()
|
||||
void discoveredQ.refetch()
|
||||
void speakersQ.refetch()
|
||||
void birdQ.refetch()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 md:gap-6">
|
||||
<PageHeader
|
||||
title="Сеть"
|
||||
description="BGP-пиры, автообнаружение и спикеры"
|
||||
actions={
|
||||
<Button variant="outline" size="sm" onClick={refetchAll} disabled={refreshing}>
|
||||
<RefreshCw className={refreshing ? 'animate-spin' : undefined} />
|
||||
Обновить
|
||||
<Button variant="outline" onClick={() => telemetryRefresh.mutate()} disabled={telemetryRefresh.isPending}>
|
||||
<RefreshCw className={telemetryRefresh.isPending ? 'animate-spin' : undefined} />
|
||||
Запросить обновление
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{discovered.length > 0 ? (
|
||||
<SignalAlert
|
||||
tone="wn"
|
||||
title={`Есть пиры на одобрение: ${discovered.length}.`}
|
||||
action={
|
||||
<Button variant="outline" onClick={() => navigate({ search: { tab: 'discovered' } })}>
|
||||
Показать
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{discovered[0]?.neighbor}
|
||||
{discovered[0]?.remote_asn ? ` · AS${discovered[0].remote_asn}` : ''} обнаружен автоматически · после одобрения пир попадёт в конфигурацию BIRD.
|
||||
</SignalAlert>
|
||||
) : null}
|
||||
|
||||
<NetworkKpi
|
||||
peers={peers}
|
||||
speakers={speakers}
|
||||
bird={birdQ.data}
|
||||
bird={snapshotUnavailable(birdQ.isError, offline, disconnectedAt, birdQ.dataUpdatedAt) ? undefined : birdQ.data}
|
||||
loading={overviewLoading || birdQ.isLoading}
|
||||
/>
|
||||
|
||||
|
||||
@@ -10,9 +10,7 @@ import { SegmentedTabs, TabsContent } from '@/components/segmented-tabs'
|
||||
import {
|
||||
FrameSection,
|
||||
KpiStatGrid,
|
||||
QuickActionGrid,
|
||||
type KpiStatItem,
|
||||
type QuickActionItem,
|
||||
} from '@/components/reui-kit'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import {
|
||||
@@ -27,6 +25,7 @@ import { SelectMenu } from '@/components/select-field'
|
||||
import { OperationsJobsCard } from '@/components/operations/operations-jobs-card'
|
||||
import { OperationsRevisionsGrid } from '@/components/operations/operations-revisions-grid'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { SignalAlert } from '@/components/portal/bits'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
|
||||
@@ -111,6 +110,7 @@ function OperationsComponent() {
|
||||
|
||||
const revisions = useMemo(() => revisionsQ.data?.items ?? [], [revisionsQ.data?.items])
|
||||
const jobs = useMemo(() => jobsQ.data?.items ?? [], [jobsQ.data?.items])
|
||||
const failedJobs = jobs.filter((j) => ['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()))
|
||||
const nameById = moduleNameById(modulesQ.data?.items ?? [])
|
||||
|
||||
const refreshing = revisionsQ.isFetching || jobsQ.isFetching
|
||||
@@ -157,51 +157,44 @@ function OperationsComponent() {
|
||||
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось перезагрузить'),
|
||||
})
|
||||
|
||||
const quickActions: QuickActionItem[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: 'apply',
|
||||
title: 'Применить',
|
||||
description: 'Деплой текущей ревизии на все BIRD-агенты.',
|
||||
icon: <Play aria-hidden />,
|
||||
iconClassName: 'text-primary',
|
||||
badge: 'Выполнить',
|
||||
disabled: applyMutation.isPending || revisions.length === 0,
|
||||
onClick: () => setApplyOpen(true),
|
||||
},
|
||||
{
|
||||
id: 'bird-reload',
|
||||
title: 'Перезагрузка BIRD',
|
||||
description: 'Выполнить birdc configure на локальном демоне.',
|
||||
icon: <RotateCcw aria-hidden />,
|
||||
iconClassName: 'text-warning',
|
||||
badge: 'Выполнить',
|
||||
disabled: birdReloadMutation.isPending,
|
||||
onClick: () => setReloadOpen(true),
|
||||
},
|
||||
],
|
||||
[applyMutation.isPending, birdReloadMutation.isPending, revisions.length],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 md:gap-6">
|
||||
<PageHeader
|
||||
title="Ревизии и операции"
|
||||
description="Деплой конфигурации, управление ревизиями и задачами"
|
||||
actions={
|
||||
<Button variant="outline" size="sm" onClick={refetchAll} disabled={refreshing}>
|
||||
<RefreshCw className={refreshing ? 'animate-spin' : ''} />
|
||||
Обновить
|
||||
</Button>
|
||||
<>
|
||||
<Button variant="outline" onClick={refetchAll} disabled={refreshing}>
|
||||
<RefreshCw className={refreshing ? 'animate-spin' : ''} />
|
||||
Обновить
|
||||
</Button>
|
||||
<Button variant="outline" disabled={birdReloadMutation.isPending} onClick={() => setReloadOpen(true)}>
|
||||
<RotateCcw />
|
||||
Перезагрузить BIRD
|
||||
</Button>
|
||||
<Button disabled={applyMutation.isPending || revisions.length === 0} onClick={() => setApplyOpen(true)}>
|
||||
<Play />
|
||||
Применить на всех спикерах
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<KpiStatGrid items={kpiItems} isLoading={kpiLoading} skeletonCount={3} />
|
||||
{failedJobs.length > 0 ? (
|
||||
<SignalAlert
|
||||
tone="er"
|
||||
title={`Задач с ошибкой: ${failedJobs.length}.`}
|
||||
action={
|
||||
<Button variant="outline" onClick={() => navigate({ search: { tab: 'jobs' } })}>
|
||||
Показать
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{failedJobs[0]?.error ?? 'Проверьте вкладку «Задачи» и журнал применения.'}
|
||||
</SignalAlert>
|
||||
) : null}
|
||||
|
||||
<QuickActionGrid
|
||||
actions={quickActions}
|
||||
description="Деплой конфигурации и управление локальным BIRD"
|
||||
/>
|
||||
<KpiStatGrid items={kpiItems} isLoading={kpiLoading} skeletonCount={3} />
|
||||
|
||||
<ConfirmDialog
|
||||
open={applyOpen}
|
||||
|
||||
@@ -91,7 +91,7 @@ function ScheduleComponent() {
|
||||
actions={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
||||
onClick={() => {
|
||||
void modulesQ.refetch()
|
||||
void jobsQ.refetch()
|
||||
|
||||
@@ -141,6 +141,29 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/v1/telemetry/refresh": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: {
|
||||
/** @description Явный tenant (только супер-роли). Без заголовка tenant определяется по API-ключу. **Реализация v1:** заголовок в Go handlers не обрабатывается; tenant только из Bearer-токена (см. docs/access.md). */
|
||||
"X-Tenant-Id"?: components["parameters"]["TenantId"];
|
||||
};
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Запросить фоновое обновление телеметрии tenant
|
||||
* @description Требует bgp:network:read. Объединяет повторные запросы в пределах 10 секунд; HTTP handler не опрашивает агентов или BIRD. GET speakers/peers/discoveries читает сохранённые snapshots, включая live=1. Запрос во время выполняющегося poll сохраняется для следующего цикла. Не изменяет конфигурацию speaker и не требует нового протокола агента.
|
||||
*/
|
||||
post: operations["requestTelemetryRefresh"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/v1/router-lists/catalog": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -457,6 +480,9 @@ export interface paths {
|
||||
* - `ipRange,community`
|
||||
*
|
||||
* В поле `community` можно передавать либо ID community, либо её значение.
|
||||
* Предел запроса — 8 MiB (8388608 байт), 10000 строк данных без заголовка и 64 поля в записи.
|
||||
* Превышение возвращает 413 до создания записей; большой импорт следует разбить на части.
|
||||
* Ошибка в данных или БД после начала создания записей может оставить частичный импорт.
|
||||
*/
|
||||
post: operations["importModuleEntriesCsv"];
|
||||
delete?: never;
|
||||
@@ -647,7 +673,7 @@ export interface paths {
|
||||
};
|
||||
/**
|
||||
* Список обнаруженных (неодобренных) пиров
|
||||
* @description Dynamic BGP-сессии (`evobgp_dyn_*`), которых ещё нет в `bgp_peer`. По умолчанию возвращает `status=pending`. При листинге выполняет live-опрос birdc/agent и upsert pending.
|
||||
* @description Dynamic BGP-сессии (`evobgp_dyn_*`), которых ещё нет в `bgp_peer`. По умолчанию возвращает `status=pending`. Читает сохранённые discoveries. Фоновый collector выполняет опрос и fenced upsert; GET не опрашивает ноды и не изменяет данные.
|
||||
*/
|
||||
get: operations["listDiscoveredPeers"];
|
||||
put?: never;
|
||||
@@ -1111,6 +1137,31 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/v1/jobs/{job_id}/report": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: {
|
||||
/** @description Явный tenant (только супер-роли). Без заголовка tenant определяется по API-ключу. **Реализация v1:** заголовок в Go handlers не обрабатывается; tenant только из Bearer-токена (см. docs/access.md). */
|
||||
"X-Tenant-Id"?: components["parameters"]["TenantId"];
|
||||
};
|
||||
path: {
|
||||
job_id: components["parameters"]["JobId"];
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Отчёт задачи и сохранённый прогресс доставки
|
||||
* @description Требует bgp:operations:read. Прогресс хранится независимо от meta задачи. Для reconciliation без собственного прогресса возвращается прогресс исходной задачи в том же tenant; после начала восстановления приоритет имеет собственный прогресс. agent_reported не означает проверку состояния BIRD.
|
||||
*/
|
||||
get: operations["getJobReport"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/v1/jobs/{job_id}/cancel": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1136,6 +1187,31 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/v1/jobs/{job_id}/retry": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: {
|
||||
/** @description Явный tenant (только супер-роли). Без заголовка tenant определяется по API-ключу. **Реализация v1:** заголовок в Go handlers не обрабатывается; tenant только из Bearer-токена (см. docs/access.md). */
|
||||
"X-Tenant-Id"?: components["parameters"]["TenantId"];
|
||||
};
|
||||
path: {
|
||||
job_id: components["parameters"]["JobId"];
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Повторить завершившуюся ошибкой задачу
|
||||
* @description Операторский повтор сохраняет ID и исходный запрос, сбрасывая бюджет попыток.
|
||||
*/
|
||||
post: operations["postJobRetry"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/v1/speakers/{speaker_id}/revisions/latest": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -2228,11 +2304,19 @@ export interface components {
|
||||
connected_speaker_id?: string | null;
|
||||
/** @description Человекочитаемая метка ноды из live-опроса. */
|
||||
connected_speaker_label?: string;
|
||||
/** @description Последнее сохранённое состояние; не является текущим наблюдением. */
|
||||
last_known_session_state?: string | null;
|
||||
/** @description При live-ответе текущее наблюдение: BGP state, absent после успешного пустого опроса или null, если актуальное состояние неизвестно. */
|
||||
session_state?: string | null;
|
||||
/** @description Качество BGP-наблюдений при GET /v1/peers?live=1. Для назначенного пира учитывается только bgp_speaker_id; для общего — все speakers. Смешанный успех и сбой не дают quality=ok или session_state=absent. Timestamps относятся только к ожидаемым speakers. */
|
||||
live_observation?: components["schemas"]["LiveObservation"];
|
||||
/** @description Состояние протокола пира на каждой опрошенной ноде. */
|
||||
session_on_speakers?: {
|
||||
speaker_id?: string;
|
||||
label?: string;
|
||||
state?: string;
|
||||
poll_error?: string;
|
||||
observation?: components["schemas"]["LiveObservation"];
|
||||
}[];
|
||||
/** @description Ноды, где сессия в состоянии Established (один пир может быть на нескольких). */
|
||||
established_on_speakers?: {
|
||||
@@ -2281,8 +2365,10 @@ export interface components {
|
||||
SpeakerLiveStatus: {
|
||||
/** @description Человекочитаемая метка ноды (agent domain или CP master). */
|
||||
label?: string;
|
||||
/** @description true если agent /v1/agent/health успешен (master — local birdc poll). */
|
||||
/** @description true только после успешного agent /v1/agent/health (master — local birdc poll). */
|
||||
agent_ok?: boolean;
|
||||
agent_observation?: components["schemas"]["LiveObservation"];
|
||||
bgp_observation?: components["schemas"]["LiveObservation"];
|
||||
agent_error?: string;
|
||||
/** Format: date-time */
|
||||
agent_last_sync_at?: string;
|
||||
@@ -2290,12 +2376,34 @@ export interface components {
|
||||
/** @description true если birdc (CP) или GET /v1/agent/bird/protocols (replica) успешен. */
|
||||
bgp_poll_ok?: boolean;
|
||||
bgp_poll_error?: string;
|
||||
bgp_sessions_total?: number;
|
||||
bgp_established?: number;
|
||||
/** @description null при отсутствии успешного опроса. */
|
||||
bgp_sessions_total?: number | null;
|
||||
/** @description null при отсутствии успешного опроса. */
|
||||
bgp_established?: number | null;
|
||||
sessions?: components["schemas"]["BgpSessionLive"][];
|
||||
} & {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
/** @description Метаданные конкретного опроса. observed_at — время завершения опроса; received_at — время получения результата CP; last_success_at — время последнего успешного опроса. Старые агенты не обязаны передавать эти поля: CP формирует их по собственным часам. При отсутствии опроса timestamps null. */
|
||||
LiveObservation: {
|
||||
/** Format: date-time */
|
||||
observed_at: string | null;
|
||||
/** Format: date-time */
|
||||
received_at: string | null;
|
||||
/** Format: date-time */
|
||||
last_success_at: string | null;
|
||||
/**
|
||||
* Format: date-time
|
||||
* @description Срок актуальности snapshot по часам CP и настроенному интервалу collector; клиент не должен считать cached quality=ok актуальным после этого времени. Для агрегата — самый ранний срок источников.
|
||||
*/
|
||||
expires_at?: string | null;
|
||||
/** @enum {string} */
|
||||
source: "none" | "cp_birdc" | "agent_bird_protocols" | "agent_health" | "mixed";
|
||||
/** @enum {string} */
|
||||
quality: "ok" | "stale" | "unreachable" | "unknown" | "disabled";
|
||||
/** @enum {string} */
|
||||
error_code?: "not_configured" | "not_polled" | "poll_failed" | "reported_unhealthy" | "snapshot_expired" | "configuration_changed" | "ambiguous_authority";
|
||||
};
|
||||
BgpSessionLive: {
|
||||
name?: string;
|
||||
neighbor?: string;
|
||||
@@ -2312,8 +2420,10 @@ export interface components {
|
||||
speaker_id?: string;
|
||||
label?: string;
|
||||
ok?: boolean;
|
||||
session_count?: number;
|
||||
/** @description null, если успешного опроса не было. */
|
||||
session_count?: number | null;
|
||||
poll_error?: string;
|
||||
observation?: components["schemas"]["LiveObservation"];
|
||||
} & {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
@@ -2831,6 +2941,17 @@ export interface components {
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Локальная квота процесса API — по умолчанию 4 активных preview/lookup, 2 на tenant; 120 lookup и по 30 CDN/revision preview в минуту на tenant. Квоты умножаются на число реплик. Повторить после Retry-After, без очереди ожидания. */
|
||||
ExpensiveOperationLimited: {
|
||||
headers: {
|
||||
/** @description Минимальная задержка повторной попытки в секундах; не гарантия свободного места. */
|
||||
"Retry-After"?: number;
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["Problem"];
|
||||
};
|
||||
};
|
||||
/** @description Нет или невалидный Bearer-токен. */
|
||||
Unauthorized: {
|
||||
headers: {
|
||||
@@ -2885,7 +3006,7 @@ export interface components {
|
||||
"application/problem+json": components["schemas"]["Problem"];
|
||||
};
|
||||
};
|
||||
/** @description Ошибка (см. тело Problem). */
|
||||
/** @description Ошибка (см. тело Problem). Общие для authenticated `/v1` ответы middleware: 413 при request body свыше 8 MiB, 400 при ошибке чтения тела, 408 при отмене/таймауте чтения. 429 с Retry-After: 1 при занятых слотах обработки request body. */
|
||||
DefaultProblem: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
@@ -3085,6 +3206,34 @@ export interface operations {
|
||||
};
|
||||
400: components["responses"]["BadRequest"];
|
||||
401: components["responses"]["Unauthorized"];
|
||||
429: components["responses"]["ExpensiveOperationLimited"];
|
||||
default: components["responses"]["DefaultProblem"];
|
||||
};
|
||||
};
|
||||
requestTelemetryRefresh: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: {
|
||||
/** @description Явный tenant (только супер-роли). Без заголовка tenant определяется по API-ключу. **Реализация v1:** заголовок в Go handlers не обрабатывается; tenant только из Bearer-токена (см. docs/access.md). */
|
||||
"X-Tenant-Id"?: components["parameters"]["TenantId"];
|
||||
};
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Запрос принят либо объединён с уже принятым. */
|
||||
202: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
/** @description true при постановке нового запроса; false при объединении или отсутствии целей. */
|
||||
requested: boolean;
|
||||
};
|
||||
};
|
||||
};
|
||||
default: components["responses"]["DefaultProblem"];
|
||||
};
|
||||
};
|
||||
@@ -3903,6 +4052,15 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
404: components["responses"]["NotFound"];
|
||||
/** @description Превышен лимит байт, строк или полей CSV; записи не созданы. */
|
||||
413: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["Problem"];
|
||||
};
|
||||
};
|
||||
422: components["responses"]["UnprocessableEntity"];
|
||||
default: components["responses"]["DefaultProblem"];
|
||||
};
|
||||
@@ -4002,6 +4160,7 @@ export interface operations {
|
||||
403: components["responses"]["Forbidden"];
|
||||
404: components["responses"]["NotFound"];
|
||||
422: components["responses"]["UnprocessableEntity"];
|
||||
429: components["responses"]["ExpensiveOperationLimited"];
|
||||
default: components["responses"]["DefaultProblem"];
|
||||
};
|
||||
};
|
||||
@@ -4291,7 +4450,7 @@ export interface operations {
|
||||
/** @description Размер страницы (по умолчанию 50). */
|
||||
limit?: components["parameters"]["Limit"];
|
||||
speaker_id?: components["parameters"]["SpeakerFilter"];
|
||||
/** @description Опрос birdc на CP и GET /v1/agent/bird/protocols на репликах; обогащает session_state и connected_speaker_*. */
|
||||
/** @description Сохранённые результаты фонового опроса birdc на CP и agent protocols на репликах; обогащает session_state и connected_speaker_*. GET не запускает опрос; live=1 сохранён для совместимости. */
|
||||
live?: "1";
|
||||
};
|
||||
header?: {
|
||||
@@ -4313,7 +4472,7 @@ export interface operations {
|
||||
items: components["schemas"]["BgpPeer"][];
|
||||
next_cursor?: string | null;
|
||||
has_more: boolean;
|
||||
/** @description При live=1 — результат опроса каждой ноды (CP birdc + agent protocols). */
|
||||
/** @description Сохранённый результат фонового опроса каждой ноды (CP birdc + agent protocols); live=1 не запускает новый опрос. */
|
||||
live_speaker_poll?: components["schemas"]["LiveSpeakerPoll"][];
|
||||
};
|
||||
};
|
||||
@@ -4557,7 +4716,7 @@ export interface operations {
|
||||
cursor?: components["parameters"]["Cursor"];
|
||||
/** @description Размер страницы (по умолчанию 50). */
|
||||
limit?: components["parameters"]["Limit"];
|
||||
/** @description Live-опрос agent /v1/agent/health и BGP protocols на репликах; CP — local birdc. Обогащает каждый item полем `live`. */
|
||||
/** @description Сохранённые результаты фоновых опросов agent health/BGP и local birdc. GET не запускает опрос. Обогащает каждый item полем `live`. */
|
||||
live?: "1";
|
||||
};
|
||||
header?: {
|
||||
@@ -4884,6 +5043,7 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
404: components["responses"]["NotFound"];
|
||||
429: components["responses"]["ExpensiveOperationLimited"];
|
||||
default: components["responses"]["DefaultProblem"];
|
||||
};
|
||||
};
|
||||
@@ -5144,6 +5304,66 @@ export interface operations {
|
||||
default: components["responses"]["DefaultProblem"];
|
||||
};
|
||||
};
|
||||
getJobReport: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: {
|
||||
/** @description Явный tenant (только супер-роли). Без заголовка tenant определяется по API-ключу. **Реализация v1:** заголовок в Go handlers не обрабатывается; tenant только из Bearer-токена (см. docs/access.md). */
|
||||
"X-Tenant-Id"?: components["parameters"]["TenantId"];
|
||||
};
|
||||
path: {
|
||||
job_id: components["parameters"]["JobId"];
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Успешно. */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
job_id: string;
|
||||
kind: string;
|
||||
status: string;
|
||||
meta?: {
|
||||
[key: string]: unknown;
|
||||
} | null;
|
||||
error?: string | null;
|
||||
/** Format: date-time */
|
||||
created_at?: string;
|
||||
revision_id?: string;
|
||||
log_entries?: {
|
||||
[key: string]: unknown;
|
||||
}[];
|
||||
log_total?: number;
|
||||
source_deployment_job_id?: string;
|
||||
deployment_progress: {
|
||||
job_id: string;
|
||||
speaker_id: string;
|
||||
revision_id: string;
|
||||
/** Format: int64 */
|
||||
generation: number;
|
||||
state: string;
|
||||
confirmation: string;
|
||||
error?: string;
|
||||
dispatch_attempts: number;
|
||||
retry_round: number;
|
||||
/** Format: date-time */
|
||||
health_received_at?: string | null;
|
||||
health_revision_id?: string;
|
||||
health_source_at?: string;
|
||||
health_error?: string;
|
||||
}[];
|
||||
};
|
||||
};
|
||||
};
|
||||
404: components["responses"]["NotFound"];
|
||||
default: components["responses"]["DefaultProblem"];
|
||||
};
|
||||
};
|
||||
postJobCancel: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -5173,6 +5393,42 @@ export interface operations {
|
||||
default: components["responses"]["DefaultProblem"];
|
||||
};
|
||||
};
|
||||
postJobRetry: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: {
|
||||
/** @description Явный tenant (только супер-роли). Без заголовка tenant определяется по API-ключу. **Реализация v1:** заголовок в Go handlers не обрабатывается; tenant только из Bearer-токена (см. docs/access.md). */
|
||||
"X-Tenant-Id"?: components["parameters"]["TenantId"];
|
||||
};
|
||||
path: {
|
||||
job_id: components["parameters"]["JobId"];
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Повтор поставлен в очередь. */
|
||||
202: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Job"];
|
||||
};
|
||||
};
|
||||
409: components["responses"]["Conflict"];
|
||||
/** @description Очередь задач заполнена. */
|
||||
429: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["Problem"];
|
||||
};
|
||||
};
|
||||
default: components["responses"]["DefaultProblem"];
|
||||
};
|
||||
};
|
||||
getSpeakerLatestRevision: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -198,6 +198,17 @@ export type PeerSessionOnSpeaker = {
|
||||
label: string
|
||||
state: string
|
||||
poll_error?: string
|
||||
observation?: LiveObservation
|
||||
}
|
||||
|
||||
export type LiveObservation = {
|
||||
observed_at: string | null
|
||||
received_at: string | null
|
||||
last_success_at: string | null
|
||||
expires_at?: string | null
|
||||
source: 'none' | 'cp_birdc' | 'agent_bird_protocols' | 'agent_health' | 'mixed'
|
||||
quality: 'ok' | 'stale' | 'unreachable' | 'unknown' | 'disabled'
|
||||
error_code?: 'not_configured' | 'not_polled' | 'poll_failed' | 'reported_unhealthy' | 'snapshot_expired' | 'configuration_changed' | 'ambiguous_authority'
|
||||
}
|
||||
|
||||
export type PeerRow = {
|
||||
@@ -206,7 +217,9 @@ export type PeerRow = {
|
||||
neighbor: string
|
||||
remote_asn?: number
|
||||
enabled?: boolean
|
||||
session_state: string
|
||||
session_state: string | null
|
||||
last_known_session_state?: string | null
|
||||
live_observation?: LiveObservation
|
||||
bgp_speaker_id: string | null
|
||||
connected_speaker_id?: string | null
|
||||
connected_speaker_label?: string
|
||||
@@ -267,13 +280,15 @@ export type BgpSessionLive = {
|
||||
export type SpeakerLiveStatus = {
|
||||
label?: string
|
||||
agent_ok?: boolean
|
||||
agent_observation?: LiveObservation
|
||||
bgp_observation?: LiveObservation
|
||||
agent_error?: string
|
||||
agent_last_sync_at?: string
|
||||
agent_last_applied_revision_id?: string
|
||||
bgp_poll_ok?: boolean
|
||||
bgp_poll_error?: string
|
||||
bgp_sessions_total?: number
|
||||
bgp_established?: number
|
||||
bgp_sessions_total?: number | null
|
||||
bgp_established?: number | null
|
||||
sessions?: BgpSessionLive[]
|
||||
}
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"root":["./src/main.tsx","./src/routetree.gen.ts","./src/vite-env.d.ts","./src/components/app-switcher.tsx","./src/components/category-badge.tsx","./src/components/confirm-dialog.tsx","./src/components/data-grid-cell.tsx","./src/components/empty-state.tsx","./src/components/form-drawer.tsx","./src/components/loading-button.tsx","./src/components/page-header.tsx","./src/components/page-shell.tsx","./src/components/panel-card.tsx","./src/components/query-state.tsx","./src/components/segmented-tabs.tsx","./src/components/select-field.tsx","./src/components/skeletons.tsx","./src/components/status-badge.tsx","./src/components/status-toggle-group.tsx","./src/components/theme-provider.tsx","./src/components/truncated-text.tsx","./src/components/access/access-api-keys-card.tsx","./src/components/access/access-api-keys-grid.tsx","./src/components/access/api-key-create-dialog.tsx","./src/components/access/api-key-token-dialog.tsx","./src/components/analytics/analytics-activity-list.tsx","./src/components/analytics/analytics-card-shell.tsx","./src/components/analytics/analytics-kpi-row.tsx","./src/components/analytics/analytics-progress.tsx","./src/components/analytics/analytics-segment-control.tsx","./src/components/analytics/chart-bar-strip.tsx","./src/components/analytics/chart-donut-metric.tsx","./src/components/analytics/dashboard-network-capacity-card.tsx","./src/components/analytics/dashboard-operations-flow-card.tsx","./src/components/analytics/dashboard-platform-card.tsx","./src/components/analytics/index.ts","./src/components/analytics/monitoring-health-card.tsx","./src/components/analytics/network-overview-analytics-card.tsx","./src/components/dashboard/card-dot-field.tsx","./src/components/dashboard/dashboard-kpi-grid.tsx","./src/components/dashboard/dashboard-network-health.tsx","./src/components/dashboard/dashboard-network-panel.tsx","./src/components/dashboard/dashboard-operations-breakdown.tsx","./src/components/dashboard/dashboard-quick-links.tsx","./src/components/directories/community-create-dialog.tsx","./src/components/directories/directories-communities-grid.tsx","./src/components/directories/directories-doh-grid.tsx","./src/components/directories/directories-row-actions.tsx","./src/components/directories/doh-profile-create-dialog.tsx","./src/components/layout/app-shell.tsx","./src/components/layout/apps-menu.tsx","./src/components/layout/command-palette.tsx","./src/components/layout/nav-user.tsx","./src/components/layout/system-monitor-popover.tsx","./src/components/lookup/lookup-add-step.tsx","./src/components/lookup/lookup-matches-grid.tsx","./src/components/lookup/lookup-search-form.tsx","./src/components/lookup/lookup-status-banner.tsx","./src/components/lookup/lookup-wizard.tsx","./src/components/modules/community-select.tsx","./src/components/modules/module-as-entry-dialog.tsx","./src/components/modules/module-cdn-source-dialog.tsx","./src/components/modules/module-create-dialog.tsx","./src/components/modules/module-domain-entry-dialog.tsx","./src/components/modules/module-edit-dialog.tsx","./src/components/modules/module-entries-grid.tsx","./src/components/modules/module-entries-section.tsx","./src/components/modules/module-ip-range-entry-dialog.tsx","./src/components/modules/module-kpi-cards.tsx","./src/components/modules/modules-list-grid.tsx","./src/components/monitoring/monitoring-ready-grid.tsx","./src/components/network/network-discovered-peers-card.tsx","./src/components/network/network-kpi.tsx","./src/components/network/network-peers-card.tsx","./src/components/network/network-peers-grid.tsx","./src/components/network/network-speakers-card.tsx","./src/components/network/network-speakers-grid.tsx","./src/components/network/peer-form-dialog.tsx","./src/components/network/speaker-form-dialog.tsx","./src/components/operations/job-timeline.tsx","./src/components/operations/operations-jobs-card.tsx","./src/components/operations/operations-jobs-grid.tsx","./src/components/operations/operations-jobs-kanban.tsx","./src/components/operations/operations-revisions-grid.tsx","./src/components/patterns/donut-breakdown-card.tsx","./src/components/patterns/illustrated-empty-state.tsx","./src/components/patterns/index.ts","./src/components/patterns/kpi-sparkline-card.tsx","./src/components/patterns/metric-tone-styles.ts","./src/components/patterns/panel-corners.tsx","./src/components/patterns/projects-empty-state.tsx","./src/components/patterns/segmented-progress-card.tsx","./src/components/reui/alert.tsx","./src/components/reui/autocomplete.tsx","./src/components/reui/badge.tsx","./src/components/reui/date-selector.tsx","./src/components/reui/frame.tsx","./src/components/reui/icon-stack.tsx","./src/components/reui/icon-tile.tsx","./src/components/reui/kanban.tsx","./src/components/reui/number-field.tsx","./src/components/reui/rating.tsx","./src/components/reui/sortable.tsx","./src/components/reui/stepper.tsx","./src/components/reui/timeline.tsx","./src/components/reui/cascader/cascader-async.tsLine truncated
|
||||
{"root":["./src/main.tsx","./src/routetree.gen.ts","./src/vite-env.d.ts","./src/components/app-switcher.tsx","./src/components/category-badge.tsx","./src/components/confirm-dialog.tsx","./src/components/data-grid-cell.tsx","./src/components/empty-state.tsx","./src/components/form-drawer.tsx","./src/components/loading-button.tsx","./src/components/page-header.tsx","./src/components/page-shell.tsx","./src/components/panel-card.tsx","./src/components/query-state.tsx","./src/components/segmented-tabs.tsx","./src/components/select-field.tsx","./src/components/skeletons.tsx","./src/components/status-badge.tsx","./src/components/status-toggle-group.tsx","./src/components/theme-provider.tsx","./src/components/truncated-text.tsx","./src/components/access/access-api-keys-card.tsx","./src/components/access/access-api-keys-grid.tsx","./src/components/access/api-key-create-dialog.tsx","./src/components/access/api-key-token-dialog.tsx","./src/components/analytics/analytics-activity-list.tsx","./src/components/analytics/analytics-card-shell.tsx","./src/components/analytics/analytics-kpi-row.tsx","./src/components/analytics/analytics-progress.tsx","./src/components/analytics/analytics-segment-control.tsx","./src/components/analytics/chart-bar-strip.tsx","./src/components/analytics/chart-donut-metric.tsx","./src/components/analytics/dashboard-network-capacity-card.tsx","./src/components/analytics/dashboard-operations-flow-card.tsx","./src/components/analytics/dashboard-platform-card.tsx","./src/components/analytics/index.ts","./src/components/analytics/monitoring-health-card.tsx","./src/components/analytics/network-overview-analytics-card.tsx","./src/components/dashboard/card-dot-field.tsx","./src/components/dashboard/dashboard-kpi-grid.tsx","./src/components/dashboard/dashboard-network-health.tsx","./src/components/dashboard/dashboard-network-panel.tsx","./src/components/dashboard/dashboard-operations-breakdown.tsx","./src/components/dashboard/dashboard-prefix-trend.tsx","./src/components/dashboard/dashboard-quick-links.tsx","./src/components/directories/community-create-dialog.tsx","./src/components/directories/directories-communities-grid.tsx","./src/components/directories/directories-doh-grid.tsx","./src/components/directories/directories-row-actions.tsx","./src/components/directories/doh-profile-create-dialog.tsx","./src/components/layout/portal-shell.tsx","./src/components/lookup/lookup-add-step.tsx","./src/components/lookup/lookup-matches-grid.tsx","./src/components/lookup/lookup-search-form.tsx","./src/components/lookup/lookup-status-banner.tsx","./src/components/lookup/lookup-wizard.tsx","./src/components/modules/community-select.tsx","./src/components/modules/module-as-entry-dialog.tsx","./src/components/modules/module-cdn-source-dialog.tsx","./src/components/modules/module-create-dialog.tsx","./src/components/modules/module-domain-entry-dialog.tsx","./src/components/modules/module-edit-dialog.tsx","./src/components/modules/module-entries-grid.tsx","./src/components/modules/module-entries-section.tsx","./src/components/modules/module-ip-range-entry-dialog.tsx","./src/components/modules/module-kpi-cards.tsx","./src/components/modules/modules-list-grid.tsx","./src/components/monitoring/monitoring-ready-grid.tsx","./src/components/network/network-discovered-peers-card.tsx","./src/components/network/network-kpi.tsx","./src/components/network/network-peers-card.tsx","./src/components/network/network-peers-grid.tsx","./src/components/network/network-speakers-card.tsx","./src/components/network/network-speakers-grid.tsx","./src/components/network/peer-form-dialog.tsx","./src/components/network/speaker-form-dialog.tsx","./src/components/operations/job-timeline.tsx","./src/components/operations/operations-jobs-card.tsx","./src/components/operations/operations-jobs-grid.tsx","./src/components/operations/operations-jobs-kanban.tsx","./src/components/operations/operations-revisions-grid.tsx","./src/components/patterns/donut-breakdown-card.tsx","./src/components/patterns/illustrated-empty-state.tsx","./src/components/patterns/index.ts","./src/components/patterns/kpi-sparkline-card.tsx","./src/components/patterns/metric-tone-styles.ts","./src/components/patterns/panel-corners.tsx","./src/components/patterns/projects-empty-state.tsx","./src/components/patterns/segmented-progress-card.tsx","./src/components/portal/bits.tsx","./src/components/portal/canvas-blocks.tsx","./src/components/portal/chrome-context.tsx","./src/components/portal/chrome-store.ts","./src/components/portal/chrome.tsx","./src/components/portal/data-page-layout.ts","./src/components/portal/data-page.tsx","./src/components/portal/format.ts","./src/components/portal/kpi-strip.tsx","./src/components/portal/kpi.tsx","./src/components/portal/section-grid.tsx","./src/components/portal/tone.ts","./src/components/reui/alert.tsx","./src/components/reui/autocomplete.tsx","./src/components/reui/badge.tsx","./src/components/reui/date-selector.tsx","./src/components/reui/fLine truncated
|
||||
+81
-20
@@ -6,10 +6,14 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"evobgp/internal/agentserver"
|
||||
"evobgp/internal/agentwatch"
|
||||
"evobgp/internal/birdfmt"
|
||||
)
|
||||
|
||||
@@ -18,19 +22,22 @@ func main() {
|
||||
birdc := flag.String("birdc", "", "path to birdc binary (default: birdc from PATH)")
|
||||
socket := flag.String("socket", "", "optional birdc control socket (-s)")
|
||||
timeout := flag.Duration("timeout", 30*time.Second, "timeout for bird/birdc")
|
||||
watchEvery := flag.Duration("watch-interval", 30*time.Second, "for watch: interval between birdc configure")
|
||||
watchEvery := flag.Duration("watch-interval", 30*time.Second, "for watch: interval between configuration checks")
|
||||
listen := flag.String("listen", "", "for serve: listen address (default :8443 or EVOBGP_AGENT_LISTEN)")
|
||||
flag.Usage = func() {
|
||||
fmt.Fprintf(os.Stderr, "Usage: %s [flags] <command>\n", os.Args[0])
|
||||
fmt.Fprintf(os.Stderr, "Commands:\n")
|
||||
fmt.Fprintf(os.Stderr, " parse-check <path/to/bird.conf> run bird -c <path> -p (syntax check)\n")
|
||||
fmt.Fprintf(os.Stderr, " configure run birdc configure (reload running BIRD)\n")
|
||||
fmt.Fprintf(os.Stderr, " watch periodically run birdc configure (compose sidecar)\n")
|
||||
fmt.Fprintf(os.Stderr, " watch reload changed BIRD configuration and periodically reconcile\n")
|
||||
fmt.Fprintf(os.Stderr, " serve Panel→Node HTTP API (POST /v1/agent/sync)\n")
|
||||
flag.PrintDefaults()
|
||||
}
|
||||
flag.Parse()
|
||||
args := flag.Args()
|
||||
args, err := parseAgentArgs(flag.CommandLine, os.Args[1:])
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(2)
|
||||
}
|
||||
if len(args) < 1 {
|
||||
flag.Usage()
|
||||
os.Exit(2)
|
||||
@@ -47,10 +54,12 @@ func main() {
|
||||
switch args[0] {
|
||||
case "serve":
|
||||
runServe(*listen, *timeout)
|
||||
case "parse-check", "configure", "watch":
|
||||
case "watch":
|
||||
runWatch(ctl, *watchEvery, *timeout)
|
||||
case "parse-check", "configure":
|
||||
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
|
||||
defer cancel()
|
||||
runBirdCommand(ctx, args, ctl, *watchEvery, *timeout)
|
||||
runBirdCommand(ctx, args, ctl)
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown command: %s\n", args[0])
|
||||
flag.Usage()
|
||||
@@ -58,7 +67,47 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
func runBirdCommand(ctx context.Context, args []string, ctl *birdfmt.BirdCtl, watchEvery, timeout time.Duration) {
|
||||
// parseAgentArgs accepts flags before or immediately after the subcommand.
|
||||
// A second parse preserves previously supplied flag values and rejects unknown
|
||||
// flags instead of silently passing them to watch or serve as positional args.
|
||||
func parseAgentArgs(fs *flag.FlagSet, argv []string) ([]string, error) {
|
||||
if err := fs.Parse(argv); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
args := fs.Args()
|
||||
if len(args) == 0 {
|
||||
return args, nil
|
||||
}
|
||||
command := args[0]
|
||||
globalStop := false
|
||||
for i, consumed := 0, len(argv)-len(args); i < consumed; i++ {
|
||||
if argv[i] == "--" {
|
||||
globalStop = true
|
||||
break
|
||||
}
|
||||
name, _, hasValue := strings.Cut(strings.TrimLeft(argv[i], "-"), "=")
|
||||
if !hasValue {
|
||||
if f := fs.Lookup(name); f != nil {
|
||||
boolean, ok := f.Value.(interface{ IsBoolFlag() bool })
|
||||
if !ok || !boolean.IsBoolFlag() {
|
||||
i++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !globalStop {
|
||||
if err := fs.Parse(args[1:]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
args = append([]string{command}, fs.Args()...)
|
||||
}
|
||||
if (command == "watch" || command == "serve" || command == "configure") && len(args) != 1 {
|
||||
return nil, fmt.Errorf("%s takes no positional arguments", command)
|
||||
}
|
||||
return args, nil
|
||||
}
|
||||
|
||||
func runBirdCommand(ctx context.Context, args []string, ctl *birdfmt.BirdCtl) {
|
||||
switch args[0] {
|
||||
case "parse-check":
|
||||
if len(args) != 2 {
|
||||
@@ -78,21 +127,33 @@ func runBirdCommand(ctx context.Context, args []string, ctl *birdfmt.BirdCtl, wa
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
case "watch":
|
||||
if watchEvery <= 0 {
|
||||
fmt.Fprintln(os.Stderr, "watch-interval must be > 0")
|
||||
}
|
||||
}
|
||||
|
||||
func runWatch(ctl *birdfmt.BirdCtl, every, timeout time.Duration) {
|
||||
if every <= 0 || timeout <= 0 {
|
||||
fmt.Fprintln(os.Stderr, "watch-interval and timeout must be > 0")
|
||||
os.Exit(2)
|
||||
}
|
||||
reconcile := 5 * time.Minute
|
||||
if raw := os.Getenv("EVOBGP_AGENT_WATCH_RECONCILE_INTERVAL"); raw != "" {
|
||||
var err error
|
||||
reconcile, err = time.ParseDuration(raw)
|
||||
if err != nil || reconcile <= 0 {
|
||||
fmt.Fprintln(os.Stderr, "EVOBGP_AGENT_WATCH_RECONCILE_INTERVAL must be a positive duration")
|
||||
os.Exit(2)
|
||||
}
|
||||
log.Printf("evobgp-agent watch: birdc configure every %s (socket=%q)", watchEvery, ctl.Socket)
|
||||
for {
|
||||
cctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
err := ctl.Configure(cctx)
|
||||
cancel()
|
||||
if err != nil {
|
||||
log.Printf("evobgp-agent watch: configure: %v", err)
|
||||
}
|
||||
time.Sleep(watchEvery)
|
||||
}
|
||||
}
|
||||
active := os.Getenv("EVOBGP_BIRD_ACTIVE_DIR")
|
||||
if active == "" {
|
||||
active = "/etc/bird"
|
||||
}
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
log.Printf("evobgp-agent watch: checking %s every %s, forced reconciliation every %s (socket=%q)", active, every, reconcile, ctl.Socket)
|
||||
if err := agentwatch.Run(ctx, ctl, agentwatch.Config{ActiveDir: active, Every: every, ReconcileEvery: reconcile, CommandTimeout: timeout}); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"io"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAgentFlagsBeforeAndAfterCommand(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
args []string
|
||||
want []string
|
||||
socket, listen string
|
||||
every time.Duration
|
||||
}{
|
||||
{"legacy flags first", []string{"-socket=/custom/bird.ctl", "-watch-interval=45s", "watch"}, []string{"watch"}, "/custom/bird.ctl", "", 45 * time.Second},
|
||||
{"compose command first", []string{"watch", "-socket=/run/bird/bird.ctl", "-watch-interval=30s"}, []string{"watch"}, "/run/bird/bird.ctl", "", 30 * time.Second},
|
||||
{"serve", []string{"serve", "-listen=127.0.0.1:9999"}, []string{"serve"}, "", "127.0.0.1:9999", time.Minute},
|
||||
{"mixed", []string{"-socket=/first", "watch", "-watch-interval=5s"}, []string{"watch"}, "/first", "", 5 * time.Second},
|
||||
{"parse check", []string{"parse-check", "bird.conf"}, []string{"parse-check", "bird.conf"}, "", "", time.Minute},
|
||||
{"global delimiter", []string{"--", "parse-check", "-bird.conf"}, []string{"parse-check", "-bird.conf"}, "", "", time.Minute},
|
||||
{"flags then delimiter", []string{"-socket=/first", "--", "parse-check", "-bird.conf"}, []string{"parse-check", "-bird.conf"}, "/first", "", time.Minute},
|
||||
{"delimiter as value", []string{"-socket", "--", "watch", "-watch-interval=5s"}, []string{"watch"}, "--", "", 5 * time.Second},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
fs := flag.NewFlagSet("agent", flag.ContinueOnError)
|
||||
fs.SetOutput(io.Discard)
|
||||
socket := fs.String("socket", "", "")
|
||||
listen := fs.String("listen", "", "")
|
||||
every := fs.Duration("watch-interval", time.Minute, "")
|
||||
args, err := parseAgentArgs(fs, tc.args)
|
||||
if err != nil || !reflect.DeepEqual(args, tc.want) || *socket != tc.socket || *listen != tc.listen || *every != tc.every {
|
||||
t.Fatalf("args=%v socket=%s listen=%s every=%s err=%v", args, *socket, *listen, *every, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
func TestAgentRejectsIgnoredFlagsAndUnexpectedArguments(t *testing.T) {
|
||||
for _, argv := range [][]string{{"watch", "-unknown=1"}, {"serve", "-unknown=1"}, {"watch", "unused"}, {"serve", "unused"}, {"configure", "unused"}} {
|
||||
fs := flag.NewFlagSet("agent", flag.ContinueOnError)
|
||||
fs.SetOutput(io.Discard)
|
||||
if _, err := parseAgentArgs(fs, argv); err == nil {
|
||||
t.Fatalf("ignored argv: %v", argv)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -41,23 +41,41 @@ func main() {
|
||||
|
||||
apiBase := strings.TrimSpace(os.Getenv("EVOBGP_CONTROL_PLANE_URL"))
|
||||
apiTok := strings.TrimSpace(os.Getenv("EVOBGP_SCHEDULER_BEARER"))
|
||||
if os.Getenv("EVOBGP_SCHEDULER_STANDALONE") == "1" && apiBase == "" {
|
||||
log.Fatal("evobgp-scheduler: EVOBGP_SCHEDULER_STANDALONE=1 requires EVOBGP_CONTROL_PLANE_URL and EVOBGP_SCHEDULER_BEARER (split deploy must not use in-process jobs.Registry)")
|
||||
mode := strings.ToLower(strings.TrimSpace(os.Getenv("EVOBGP_SCHEDULER_MODE")))
|
||||
if mode == "" {
|
||||
switch {
|
||||
case apiBase != "" || apiTok != "":
|
||||
mode = "http"
|
||||
case pool != nil:
|
||||
mode = "db"
|
||||
default:
|
||||
mode = "local"
|
||||
}
|
||||
}
|
||||
deps := &scheduler.Deps{
|
||||
Store: st,
|
||||
HTTP: &http.Client{Timeout: 45 * time.Second},
|
||||
}
|
||||
if (apiBase != "") != (apiTok != "") {
|
||||
log.Fatal("evobgp-scheduler: set both EVOBGP_CONTROL_PLANE_URL and EVOBGP_SCHEDULER_BEARER for HTTP mode, or leave both empty for in-process job registry")
|
||||
}
|
||||
if apiBase != "" {
|
||||
deps.APIBase = apiBase
|
||||
deps.APIToken = apiTok
|
||||
log.Printf("evobgp-scheduler: control plane HTTP mode (%s)", apiBase)
|
||||
} else {
|
||||
deps := &scheduler.Deps{Store: st, HTTP: &http.Client{Timeout: 45 * time.Second}}
|
||||
switch mode {
|
||||
case "db":
|
||||
if pool == nil {
|
||||
log.Fatal("evobgp-scheduler: db mode requires EVOBGP_DATABASE_URL")
|
||||
}
|
||||
deps.Jobs = reg
|
||||
log.Printf("evobgp-scheduler: in-process job registry mode (use EVOBGP_CONTROL_PLANE_URL + EVOBGP_SCHEDULER_BEARER for split containers)")
|
||||
log.Print("evobgp-scheduler: trusted DB enqueue mode; API workers consume the shared durable queue")
|
||||
case "http":
|
||||
tenant := strings.TrimSpace(os.Getenv("EVOBGP_SCHEDULER_TENANT_ID"))
|
||||
if apiBase == "" || apiTok == "" || tenant == "" {
|
||||
log.Fatal("evobgp-scheduler: http mode requires EVOBGP_CONTROL_PLANE_URL, EVOBGP_SCHEDULER_BEARER and EVOBGP_SCHEDULER_TENANT_ID")
|
||||
}
|
||||
deps.APIBase, deps.APIToken, deps.APITenantID = apiBase, apiTok, tenant
|
||||
log.Print("evobgp-scheduler: single-tenant HTTP enqueue mode")
|
||||
case "local":
|
||||
if os.Getenv("EVOBGP_SCHEDULER_STANDALONE") == "1" || pool != nil {
|
||||
log.Fatal("evobgp-scheduler: local mode is for in-process development only; use db for a shared PostgreSQL queue")
|
||||
}
|
||||
deps.Jobs = reg
|
||||
defer func() { reg.RequestCancelAll(); _ = reg.Drain(context.Background()) }()
|
||||
log.Print("evobgp-scheduler: local development mode")
|
||||
default:
|
||||
log.Fatal("evobgp-scheduler: EVOBGP_SCHEDULER_MODE must be db, http or local")
|
||||
}
|
||||
scheduler.Run(ctx, deps)
|
||||
}
|
||||
@@ -4,5 +4,17 @@
|
||||
EVOBGP_REGISTRY=git.shx.one/denozord
|
||||
EVOBGP_IMAGE_TAG=latest
|
||||
|
||||
# Stable ID of the local BIRD node for telemetry. Set uniquely per actual BIRD node
|
||||
# when several control-plane deployments share one PostgreSQL database.
|
||||
# EVOBGP_TELEMETRY_LOCAL_ID=bird2-node-a
|
||||
|
||||
# Prometheus: ограничение TSDB по размеру (WAL и head требуют отдельного запаса).
|
||||
# EVOBGP_PROMETHEUS_RETENTION_SIZE=2GB
|
||||
|
||||
# Для защищенного Web UI (Traefik + Let's Encrypt + Cloudflare DNS challenge)
|
||||
# используйте второй файл рядом: .env.web-sec (шаблон: .env.web-sec.example)
|
||||
|
||||
# Reference Compose hardcodes EVOBGP_SCHEDULER_MODE=db (trusted, all tenant scopes).
|
||||
# Setting HTTP variables in this .env DOES NOT switch the scheduler mode.
|
||||
# To use a single-tenant HTTP scheduler, explicitly override its service environment
|
||||
# with MODE=http plus URL, bearer and tenant; see docs/quickstart.md.
|
||||
@@ -4,6 +4,13 @@
|
||||
EVOBGP_REGISTRY=git.shx.one/denozord
|
||||
EVOBGP_IMAGE_TAG=latest
|
||||
|
||||
# Уникальный стабильный ID локального BIRD-узла в общей БД; не меняйте при рестарте.
|
||||
# EVOBGP_TELEMETRY_LOCAL_ID=bird2-node-a
|
||||
|
||||
# Optional NATS для внешних клиентов: COMPOSE_PROFILES=nats
|
||||
# Prometheus: TSDB retention size; WAL/head требуют дополнительного места.
|
||||
# EVOBGP_PROMETHEUS_RETENTION_SIZE=2GB
|
||||
|
||||
# Имя compose-проекта (должно совпадать с name: в docker-compose.yaml)
|
||||
COMPOSE_PROJECT_NAME=evobgp-microvps-full
|
||||
|
||||
|
||||
@@ -7,6 +7,13 @@
|
||||
EVOBGP_REGISTRY=git.shx.one/denozord
|
||||
EVOBGP_IMAGE_TAG=latest
|
||||
|
||||
# Уникальный стабильный ID локального BIRD-узла в общей БД; не меняйте при рестарте.
|
||||
# EVOBGP_TELEMETRY_LOCAL_ID=bird2-node-a
|
||||
|
||||
# Optional NATS для внешних клиентов: COMPOSE_PROFILES=nats
|
||||
# Prometheus: TSDB retention size; WAL/head требуют дополнительного места.
|
||||
# EVOBGP_PROMETHEUS_RETENTION_SIZE=2GB
|
||||
|
||||
# Защищенный Web UI (Traefik + Let's Encrypt + Cloudflare DNS challenge)
|
||||
WEBUI_DOMAIN=bgp.shz.su
|
||||
WEBUI_IP_WHITELIST=109.174.26.78/32,87.103.241.8/32
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Дополнительный файл Compose: профиль microvps-full + ужатые лимиты под хост ~1 ГиБ RAM.
|
||||
# Дополнительный файл Compose: профиль microvps-full + ресурсные лимиты.
|
||||
#
|
||||
# Запуск (PowerShell, из каталога deploy\compose): сначала .env из .env.example, docker login git.shx.one, docker compose pull
|
||||
# docker compose -f docker-compose.yaml -f docker-compose.microvps-full.yaml --profile microvps-full up -d
|
||||
@@ -6,13 +6,13 @@
|
||||
# Остановка:
|
||||
# docker compose -f docker-compose.yaml -f docker-compose.microvps-full.yaml --profile microvps-full down
|
||||
#
|
||||
# Лимиты под хост ~1 ГиБ: сумма лимитов контейнеров ~800 МиБ + запас ОС/Docker. При OOM увеличьте RAM VPS или уберите prometheus/nats из профиля (отдельная правка compose).
|
||||
# Сумма memory limits с edge и без optional NATS — 984M, это не замер потребления.
|
||||
# Пригодность для хоста ~1 ГиБ требует workload-теста с запасом ОС/Docker и BIRD peaks.
|
||||
# Postgres ниже ~240–256 МиБ часто не успевает пройти healthcheck / падает по памяти — не уменьшайте postgres без tuned postgresql.conf.
|
||||
|
||||
services:
|
||||
evobgp-all:
|
||||
environment:
|
||||
EVOBGP_BROKER_URL: nats://nats:4222
|
||||
EVOBGP_SERVICE: evobgp-all
|
||||
EVOBGP_RUNTIME_LOGS_DIR: /opt/evobgp/runtime-logs
|
||||
volumes:
|
||||
|
||||
@@ -62,6 +62,7 @@ services:
|
||||
max-file: "3"
|
||||
|
||||
nats:
|
||||
profiles: ["nats"]
|
||||
image: nats:2.10-alpine
|
||||
restart: unless-stopped
|
||||
command: ["-js", "-m", "8222"]
|
||||
@@ -104,7 +105,7 @@ services:
|
||||
- bird_etc:/etc/bird
|
||||
- bird_run:/run/bird
|
||||
entrypoint: ["/usr/local/bin/evobgp-agent"]
|
||||
command: ["watch", "-socket=/run/bird/bird.ctl", "-watch-interval=30s"]
|
||||
command: ["-socket=/run/bird/bird.ctl", "-watch-interval=30s", "watch"]
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
@@ -117,20 +118,18 @@ services:
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
nats:
|
||||
condition: service_started
|
||||
bird2:
|
||||
condition: service_started
|
||||
ports:
|
||||
- "8080:8080"
|
||||
environment:
|
||||
EVOBGP_DATABASE_URL: postgres://evobgp:evobgp@postgres:5432/evobgp?sslmode=disable
|
||||
# EVOBGP_BROKER_URL: nats://nats:4222
|
||||
EVOBGP_NODE_DISPATCH_ENABLED: "1"
|
||||
EVOBGP_BUNDLE_SEED_HEX: "bd8fbcd31545aacfdd228203beca8e945ab9a752f2ce5624cf42d9f316389a9d"
|
||||
EVOBGP_HTTP_ADDR: ":8080"
|
||||
EVOBGP_SEED_DEMO: "1"
|
||||
EVOBGP_BIRDC_SOCKET: /run/bird/bird.ctl
|
||||
EVOBGP_TELEMETRY_LOCAL_ID: ${EVOBGP_TELEMETRY_LOCAL_ID:-}
|
||||
EVOBGP_BIRDC_INTERVAL: 30s
|
||||
EVOBGP_BIRD_ACTIVE_DIR: /etc/bird
|
||||
EVOBGP_BIRD_STAGING_DIR: /tmp/evobgp-bird-staging
|
||||
@@ -233,10 +232,13 @@ services:
|
||||
target: /etc/prometheus/prometheus.yml
|
||||
- source: prometheus_alerts_yml
|
||||
target: /etc/prometheus/alerts.yml
|
||||
volumes:
|
||||
- prometheus_data:/prometheus
|
||||
command:
|
||||
- --config.file=/etc/prometheus/prometheus.yml
|
||||
- --storage.tsdb.path=/prometheus
|
||||
- --storage.tsdb.retention.time=7d
|
||||
- --storage.tsdb.retention.size=${EVOBGP_PROMETHEUS_RETENTION_SIZE:-2GB}
|
||||
- --web.enable-lifecycle
|
||||
logging:
|
||||
driver: json-file
|
||||
@@ -285,6 +287,7 @@ services:
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
prometheus_data:
|
||||
bird_etc:
|
||||
bird_run:
|
||||
traefik_letsencrypt:
|
||||
|
||||
@@ -9,10 +9,10 @@
|
||||
# docker compose --profile microvps up -d
|
||||
# microVPS ~1 ГиБ + Web UI — docs/quickstart.md и docker-compose.microvps-full.yaml
|
||||
#
|
||||
# reference: 5× Go (api + scheduler + ingest + render + deploy) + postgres + NATS JetStream + bird2 + agent + web.
|
||||
# Async jobs выполняются in-process в evobgp-api (jobs.Registry); NATS — задел под брокер (см. docs/architecture.md).
|
||||
# reference: 5× Go (api + scheduler + ingest + render + deploy) + postgres + bird2 + agent + web.
|
||||
# Async jobs выполняются in-process в evobgp-api (jobs.Registry); NATS — опция `--profile nats` (см. docs/architecture.md).
|
||||
# microvps: evobgp-all + postgres + bird2 + agent (без брокера и без UI).
|
||||
# microvps-full: как microvps + evobgp-web-microvps (порт 3000) + nats + prometheus-microvps (порт 9090).
|
||||
# microvps-full: как microvps + evobgp-web-microvps (порт 3000) + prometheus-microvps (порт 9090).
|
||||
#
|
||||
# Опция microVPS_sqlite из плана (без контейнера Postgres): требует реализации store на SQLite в приложении;
|
||||
# пока используйте microvps с контейнерным Postgres (единые миграции).
|
||||
@@ -35,7 +35,6 @@ x-logging: &default-logging
|
||||
|
||||
x-env-ref: &env-ref
|
||||
EVOBGP_DATABASE_URL: postgres://evobgp:evobgp@postgres:5432/evobgp?sslmode=disable
|
||||
EVOBGP_BROKER_URL: nats://nats:4222
|
||||
|
||||
x-env-micro: &env-micro
|
||||
EVOBGP_DATABASE_URL: postgres://evobgp:evobgp@postgres:5432/evobgp?sslmode=disable
|
||||
@@ -67,7 +66,7 @@ services:
|
||||
memory: 384M
|
||||
|
||||
nats:
|
||||
profiles: ["reference", "microvps-full"]
|
||||
profiles: ["nats"]
|
||||
image: nats:2.10-alpine
|
||||
restart: unless-stopped
|
||||
command: ["-js", "-m", "8222"]
|
||||
@@ -86,8 +85,6 @@ services:
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
nats:
|
||||
condition: service_started
|
||||
bird2:
|
||||
condition: service_started
|
||||
ports:
|
||||
@@ -99,6 +96,7 @@ services:
|
||||
# DEV ONLY — не для production. Bearer dev + слабые demo-секреты (см. docs/access.md).
|
||||
EVOBGP_DEV_INSECURE: "1"
|
||||
EVOBGP_BIRDC_SOCKET: /run/bird/bird.ctl
|
||||
EVOBGP_TELEMETRY_LOCAL_ID: ${EVOBGP_TELEMETRY_LOCAL_ID:-}
|
||||
EVOBGP_BIRDC_INTERVAL: 30s
|
||||
EVOBGP_BIRD_ACTIVE_DIR: /etc/bird
|
||||
EVOBGP_BIRD_STAGING_DIR: /tmp/evobgp-bird-staging
|
||||
@@ -120,16 +118,13 @@ services:
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
nats:
|
||||
condition: service_started
|
||||
evobgp-api:
|
||||
condition: service_started
|
||||
environment:
|
||||
<<: *env-ref
|
||||
# Split deploy: scheduler must enqueue jobs via API, not in-process Registry (ARCH-04).
|
||||
# Trusted DB scheduler enqueues into the shared durable queue; API workers claim jobs.
|
||||
EVOBGP_SCHEDULER_STANDALONE: "1"
|
||||
EVOBGP_CONTROL_PLANE_URL: http://evobgp-api:8080
|
||||
EVOBGP_SCHEDULER_BEARER: dev
|
||||
EVOBGP_SCHEDULER_MODE: db
|
||||
logging: *default-logging
|
||||
deploy:
|
||||
resources:
|
||||
@@ -144,8 +139,6 @@ services:
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
nats:
|
||||
condition: service_started
|
||||
environment:
|
||||
<<: *env-ref
|
||||
logging: *default-logging
|
||||
@@ -162,8 +155,6 @@ services:
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
nats:
|
||||
condition: service_started
|
||||
environment:
|
||||
<<: *env-ref
|
||||
logging: *default-logging
|
||||
@@ -180,8 +171,6 @@ services:
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
nats:
|
||||
condition: service_started
|
||||
environment:
|
||||
<<: *env-ref
|
||||
EVOBGP_BIRD_ACTIVE_DIR: /etc/bird
|
||||
@@ -225,6 +214,7 @@ services:
|
||||
EVOBGP_HTTP_ADDR: ":8080"
|
||||
EVOBGP_SEED_DEMO: "1"
|
||||
EVOBGP_BIRDC_SOCKET: /run/bird/bird.ctl
|
||||
EVOBGP_TELEMETRY_LOCAL_ID: ${EVOBGP_TELEMETRY_LOCAL_ID:-}
|
||||
EVOBGP_BIRDC_INTERVAL: 30s
|
||||
EVOBGP_BIRD_ACTIVE_DIR: /etc/bird
|
||||
EVOBGP_BIRD_STAGING_DIR: /tmp/evobgp-bird-staging
|
||||
@@ -315,10 +305,12 @@ services:
|
||||
volumes:
|
||||
- ../prometheus/prometheus-microvps.yml:/etc/prometheus/prometheus.yml:ro
|
||||
- ../prometheus/alerts.yml:/etc/prometheus/alerts.yml:ro
|
||||
- prometheus_data:/prometheus
|
||||
command:
|
||||
- --config.file=/etc/prometheus/prometheus.yml
|
||||
- --storage.tsdb.path=/prometheus
|
||||
- --storage.tsdb.retention.time=7d
|
||||
- --storage.tsdb.retention.size=${EVOBGP_PROMETHEUS_RETENTION_SIZE:-2GB}
|
||||
- --web.enable-lifecycle
|
||||
logging: *default-logging
|
||||
deploy:
|
||||
@@ -359,7 +351,7 @@ services:
|
||||
- bird_etc:/etc/bird
|
||||
- bird_run:/run/bird
|
||||
entrypoint: ["/usr/local/bin/evobgp-agent"]
|
||||
command: ["watch", "-socket=/run/bird/bird.ctl", "-watch-interval=30s"]
|
||||
command: ["-socket=/run/bird/bird.ctl", "-watch-interval=30s", "watch"]
|
||||
logging: *default-logging
|
||||
deploy:
|
||||
resources:
|
||||
@@ -377,10 +369,12 @@ services:
|
||||
volumes:
|
||||
- ../prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
||||
- ../prometheus/alerts.yml:/etc/prometheus/alerts.yml:ro
|
||||
- prometheus_data:/prometheus
|
||||
command:
|
||||
- --config.file=/etc/prometheus/prometheus.yml
|
||||
- --storage.tsdb.path=/prometheus
|
||||
- --storage.tsdb.retention.time=15d
|
||||
- --storage.tsdb.retention.size=${EVOBGP_PROMETHEUS_RETENTION_SIZE:-2GB}
|
||||
- --web.enable-lifecycle
|
||||
logging: *default-logging
|
||||
deploy:
|
||||
@@ -390,6 +384,7 @@ services:
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
prometheus_data:
|
||||
bird_etc:
|
||||
bird_run:
|
||||
traefik_letsencrypt:
|
||||
@@ -1,4 +1,5 @@
|
||||
# Standalone stack (без profiles): Portainer / `docker compose -f stack.microvps-full.yaml up -d`
|
||||
# Standalone stack: Portainer / `docker compose -f stack.microvps-full.yaml up -d`.
|
||||
# Основные сервисы запускаются без профиля; NATS доступен только в профиле `nats`.
|
||||
# Эквивалент microvps-full из docker-compose.yaml + docker-compose.microvps-full.yaml,
|
||||
# с встроенными configs для Prometheus (как у вас).
|
||||
#
|
||||
@@ -66,6 +67,7 @@ services:
|
||||
max-file: "3"
|
||||
|
||||
nats:
|
||||
profiles: ["nats"]
|
||||
image: nats:2.10-alpine
|
||||
restart: unless-stopped
|
||||
command: ["-js", "-m", "8222"]
|
||||
@@ -108,7 +110,7 @@ services:
|
||||
- bird_etc:/etc/bird
|
||||
- bird_run:/run/bird
|
||||
entrypoint: ["/usr/local/bin/evobgp-agent"]
|
||||
command: ["watch", "-socket=/run/bird/bird.ctl", "-watch-interval=30s"]
|
||||
command: ["-socket=/run/bird/bird.ctl", "-watch-interval=30s", "watch"]
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
@@ -121,18 +123,16 @@ services:
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
nats:
|
||||
condition: service_started
|
||||
bird2:
|
||||
condition: service_started
|
||||
ports:
|
||||
- "8080:8080"
|
||||
environment:
|
||||
EVOBGP_DATABASE_URL: postgres://evobgp:evobgp@postgres:5432/evobgp?sslmode=disable
|
||||
EVOBGP_BROKER_URL: nats://nats:4222
|
||||
EVOBGP_HTTP_ADDR: ":8080"
|
||||
EVOBGP_SEED_DEMO: "1"
|
||||
EVOBGP_BIRDC_SOCKET: /run/bird/bird.ctl
|
||||
EVOBGP_TELEMETRY_LOCAL_ID: ${EVOBGP_TELEMETRY_LOCAL_ID:-}
|
||||
EVOBGP_BIRDC_INTERVAL: 30s
|
||||
EVOBGP_BIRD_ACTIVE_DIR: /etc/bird
|
||||
EVOBGP_BIRD_STAGING_DIR: /tmp/evobgp-bird-staging
|
||||
@@ -237,10 +237,13 @@ services:
|
||||
target: /etc/prometheus/prometheus.yml
|
||||
- source: prometheus_alerts_yml
|
||||
target: /etc/prometheus/alerts.yml
|
||||
volumes:
|
||||
- prometheus_data:/prometheus
|
||||
command:
|
||||
- --config.file=/etc/prometheus/prometheus.yml
|
||||
- --storage.tsdb.path=/prometheus
|
||||
- --storage.tsdb.retention.time=7d
|
||||
- --storage.tsdb.retention.size=${EVOBGP_PROMETHEUS_RETENTION_SIZE:-2GB}
|
||||
- --web.enable-lifecycle
|
||||
logging:
|
||||
driver: json-file
|
||||
@@ -361,6 +364,7 @@ services:
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
prometheus_data:
|
||||
bird_etc:
|
||||
bird_run:
|
||||
traefik_letsencrypt:
|
||||
|
||||
@@ -14,20 +14,20 @@ BuildKit кэширует `/go/pkg/mod`, `~/.cache/go-build` и pnpm store че
|
||||
|
||||
| Образ | Runtime base | Заметка |
|
||||
|-------|----------------|---------|
|
||||
| scheduler, ingest, render | `gcr.io/distroless/static-debian12:nonroot` | static Go (`CGO_ENABLED=0`), без shell |
|
||||
| api, all, deploy, node | `debian:bookworm-slim` + `bird` + `birdc` | `bird -p` (parse-check) и `birdc`; демон не запускается |
|
||||
| scheduler, ingest, render, deploy | `gcr.io/distroless/static-debian12:nonroot` | static Go (`CGO_ENABLED=0`), без shell; deploy только сверяет drift |
|
||||
| api, all, node | `debian:bookworm-slim` + `bird` + `birdc` | `bird -p` (parse-check) и `birdc`; демон не запускается |
|
||||
| agent | тот же Ubuntu+bird2, что bird2 | общие слои с `evobgp-bird2` |
|
||||
| bird2 | Ubuntu Noble + пакет bird2 | |
|
||||
| web, web-all | `nginx:1.27-alpine` | `worker_processes 1` |
|
||||
|
||||
Сборка Go: `golang:1.24-alpine`. Context режется корневым [.dockerignore](../../.dockerignore).
|
||||
Сборка Go: `golang:1.24-alpine`. Для Go Dockerfile действует отдельный [Dockerfile.dockerignore](gobinary/Dockerfile.dockerignore): в контексте остаются только Go-модуль, исходники, миграции, встроенные shell-скрипты и сборочный скрипт BIRD. Web-исходники не входят в Go-контекст. Кэш Go build также зависит от `GIT_SHA` и `BUILD_TIME`, поэтому эффект для web-only коммитов требует замера. Web context режется корневым [.dockerignore](../../.dockerignore).
|
||||
|
||||
### Кэш registry
|
||||
|
||||
- `git.shx.one/<owner>/evobgp-buildcache:go-buildcache` — **запись** только из `go-build-all`
|
||||
- `git.shx.one/<owner>/evobgp-buildcache:web-buildcache` — **запись** только из `web-build`
|
||||
- `git.shx.one/<owner>/evobgp-buildcache:birdc-buildcache` — **запись** только из `go-birdc`
|
||||
- `git.shx.one/<owner>/evobgp-buildcache:base-*` — зеркало FROM с **Docker Hub** (`docker.io/library/…`; distroless — `gcr.io`). Только `linux/amd64`; skip если тег уже в Gitea. Неуспешный copy не валит CD — bake берёт Docker Hub FROM для этой базы.
|
||||
- `git.shx.one/<owner>/evobgp-buildcache:base-*` — зеркало FROM с **Docker Hub** (`docker.io/library/…`; distroless — `gcr.io`). Только `linux/amd64`; существующий тег не копируется повторно, а в bake передаётся `repo@sha256:…`. `MIRROR_REFRESH=1` принудительно обновляет зеркало и завершает скрипт с ошибкой, если copy или digest lookup не удались. Без refresh неуспешный copy или digest lookup оставляет исходный upstream FROM для этой базы.
|
||||
|
||||
`pull = false` в bake: не перекачивать FROM, если слой уже в builder. Не запускайте `docker system prune -a` на runner.
|
||||
|
||||
|
||||
@@ -236,7 +236,7 @@ target "evobgp-render" {
|
||||
}
|
||||
|
||||
target "evobgp-deploy" {
|
||||
inherits = ["_go-runtime-birdc"]
|
||||
inherits = ["_go-runtime"]
|
||||
args = { BIN = "evobgp-deploy" }
|
||||
tags = image-tags("evobgp-deploy")
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
# Универсальная сборка бинарей cmd/* (ARG BIN) или всех сразу (target build-all).
|
||||
# Воркеры (runtime): distroless static. api/all/deploy/node (runtime-birdc): debian-slim + bird + birdc.
|
||||
# Воркеры, включая deploy (runtime): distroless static. api/all/node (runtime-birdc): debian-slim + bird + birdc.
|
||||
# CI: docker buildx bake -f deploy/docker/docker-bake.hcl
|
||||
ARG BASE_GOLANG=docker.io/library/golang:1.24-alpine
|
||||
ARG BASE_DEBIAN=docker.io/library/debian:bookworm-slim
|
||||
@@ -64,7 +64,7 @@ COPY --from=build-all /out/${BIN} /usr/local/bin/evobgp
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["/usr/local/bin/evobgp"]
|
||||
|
||||
# api / all / deploy / node — bird -p (parse-check) + birdc configure.
|
||||
# api / all / node — bird -p (parse-check) + birdc configure.
|
||||
# Демон BIRD в этом контейнере не запускается; процесс bird — в образе evobgp-bird2.
|
||||
FROM ${BASE_DEBIAN} AS runtime-birdc
|
||||
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# Go builds use only the Go module and assets embedded into the binaries.
|
||||
# This file takes precedence over the repository .dockerignore for this Dockerfile.
|
||||
*
|
||||
!go.mod
|
||||
!go.sum
|
||||
!cmd/
|
||||
!cmd/**
|
||||
!internal/
|
||||
!internal/**
|
||||
!migrations/
|
||||
!migrations/**
|
||||
!deploy/
|
||||
!deploy/docker/
|
||||
!deploy/docker/bird/
|
||||
!deploy/docker/bird/bird-from-source.sh
|
||||
**/*_test.go
|
||||
**/*.md
|
||||
@@ -4,7 +4,8 @@
|
||||
# REGISTRY=git.shx.one/<owner> (без суффикса /evobgp-buildcache)
|
||||
#
|
||||
# Источник library-образов — Docker Hub (docker.io), не public.ecr.aws (429 Too Many Requests).
|
||||
# Если тег уже есть в Gitea — skip (без повторного pull).
|
||||
# Если тег уже есть в Gitea — skip. MIRROR_REFRESH=1 принудительно обновляет зеркала.
|
||||
# В bake передаётся immutable digest зеркала, а не его изменяемый tag.
|
||||
# Копируем только linux/amd64 — bake platforms совпадает, multi-arch индекс не нужен.
|
||||
# Шаг CD не должен падать: неуспешный copy оставляет bake на Docker Hub FROM для этой базы.
|
||||
set -eu
|
||||
@@ -13,6 +14,12 @@ CACHE_REPO="${REGISTRY}/evobgp-buildcache"
|
||||
ENV_FILE="${MIRROR_ENV_FILE:-}"
|
||||
PLATFORM="${MIRROR_PLATFORM:-linux/amd64}"
|
||||
RETRIES="${MIRROR_RETRIES:-4}"
|
||||
REFRESH="${MIRROR_REFRESH:-0}"
|
||||
|
||||
case "$REFRESH" in
|
||||
0|1) ;;
|
||||
*) echo "MIRROR_REFRESH must be 0 or 1" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
tmp="$(mktemp)"
|
||||
cleanup() { rm -f "$tmp"; }
|
||||
@@ -22,6 +29,14 @@ dest_exists() {
|
||||
docker buildx imagetools inspect "$1" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
dest_digest() {
|
||||
digest="$(docker buildx imagetools inspect --format '{{.Manifest.Digest}}' "$1")" || return 1
|
||||
case "$digest" in
|
||||
sha256:????????????????????????????????????????????????????????????????) printf '%s\n' "$digest" ;;
|
||||
*) echo "invalid mirror digest for $1: $digest" >&2; return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
copy_retry() {
|
||||
src="$1"
|
||||
dest="$2"
|
||||
@@ -31,9 +46,11 @@ copy_retry() {
|
||||
if docker buildx imagetools create --platform "$PLATFORM" --tag "$dest" "$src"; then
|
||||
return 0
|
||||
fi
|
||||
delay=$((n * 25))
|
||||
echo "mirror retry ${n}/${RETRIES}, sleep ${delay}s: ${dest}"
|
||||
sleep "$delay"
|
||||
if [ "$n" -lt "$RETRIES" ]; then
|
||||
delay=$((n * 25))
|
||||
echo "mirror retry ${n}/${RETRIES}, sleep ${delay}s: ${dest}"
|
||||
sleep "$delay"
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
@@ -43,30 +60,36 @@ mirror() {
|
||||
tag="$2"
|
||||
var="$3"
|
||||
dest="${CACHE_REPO}:${tag}"
|
||||
if dest_exists "$dest"; then
|
||||
if [ "$REFRESH" = 0 ] && dest_exists "$dest"; then
|
||||
echo "skip (already in registry): ${dest}"
|
||||
printf '%s=%s\n' "$var" "$dest" >>"$tmp"
|
||||
else
|
||||
echo "mirror ${src} -> ${dest} (${PLATFORM})"
|
||||
if ! copy_retry "$src" "$dest"; then
|
||||
if [ "$REFRESH" = 1 ]; then
|
||||
echo "error: refresh failed for ${dest}" >&2
|
||||
return 1
|
||||
fi
|
||||
echo "warn: ${dest} not mirrored — bake uses upstream FROM for ${var}"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
if digest="$(dest_digest "$dest")"; then
|
||||
printf '%s=%s@%s\n' "$var" "$CACHE_REPO" "$digest" >>"$tmp"
|
||||
return 0
|
||||
fi
|
||||
echo "mirror ${src} -> ${dest} (${PLATFORM})"
|
||||
if copy_retry "$src" "$dest"; then
|
||||
printf '%s=%s\n' "$var" "$dest" >>"$tmp"
|
||||
return 0
|
||||
if [ "$REFRESH" = 1 ]; then
|
||||
echo "error: cannot resolve refreshed digest for ${dest}" >&2
|
||||
return 1
|
||||
fi
|
||||
echo "warn: ${dest} not mirrored — bake uses Docker Hub FROM for ${var}"
|
||||
echo "warn: cannot resolve digest for ${dest} — bake uses upstream FROM for ${var}"
|
||||
return 0
|
||||
}
|
||||
|
||||
mirror "docker.io/library/golang:1.24-alpine" "base-golang-1.24-alpine" BASE_GOLANG
|
||||
sleep 8
|
||||
mirror "docker.io/library/debian:bookworm-slim" "base-debian-bookworm-slim" BASE_DEBIAN
|
||||
sleep 8
|
||||
mirror "docker.io/library/node:22-alpine" "base-node-22-alpine" BASE_NODE
|
||||
sleep 8
|
||||
mirror "docker.io/library/nginx:1.27-alpine" "base-nginx-1.27-alpine" BASE_NGINX
|
||||
sleep 8
|
||||
mirror "docker.io/library/ubuntu:noble" "base-ubuntu-noble" BASE_UBUNTU
|
||||
sleep 8
|
||||
mirror "gcr.io/distroless/static-debian12:nonroot" "base-distroless-static-debian12-nonroot" BASE_DISTROLESS
|
||||
|
||||
echo "----- mirrored BASE_* -----"
|
||||
|
||||
@@ -3,7 +3,7 @@ groups:
|
||||
- name: evobgp-controlplane
|
||||
rules:
|
||||
- alert: EvoBGPMaterializedPrefixesZero
|
||||
expr: evobgp_materialized_prefixes_max == 0
|
||||
expr: evobgp_stored_revision_materialized_prefixes_max == 0 and evobgp_store_metrics_collection_success == 1
|
||||
for: 2h
|
||||
labels:
|
||||
severity: warning
|
||||
@@ -11,6 +11,15 @@ groups:
|
||||
summary: "В сторе control-plane нет ни одной ревизии с ненулевым materialized_prefix_count."
|
||||
description: "Проверьте render/ingest и наличие ревизий."
|
||||
|
||||
- alert: EvoBGPStoreMetricsCollectionFailed
|
||||
expr: evobgp_store_metrics_collection_success == 0
|
||||
for: 5m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "Не удалось прочитать агрегаты хранилища для /metrics."
|
||||
description: "Проверьте PostgreSQL и ошибки API; отсутствие агрегатов не означает нуль."
|
||||
|
||||
- alert: EvoBGPBirdEstablishedMissing
|
||||
expr: |
|
||||
evobgp_bird_protocols_scrape_success == 1
|
||||
|
||||
@@ -16,6 +16,13 @@
|
||||
| [overview.md](overview.md) | Ключевые возможности системы |
|
||||
| [quickstart.md](quickstart.md) | Быстрый запуск: готовые образы из `git.shx.one`, Docker Compose, локальный Go, веб |
|
||||
| [architecture.md](architecture.md) | Компоненты, потоки данных, пакеты |
|
||||
| [backend-improvement-plan.md](backend-improvement-plan.md) | План улучшения backend, статистики и Docker; статус выполнения |
|
||||
| [backend-baseline.md](backend-baseline.md) | Этап 0: ресурсные snapshots и воспроизведение дефектов |
|
||||
| [backend-compatibility.md](backend-compatibility.md) | Проверяемые legacy-контракты и оставшиеся проверки старых speakers |
|
||||
| [bundle-limits.md](bundle-limits.md) | Пределы загрузки/распаковки бандлов и безопасные пути файлов |
|
||||
| [outbound-policy-decision.md](outbound-policy-decision.md) | Разрешённые внутренние DoH/RIPE, отдельная защита CDN и пределы внешних ответов |
|
||||
| [metrics-semantics.md](metrics-semantics.md) | Исторические, inventory и live-метрики; совместимость имён и ошибки сбора |
|
||||
| [docker-resources.md](docker-resources.md) | Optional NATS, retention и перенос истории Prometheus, границы проверки Docker |
|
||||
| [manual.md](manual.md) | Подробное руководство по модулям, процессам и API |
|
||||
| [api.md](api.md) | REST: префикс `/v1`, публичные маршруты, ссылки на OpenAPI |
|
||||
| [router-lists-ui-integration.md](router-lists-ui-integration.md) | Интеграция `router-lists-ui` с EvoBGP API (`DOMAINS/IP_RANGES/AS_PREFIXES/communities`) |
|
||||
|
||||
@@ -37,6 +37,18 @@ curl -sS https://bgp.shnt.top/v1/auth/config
|
||||
|
||||
UI: `VITE_AUTH_ENABLED`, `VITE_AUTH_PORTAL_URL`. App Switcher: `CURRENT_APP_ID=bgp`, конфиг с `GET {portal}/api/v1/app-switcher`.
|
||||
|
||||
## Доверенные reverse proxy и публичный URL реплик
|
||||
|
||||
По умолчанию API считает соединение прямым: для аудита и лимита `EVOBGP_AUTH_RATE_LIMIT` используется IP TCP-пира, а `X-Forwarded-For`, `X-Real-IP`, `X-Forwarded-Host`, `X-Forwarded-Proto` и `Origin` не задают IP или публичный URL. Лимит авторизации — до 60 запросов в минуту на IP по умолчанию; значение `EVOBGP_AUTH_RATE_LIMIT` принимается в диапазоне 1–1000. Таблица лимитера ограничена 4096 активными IP; при заполнении новые IP получают `429` до истечения окна, действующие счётчики не вытесняются.
|
||||
|
||||
Для API за reverse proxy задайте `EVOBGP_TRUSTED_PROXY_CIDRS` списком **только адресов сетевых интерфейсов доверенных proxy**, например `10.20.0.5/32,10.20.0.6/32`. Пустое или некорректное значение отключает доверие к заголовкам. API разбирает `X-Forwarded-For` справа налево от TCP-пира и останавливается на первом недоверенном адресе; некорректная ближайшая цепочка возвращает IP TCP-пира. Proxy должен удалять клиентские `X-Forwarded-*` и устанавливать собственные `X-Forwarded-For`, `X-Forwarded-Host` и `X-Forwarded-Proto`. Не добавляйте широкие CIDR клиентов или всего Docker-окружения, если в них возможны прямые подключения к API.
|
||||
|
||||
При создании удалённого speaker безопаснее явно передать публичный HTTPS URL в `control_plane_url`: это значение имеет приоритет для команды установки. Если поле не задано, API использует валидные `X-Forwarded-Host` и `X-Forwarded-Proto` только от доверенного proxy, иначе схему прямого соединения и `Host`. `Origin` никогда не определяет адрес backend. Для deployment за proxy проверьте, что в результате создания speaker команда установки содержит внешний URL, доступный ноде.
|
||||
|
||||
## Исходящие запросы к CDN
|
||||
|
||||
Клиент CDN выполняет проверку DNS и назначения при локальном соединении и не использует `HTTP_PROXY`, `HTTPS_PROXY` или `NO_PROXY`: все CDN-запросы идут напрямую. Через внешний proxy нельзя гарантировать, что проверенный локально IP совпадёт с адресом, к которому подключится proxy. DNS разрешается заново для каждого нового соединения; перенаправления допускаются только на HTTPS URL без учётных данных. `EVOBGP_CDN_ALLOW_PRIVATE=1` явно разрешает приватные назначения **только для HTTPS** CDN URL; для HTTP этот флаг исключения не делает. Это правило не меняет приватный транспорт node agent.
|
||||
|
||||
## API-ключи (`EVOBGP_API_KEYS`)
|
||||
|
||||
Формат переменной окружения: список записей через **запятую** без пробелов внутри логики парсера (пробелы вокруг записей допускаются при обрезке). Каждая запись:
|
||||
|
||||
+36
@@ -23,6 +23,14 @@
|
||||
|
||||
Все остальные запросы под **`/v1/...`**, кроме перечисленных выше трёх `GET`, проходят через middleware и требуют **`Authorization: Bearer <api_key>`** (см. [access.md](access.md)).
|
||||
|
||||
## Ограничение тела запроса
|
||||
|
||||
После аутентификации все непубличные `/v1/...` запросы проверяются целиком до вызова handler: максимум **8 MiB (8388608 bytes)**. Проверка действует для любого метода, включая GET, без зависимости от `Content-Type` или `Content-Length`; JSON с большим хвостом whitespace не позволяет выполнить мутацию до обнаружения превышения. Ответ — `413 application/problem+json`, без выполнения запрошенной мутации. Ошибка чтения возвращает `400`, отмена/таймаут чтения — `408`.
|
||||
|
||||
Запросы с телом допускаются без очереди ожидания: по умолчанию **4 одновременно на API instance**. `EVOBGP_API_BODY_MAX_CONCURRENT` задаёт число от 1 до 64; невалидное значение выбирает default 4. Слот удерживается до завершения handler, пока его request buffer может использоваться. При занятых слотах — немедленный `429` с `Retry-After: 1`. Пустые `http.NoBody` запросы, включая обычные node GET/pull, слота не занимают; указание `Content-Length: 0` при фактически существующем body не отключает ограничение.
|
||||
|
||||
CSV сохраняет отдельные ограничения своей операции: 8 MiB, 10000 строк данных, 64 поля. Тело запроса проверяется до импорта; SSE ограничивается только на входе и продолжает потоковый ответ. Предел буферов ограничивает память входящих тел, а не всю память разбора JSON, работы handler или response.
|
||||
|
||||
## Группы маршрутов (соответствие тегам OpenAPI)
|
||||
|
||||
Ниже — обзор того, что реализовано в коде (`internal/httpapi/routes.go`, `routes_crud.go`). Детали тел, кодов ответов и схем — только в OpenAPI.
|
||||
@@ -65,6 +73,8 @@
|
||||
- `GET|PATCH|DELETE /v1/peers/{id}`
|
||||
- Для `POST|PATCH|DELETE` peer запускается быстрый job `peer_reconcile` (без module ingest/сбора префиксов); после него автоматически ставится apply на спикеры.
|
||||
|
||||
Live-состояние следует читать вместе с `live_observation` (схема `LiveObservation` в OpenAPI). Отсутствующее наблюдение не означает ноль сессий или отказ BGP: `session_state` может быть `null`, а сохранённое значение вынесено в `last_known_session_state`. Для назначенного пира состояние вычисляется по его speaker; успешный опрос другого узла не делает его healthy. У speakers отдельно представлены качество опроса BGP и агента; счётчики без успешного опроса nullable.
|
||||
|
||||
### Speakers
|
||||
|
||||
- `GET /v1/speakers`, `POST /v1/speakers`
|
||||
@@ -86,6 +96,8 @@
|
||||
- `POST /v1/speakers/{id}/apply`
|
||||
- `POST /v1/bird/reload`
|
||||
|
||||
Ответ `202` подтверждает принятие задания. Результат доставки удалённому агенту не равен применению: старый `2xx` без явного revision ID имеет уровень `unconfirmed`; совпадающий явный ACK — `agent_reported`. Локальный успешный apply — `local_applied`. Подтверждение хеша работающего BIRD-конфига (`verified`) пока не реализовано. Job содержит частичные результаты по speakers; ошибка одного узла не скрывается общим успехом, отмена сохраняет выполненную часть и останавливает публикацию следующим узлам. Границы совместимости и одного активного deploy writer описаны в [backend-compatibility.md](backend-compatibility.md).
|
||||
|
||||
### Jobs
|
||||
|
||||
- `GET /v1/jobs`, `GET /v1/jobs/{job_id}`
|
||||
@@ -161,6 +173,30 @@ curl -s -H "Authorization: Bearer opkey" http://localhost:8080/v1/modules
|
||||
|
||||
CORS для браузерных клиентов настраивается переменной **`EVOBGP_CORS_ORIGINS`** на стороне API.
|
||||
|
||||
### Квоты preview и lookup
|
||||
|
||||
Синхронные `GET /lookup`, CDN preview и revision preview используют общий локальный пул: по умолчанию 4 активных операции на процесс API, не более 2 на tenant. Для каждого tenant действуют отдельные минутные счётчики: 120 lookup, 30 CDN preview и 30 revision preview. При заполнении возвращается `429` с `Retry-After`, без очереди ожидания. Отмена клиента не освобождает место, пока обработчик действительно не завершил работу. `Retry-After` доступен также клиентам через CORS.
|
||||
|
||||
| Переменная | По умолчанию | Допустимый диапазон |
|
||||
|---|---:|---:|
|
||||
| `EVOBGP_API_EXPENSIVE_MAX_CONCURRENT` | 4 | 1–64 |
|
||||
| `EVOBGP_API_EXPENSIVE_TENANT_CONCURRENT` | 2 | 1–64 |
|
||||
| `EVOBGP_API_LOOKUP_PER_MINUTE` | 120 | 1–100000 |
|
||||
| `EVOBGP_API_CDN_PREVIEW_PER_MINUTE` | 30 | 1–100000 |
|
||||
| `EVOBGP_API_REVISION_PREVIEW_PER_MINUTE` | 30 | 1–100000 |
|
||||
|
||||
Некорректное значение заменяется значением по умолчанию. Состояние ограничено 4096 tenant; действующие счётчики и активная работа не вытесняются ради новых tenant. Квоты локальны: несколько API-реплик умножают доступную нагрузку. Они не являются общей кластерной квотой. Refresh/retry управляются отдельной durable queue с её лимитами и replay исходного запроса; legacy bundle/latest endpoints не используют квоту preview/lookup. Общий admission входного тела может вернуть `429` до проверки replay, поэтому такой POST можно повторить с тем же idempotency key.
|
||||
|
||||
### Ограничения CSV-импорта
|
||||
|
||||
CSV-импорт ограничен 8 MiB полного тела запроса, 10000 строк данных без заголовка и 64 полями в одной записи. Превышение возвращает `413` до создания записей, включая запросы без `Content-Length`; отправляйте большие списки частями. Синтаксически корректный префикс слишком большого файла больше не импортируется молча. Отмена запроса останавливает последующие записи. Ошибки данных или БД после начала импорта всё ещё могут оставить уже созданные строки: импорт не является общей транзакцией.
|
||||
|
||||
### Сохранённый отчёт доставки
|
||||
|
||||
`GET /v1/jobs/{job_id}/report` требует `bgp:operations:read` и возвращает `deployment_progress` независимо от промежуточной `meta`. Для recovery job поле `source_deployment_job_id` указывает исходную операцию в том же tenant. Пока собственных записей нет, отчёт показывает исходный прогресс; после начала recovery — собственный. Каждый элемент содержит `job_id`, поэтому происхождение результата видно явно.
|
||||
|
||||
`health_received_at` — время получения наблюдения control plane, `health_source_at` — необязательная отметка агента. Отсутствие этого поля у 2.56.2 не препятствует доставке. `unknown` и `unconfirmed` нельзя интерпретировать как подтверждённое применение или отказ BIRD. Полный HTTP-контракт: [OpenAPI, getJobReport](openapi.yaml).
|
||||
|
||||
## Связанные документы
|
||||
|
||||
- [access.md](access.md) — ключи и роли.
|
||||
|
||||
+22
-7
@@ -14,14 +14,14 @@
|
||||
|----------|------|
|
||||
| `evobgp-api` | Только HTTP API и связанная логика в одном процессе. |
|
||||
| `evobgp-all` | Тот же API + in-process **scheduler** (очередь `module_refresh` в общем Registry), **ingest** (prefetch ETag CDN), **render** (опционально auto-publish), **deploy** (лог расхождений published/applied). |
|
||||
| `evobgp-scheduler` | По `refresh_interval_sec` ставит refresh: в одном процессе с API — через `jobs.Registry`; в reference Compose — **HTTP** `POST /v1/modules/{id}/refresh` (`EVOBGP_CONTROL_PLANE_URL`, `EVOBGP_SCHEDULER_BEARER`). |
|
||||
| `evobgp-scheduler` | По `refresh_interval_sec` ставит tenant refresh. Reference Compose: доверенный DB-worker, общая PostgreSQL queue (`EVOBGP_SCHEDULER_MODE=db`). Опциональный HTTP-режим ограничен явным tenant; параметры и Compose override описаны в quickstart. |
|
||||
| `evobgp-ingest` | Периодический conditional GET по URL CDN-источников и обновление `etag` в БД. |
|
||||
| `evobgp-render` | По умолчанию только heartbeat; при `EVOBGP_RENDER_AUTOPUBLISH=1` выставляет всем спикерам tenant последнюю ревизию (упрощение для демо). |
|
||||
| `evobgp-deploy` | Периодически логирует **drift**: `last_applied_revision_id` vs опубликованная ревизия для ноды. |
|
||||
| `evobgp-node` | CLI реплики: `pull-bundle`, `verify-bundle`, `apply-bundle`. |
|
||||
| `evobgp-agent` | Локальный агент рядом с BIRD: `watch`, **`serve`** (Panel→Node sync API на реплике). |
|
||||
|
||||
В Docker Compose профиль **reference** запускает отдельные контейнеры под `evobgp-api` и четыре воркера; профиль **microvps** использует один контейнер `evobgp-all`.
|
||||
В Docker Compose профиль **reference** запускает отдельные контейнеры под `evobgp-api` и четыре воркера; профиль **microvps** использует один контейнер `evobgp-all`. Сервис NATS вынесен в отдельный профиль `nats` и не требуется для внутренней очереди задач.
|
||||
|
||||
## Пакеты `internal/` (сжатая карта)
|
||||
|
||||
@@ -31,14 +31,15 @@
|
||||
| `store` | Абстракция бэкенда данных; реализации в памяти и через репозиторий. |
|
||||
| `repository` | Доступ к PostgreSQL, сущности и миграции на уровне приложения. |
|
||||
| `db` | Подключение к БД и применение миграций. |
|
||||
| `jobs` | Реестр и выполнение асинхронных задач, связанных с API. |
|
||||
| `jobs` | Durable enqueue/claim, lease/heartbeat/fencing, выполнение задач и deployment reconciliation. |
|
||||
| `telemetry` | Ограниченный фоновый collector legacy BGP/health и local BIRD; persistent snapshots и fenced discovery. |
|
||||
| `bundle` | Упаковка и проверка бандлов для нод. |
|
||||
| `signing` | Криптографическая проверка подписей. |
|
||||
| `birdfmt` | Форматирование и фрагменты конфигурации BIRD, вызовы `birdc`. |
|
||||
| `birddeploy` | Логика применения конфигурации к BIRD (используется в цепочке деплоя). |
|
||||
| `config` | Переменные окружения `EVOBGP_*`. |
|
||||
| `observability` | Метрики Prometheus, HTTP middleware. |
|
||||
| `broker` | Опциональный `EVOBGP_BROKER_URL` для будущей шины; сейчас задачи только in-process (`jobs.Registry`), пакет лишь логирует факт настройки URL. |
|
||||
| `broker` | Опциональный `EVOBGP_BROKER_URL` для будущей шины; пакет лишь логирует URL, NATS не участвует в исполнении задач. Текущий Registry дополнен PostgreSQL persistence/claim/reclaim. |
|
||||
| `pipeline` | Ingest+render для `module_refresh`: CDN/AS/IP/DOMAINS → `module_prefix_snapshot` (batch `COPY`, per-module lock) → агрегация CIDR O(n log n) → `CreateRenderRevision`. Fast-path снапшота — `module.input_hash`; DoH — TTL-кэш `domain_resolve_cache`; scheduler — jitter границ интервала. |
|
||||
| `nodedispatch` | Panel→Node HTTP wake-up (`POST /v1/agent/sync`) после `deploy_apply`. |
|
||||
| `agentserver` | HTTP API на реплике (`serve`): sync + health для Traefik; опционально firewall failover (`/v1/firewall/*`). |
|
||||
@@ -71,7 +72,7 @@ flowchart LR
|
||||
Operator --> API
|
||||
NodeCLI --> API
|
||||
API --> PG
|
||||
Sched -->|HTTP_or_DB| API
|
||||
Sched -->|optional_single_tenant_HTTP| API
|
||||
Sched --> PG
|
||||
Ingest --> PG
|
||||
Render --> PG
|
||||
@@ -79,7 +80,21 @@ flowchart LR
|
||||
Agent --> BIRD
|
||||
```
|
||||
|
||||
Очередь задач по-прежнему **in-memory в процессе API** (`jobs.Registry`); отдельный контейнер `evobgp-scheduler` не разделяет память с API и дергает refresh по HTTP. Полноценный брокер (NATS) и общая очередь `job_audit` между процессами — в следующих итерациях.
|
||||
При PostgreSQL очередь использует общий `job_audit`: enqueue фиксируется до HTTP `202`, claim получает owner/lease/fencing token, а heartbeat продлевает владение. List/get/cancel/report читают общее хранилище. `Server.StartBackground` запускает durable queue и deployment reconciliation; Memory-режим сохраняет задания только в процессе. Подробности и ограничения — [durable-jobs.md](durable-jobs.md). NATS не участвует в исполнении задач. Разрыв между сохранением CRUD и автоматическим enqueue пока не закрыт transactional outbox; ошибка постановки явно сообщается клиенту.
|
||||
|
||||
### Фоновая статистика
|
||||
|
||||
`Server.StartBackground` также запускает `telemetry.Collector`. Фиксированное число workers получает due targets из store с lease/fencing, опрашивает прежние endpoints агента 2.56.2 и локальный BIRD, затем сохраняет независимые BGP/health результаты. Discovery записывается в той же fenced транзакции. Истёкший владелец не может заменить snapshot или discoveries. Конфигурация speaker проверяется перед фиксацией; смена endpoint/секрета не переносит успешное наблюдение прежней конфигурации на новую.
|
||||
|
||||
GET peers/speakers/discoveries читает сохранённое состояние; `live=1` не вызывает сетевой fan-out. POST `/v1/telemetry/refresh` объединяет запросы в пределах 10 секунд и назначает следующий сбор. `received_at` и настроенный интервал определяют свежесть, `expires_at` передаёт её срок клиенту; просроченные sessions не становятся текущим Established. При недоступности хранилища snapshots API отвечает 503.
|
||||
|
||||
Для local BIRD несколько CP различаются стабильной authority. `EVOBGP_TELEMETRY_LOCAL_ID` должен идентифицировать именно BIRD-узел; одинаковый ID допустим только при чтении одного экземпляра BIRD. Две одновременно актуальные authority одного master дают неизвестный статус. Устаревшая authority после перезапуска не скрывает единственный свежий снимок. Настройки интервала, workers и локальной идентичности — [quickstart.md](quickstart.md).
|
||||
|
||||
### Снимки префиксов модуля
|
||||
|
||||
`UpdateModulePrefixSnapshot` выполняет read-modify-write атомарно. В PostgreSQL transaction advisory lock, чтение текущих строк, замена снимка и `module.input_hash` используют одну транзакцию и одно соединение. Потеря соединения не позволяет продолжить запись через другое. GET читает заголовок и строки в repeatable-read транзакции; ошибки scan/iteration не превращаются в неполный успешный результат. Memory использует блокировку ключа tenant/module и фиксирует snapshot/hash под общей mutex. Callback преобразует переданный снимок и не вызывает store I/O.
|
||||
|
||||
Это сериализует фиксацию снимка, но ещё не гарантирует единую версию всех входов ingest: конфигурация и input hash вычисляются до транзакции, а tenant manifest/version CAS остаётся отдельной работой B04. Контекст передаётся в операции snapshot и lookup; устаревшие вызовы других store-сущностей требуют последующего расширения.
|
||||
|
||||
## Диаграмма: microvps (`evobgp-all`)
|
||||
|
||||
@@ -96,7 +111,7 @@ flowchart LR
|
||||
|
||||
Внутри `evobgp-all` все воркеры используют **тот же** `store` и `jobs.Registry`, что и HTTP handlers, поэтому `module_refresh` выполняется в том же процессе без HTTP.
|
||||
|
||||
Профиль Compose **`microvps-full`** добавляет к этому стеку **Web UI** (nginx → `evobgp-all`), **NATS** и **Prometheus** без отдельных контейнеров воркеров (функционально то же, что отдельные `scheduler`/`ingest`/… в reference). Запуск и лимиты под ~1 ГиБ RAM — в [quickstart.md](quickstart.md).
|
||||
Профиль Compose **`microvps-full`** добавляет к этому стеку **Web UI** (nginx → `evobgp-all`) и **Prometheus** без отдельных контейнеров воркеров (функционально то же, что отдельные `scheduler`/`ingest`/… в reference). NATS включается отдельно через профиль `nats`. Запуск и текущие лимиты — в [quickstart.md](quickstart.md); пригодность для ~1 ГиБ RAM требует замера под нагрузкой.
|
||||
|
||||
## Поток pipeline (ingest → render)
|
||||
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
# Backend: исходные измерения и воспроизведение проблем
|
||||
|
||||
Материалы этапа 0 [плана развития](backend-improvement-plan.md). Сборщик ниже выполняет только чтение Docker; он не запускает, не останавливает и не обновляет сервисы, не вызывает refresh/apply и не подключается к production автоматически.
|
||||
|
||||
## Ресурсный snapshot
|
||||
|
||||
На машине с Docker daemon и PowerShell выполните из корня репозитория:
|
||||
|
||||
```powershell
|
||||
./scripts/collect-backend-baseline.ps1 -ComposeFile @('deploy/compose/docker-compose.yaml', 'deploy/compose/docker-compose.microvps-full.yaml') -Profile microvps-full -Samples 3 -IntervalSeconds 15
|
||||
```
|
||||
|
||||
Укажите те же Compose-файлы, profile и при необходимости `-ProjectName`, с которыми запущен исследуемый стек. Относительные Compose-пути разрешаются от корня репозитория. Для другого окружения передайте его файлы явно. Скрипт работает с **текущим Docker context**; перед запуском проверьте, что выбран нужный хост.
|
||||
|
||||
Результат по умолчанию записывается в игнорируемый Git каталог `artifacts/backend-baseline/`; другой путь задаётся `-OutputPath`. Код выхода `0` означает собранные snapshots работающих контейнеров, `2` — неполный результат или недоступную среду. Статусы `partial/unavailable` нельзя считать успешным замером. `configured_services` содержит определения из effective config (включая другие profiles, если их возвращает Compose), а `containers` — фактически найденные контейнеры выбранного проекта. Нельзя суммировать всю конфигурацию как реально запущенный workload.
|
||||
|
||||
Сохраняются только разрешённые поля: commit, image references/IDs, Docker version, CPU/RAM хоста, настроенные/фактические resource limits, OOM/restart counters и результаты `docker stats`. Raw Compose config разбирается только в памяти. Environment, commands, labels, mount paths, логи и текст ошибок Docker не записываются. Сам отчёт всё равно содержит инфраструктурные метаданные; перед публикацией его нужно проверить.
|
||||
|
||||
Это точечные значения CPU/memory и накопительные IO counters, а не p95/p99, peak RSS или нагрузочный benchmark. Отдельно зафиксируйте dataset (число модулей/префиксов/узлов), workload, длительность, BGP-сессии, API latency, DB waits, PSI и cgroup events по D00. Для delta IO сравнивайте один и тот же container ID без restart; новое значение counters после пересоздания не означает снижение нагрузки. Объём образа/слоёв, pull/build time и 24h soak пока собираются отдельно.
|
||||
|
||||
## Регрессионные проверки достоверности live-status
|
||||
|
||||
```powershell
|
||||
go test ./internal/httpapi -run '^TestStage0Repro' -count=1 -v
|
||||
```
|
||||
|
||||
После исправления S01 build tag удалён: эти тесты входят в обычный обязательный набор. Проверяются сохранённый Established при timeout/отсутствующей сессии/collector и ложный healthy у неопрошенного master. Дополнительные проверки в `peers_live_test.go` подтверждают, что успешный опрос постороннего speaker не превращает неизвестное состояние назначенного speaker в `absent` или `ok`. Это локальные регрессии HTTP-слоя; они не заменяют стенд с реальными агентами.
|
||||
|
||||
## Статус фактической проверки этапа 0
|
||||
|
||||
### Дополнение: legacy 2.56.2 и durable jobs
|
||||
|
||||
Обязательная версия удалённых speakers уточнена пользователем: **2.56.2**. Тег `v2.56.2` разрешён в commit `924f111e4871e1d6d47c9916d7da1b9051718f1c`. На Windows собраны исторические node/agent без изменения их исходников. Node прошёл pull/auth/signature против текущего HTTP API, включая отказ на неверном ключе подписи; agent serve прошёл dispatch/ACK/health/protocols с текущим подписанным bundle и fake BIRD. Дополнительный CLI v1.0.0 также прошёл проверку. Это source-built executables, не опубликованные Docker images и не настоящий BIRD/TLS стенд.
|
||||
|
||||
CI-скрипт `scripts/ci/legacy-node-compatibility.sh` закрепляет commits и проверяет отсутствие skips нужных binary tests. `scripts/tests/mirror-base-images.test.sh` локально прошёл восемь offline сценариев; он не измеряет загрузку или сборку Docker.
|
||||
|
||||
Тест отчёта jobs подтверждает чтение durable speaker progress новым Registry, собственный результат recovery вместо старого результата родителя и изоляцию tenant. PostgreSQL-проверки lease/generation/restart и fresh seed добавлены в обязательный CI gate; локально без DSN результат остаётся неподтверждённым.
|
||||
|
||||
После добавления отчёта выполнены `go test ./... -count=1`, `go vet ./...`, `scripts/lint-go.ps1`, OpenAPI lint/codegen и frontend typecheck/lint/build — exit 0. После финального исправления порядка health observations повторены тесты затронутых пакетов и legacy binaries 2.56.2 — exit 0. Astra завершила проверку очереди, fencing, health token, отчёта и fixture pruning без оставшихся блокирующих замечаний. Реальный SQL/race gate не выполнен локально; обязательных CI DB/repository tests теперь 17.
|
||||
|
||||
### Дополнение: разделение внешних HTTP pools
|
||||
|
||||
Восстановление прежнего доступа к настроенным private DoH/RIPE явно разрешено пользователем после первоначального отказа автоматической проверки. Политика и границы описаны в [outbound-policy-decision.md](outbound-policy-decision.md). Тест actual bootstrap DOMAINS refresh с локальным DoH прошёл; CDN через paired client сохраняет public-only guard. Три последовательных TLS CDN-запроса используют одно соединение.
|
||||
|
||||
До начала S02 полный Go test, lint-go и legacy 2.56.2 binary smoke прошли. Astra независимо прогнала RIPE/DoH/CDN/breaker тесты четырёх пакетов. Bounded breaker cache проверен на capacity1024, active/open retention, metric cleanup и 253-символьный hostname с завершающей точкой. Production DNS/network policy и реальные ресурсы хоста не проверялись.
|
||||
|
||||
### Дополнение: HTTP admission и квоты
|
||||
|
||||
После добавления общего 8 MiB body preflight, четырёх body slots и tenant/global квот preview/lookup выполнены OpenAPI lint/codegen, frontend typecheck/lint/build, `scripts/lint-go.ps1` и повторный полный `go test ./... -count=1` — exit 0. Legacy node/agent 2.56.2 повторно прошли HTTP/dispatch compatibility tests. Astra независимо проверила auth-first, отмену без преждевременного освобождения слота, Retry-After/CORS, ограниченный CDN error-body и отсутствие изменений legacy GET/SSE.
|
||||
|
||||
Первый полный прогон выявил нестабильность очереди при равном CreatedAt. Исправлен tie-breaker RequestSequence/ID; детерминированная regression с одинаковым временем прошла 50 повторов у Astra, jobs/store — 20 у Sol. После исправления полный прогон и lint прошли. Реальный PostgreSQL/race и нагрузочные RSS/CPU измерения этим не заменяются.
|
||||
|
||||
### Дополнение: B05 CSV и bundle limits
|
||||
|
||||
CSV byte/row/field preflight, отсутствие записи при превышении и отмена прошли focused tests и независимую проверку Astra. Полный `go test ./... -count=1`, Go lint и frontend gates прошли после добавления CSV/bundle limits; после финальных sparse/retry-body исправлений повторены bundle/nodecli/httpclient/pipeline tests и проверка старых node/agent 2.56.2 — exit 0. OpenAPI lint/codegen также успешны.
|
||||
|
||||
Windows-проверка extraction выполнила hard-link часть, затем получила `SKIP` при создании symlink из-за отсутствия привилегии. Это не подтверждение symlink runtime safety: на Linux тест не допускает такой skip и должен пройти в обычном CI `go test -race`. Конфигурация из миллиона повторяющихся IPv6 route-строк проверяет допустимый размер bundle, а не BIRD или производительность реального миллиона уникальных маршрутов.
|
||||
|
||||
В локальной среде 26.10.2026 доступны Go 1.26.1 и golangci-lint; Docker/psql и C-компилятор в PATH не найдены, `CGO_ENABLED=0`. Поэтому production-ресурсы, реальный PostgreSQL integration и Go race detector здесь не подтверждены. CI использует проектную версию Go; результаты локального более нового Go не заменяют этот прогон. Конкретные выполненные проверки и оставшиеся gates фиксируются в [статусе плана](backend-improvement-plan.md).
|
||||
|
||||
| Проверка 26.10.2026 | Результат |
|
||||
|---|---|
|
||||
| `go test ./... -count=1` | Exit 0; повторный полный прогон после добавления тестов. DB-тесты без DSN пропущены. Для тестовых localhost-серверов потребовался запуск вне sandbox |
|
||||
| `go vet ./...` | Exit 0 |
|
||||
| `pwsh -NoProfile -File scripts/lint-go.ps1` | Exit 0 после исправления области поиска файлов и замечаний errcheck в новых тестах; выполняет gofmt check, vet и golangci-lint |
|
||||
| `pwsh -NoProfile -File scripts/tests/collect-backend-baseline.test.ps1` | Exit 0; offline mock, проверены несколько контейнеров, whitelist без секретов и partial/exit 2 при отсутствии stats |
|
||||
| JSON guard PostgreSQL CI | На синтетических событиях: полный pass принят; skip, пропущенный обязательный тест и подмена subtest отклонены. Сам PostgreSQL job локально не запускался |
|
||||
| `go test ./internal/httpapi -run '^TestStage0Repro' -count=1 -v` | После S01 включён в основной набор; ожидается exit 0. Первоначально opt-in тесты воспроизводили дефекты, теперь закрепляют исправленное поведение |
|
||||
| `go test ./... -race -count=1` | Не выполнен: race требует CGO и отсутствующий C toolchain. Проверка остаётся обязательной в CI |
|
||||
| Resource collector без Docker | Сформирован `status=unavailable`, причина `docker-cli-unavailable`; никаких чисел VPS из этого запуска не получено |
|
||||
| Повторная проверка Astra | Замечания к guard обязательных DB-тестов и полноте stats исправлены; оставшихся блокирующих замечаний в проверенном diff нет |
|
||||
|
||||
### Runtime-итерация B01/B02/S01
|
||||
|
||||
- Полный `go test ./... -count=1` после первой runtime-итерации: exit 0 вне sandbox для localhost-тестов. PostgreSQL без DSN по-прежнему пропускается.
|
||||
- После замечаний Astra повторный `go test ./internal/birddeploy ./internal/jobs ./internal/nodedispatch ./internal/httpapi -count=1`: exit 0. Проверены исправления include scanner, отмена dispatch и старый пустой ответ 204.
|
||||
- Повторный review Astra не выявил блокирующих замечаний в исправлениях B01/B02/S01. В UI замечания по знаменателям deployment/peer KPI и явному unhealthy исправлены и покрыты регрессиями; повторный review без блокеров.
|
||||
- UI: `pnpm --filter @evobgp/web run typecheck`, `lint`, `build` — exit 0; Vitest — 9/9. В lint сохраняется существующий warning `use-file-upload.ts`. Визуальный прогон в браузере не выполнялся.
|
||||
- `pwsh -NoProfile -File scripts/lint-go.ps1` после исправлений B01/B02/S01 — exit 0 (gofmt, vet, golangci-lint).
|
||||
- B02 Linux-тесты компилируются, но не запускались локально. CI `bird2` дополнен реальным `bird -p`; configure в этом тесте имитируется. Это не проверка работающего BIRD daemon, сетевых сессий или полного восстановления после аварии процесса.
|
||||
- Новый apply требует существующий parse-valid `ActiveDir/bird.conf` как LKG. Управляемые include должны быть отдельными строками и ссылаться внутрь `bird.d`; внешние/вложенные/неподдержанные include отклоняются до переключения. Старый удалённый агент продолжает применять прежний bundle своим кодом; ограничение относится к обновлённому локальному apply.
|
||||
- B05 transport/proxy и B02 symlink hardening: повторный полный `go test ./... -count=1` — exit 0; scoped tests, `go vet ./...` и `scripts/lint-go.ps1` — exit 0. Astra не нашла блокеров в проверенном diff.
|
||||
|
||||
### Следующая очередь: B04 migrations / S06 / CDN body
|
||||
|
||||
- После изменений полный `go test ./... -count=1` — exit 0. После финальных замечаний Astra тесты изменённых пакетов (`db`, `observability`, `repository`, `store`, `pipeline`, `httpapi`) — exit 0; последние lexical-guard регрессии дополнительно проверены `go test ./internal/db -count=1`. DB integration без DSN пропущены и не считаются пройденными.
|
||||
- Общий `scripts/lint-go.ps1` — exit 0; OpenAPI lint — exit 0 с прежним предупреждением о неиспользуемой `FirewallRule`; `git diff --check` и shell syntax CI guard — exit 0.
|
||||
- CDN: добавлен настоящий TLS HTTP redirect с контролируемым DNS/dial, проверяющий отказ до соединения с private target. Exact-limit/overflow, ingest/preview и gzip tests прошли; данные больше 8 MiB не парсятся частично. Реальный внешний DNS/production при этом не использовался.
|
||||
- S06: проверены отсутствие ложных нулей при ошибке БД, сохранение legacy labels и нормализация только нового ряда. Desired/published/applied/live метрики остаются следующей работой.
|
||||
- B04: 33 существующих PostgreSQL migration-файла проходят проверку transaction-only policy. Новые DB-тесты используют изолированные схемы; CI guard требует все 23 обязательных DB/repository tests. Повторный review Astra закрыл обходы через комментарии, dollar-identifiers и E-string boundaries. Реальный PostgreSQL, race и Linux BIRD всё ещё требуют соответствующего окружения.
|
||||
|
||||
CI-запуск для подготовленной изолированной тестовой БД на Linux: задать `EVOBGP_TEST_DATABASE_URL`, затем `bash scripts/ci/postgres-integration.sh`. Использовать только отдельную тестовую БД: тесты применяют миграции и выполняют CRUD/prune. Не указывать production DSN. В Gitea job эта БД создаётся service-контейнером PostgreSQL 16; 23 обязательных DB/repository tests исполняются последовательно с `-race` и проверкой событий pass/skip/fail.
|
||||
|
||||
|
||||
## PostgreSQL runtime: Windows, 26 октября 2026
|
||||
|
||||
На отдельном временном кластере PostgreSQL **16.15** выполнены все **23 обязательных** DB/repository integration tests из `scripts/ci/postgres-integration.sh`: обе группы PASS, 0 skip, 0 fail. Проверены миграции и код до **000036 включительно**, до начала добавления manifest migration 000037. Сценарии включают competing migration locks/rollback, durable queue/deployment leases, telemetry discovery rollback и snapshot transaction cancellation/concurrency/partial write/потерю backend-соединения.
|
||||
|
||||
Прогон использовал `go test -count=1 -p 1 -json`: DB-фильтр `^TestPostgresMigration.*Integration$`, repository-фильтр `Integration$`. Все 23 именованных pass проверены по JSON-логам независимо от exit code пакетов. Это Windows-прогон **без `-race`**; обязательный Linux/race CI остаётся отдельным gate.
|
||||
|
||||
Portable runtime взят из [официального архива EDB](https://www.enterprisedb.com/download-postgresql-binaries), файл `postgresql-16.15-5-windows-x64-binaries.zip`; SHA-256 проверен по vendor `.sha256`: `43bb45f173a6f08cf1d29a97a6d8deb119e8e8093a24c00d2d1001a0ccaa8281`. Кластер слушал только `127.0.0.1` на свободном порту с временным паролем. Служба Windows не создавалась; после тестов `pg_ctl status` подтвердил отсутствие работающего сервера. Локальные JSON-логи сохранены в ignored `artifacts/backend-baseline/pg-test-5c93b7da43dc4c488ca7796a686213c2/`.
|
||||
@@ -0,0 +1,58 @@
|
||||
# Совместимость control plane и спикеров: проверяемая основа
|
||||
|
||||
Обязательная legacy-версия по указанию пользователя — **2.56.2**. Новый control plane должен обслуживать эти спикеры без синхронного обновления агентов. Digest развёрнутых образов и версия BIRD внутри них ещё не подтверждены. Дополнительная проверка исходников `v1.0.0` ниже не является обещанием поддержки всех промежуточных релизов.
|
||||
|
||||
## Проверка исполняемого CLI 2.56.2
|
||||
|
||||
26.10.2026 `git ls-remote` подтвердил `refs/tags/v2.56.2` → `924f111e4871e1d6d47c9916d7da1b9051718f1c`. Из этого неизменённого commit локально собран `evobgp-node` (Go 1.26.1, Windows/amd64, зависимости из локального кэша). Тест `TestLegacyNodeBinaryCurrentControlPlane` запускает именно исторический executable против настоящего HTTP-handler нового CP: latest/pull, проверка текущего подписанного бандла, отказ неверному/чужому токену и повреждённому архиву. Локально тест прошёл для **2.56.2** и отдельно для **1.0.0**.
|
||||
|
||||
Отдельно собран и запущен `evobgp-agent` **2.56.2** в режиме `serve`. `TestLegacyAgentBinaryCurrentDispatch` проверил текущий dispatch, авторизацию, получение подписанного текущим builder бандла, явный ACK, health и BGP protocols. BIRD/birdc заменены изолированными helper-процессами; CP для этого теста выдаёт заранее подписанный бандл через тестовый HTTP-handler. Проверка прошла локально на Windows, без изменения BIRD хоста. В CLI-тесте дополнительно проверен отказ для корректного архива с чужим публичным ключом.
|
||||
|
||||
В CI добавлена матрица двух pinned commits (`scripts/ci/legacy-node-compatibility.sh`), включая agent для 2.56.2; обязательные тесты не могут быть пропущены незаметно. CI job ещё не запускался. Это source-built binaries, не опубликованные Docker images: реальные image digests, TLS, настоящий BIRD parse/apply/rollback и mixed-fleet стенд остаются отдельными gates.
|
||||
|
||||
Для повторного локального запуска задайте `EVOBGP_TEST_LEGACY_NODE_BIN` абсолютным путём к собранному historical CLI и выполните `go test ./internal/httpapi -run '^TestLegacyNodeBinaryCurrentControlPlane$' -count=1 -v`. Без переменной тест явно пропускается. Проверка не вызывает apply и не меняет конфигурацию BIRD хоста.
|
||||
|
||||
Агент проверяется аналогично: `EVOBGP_TEST_LEGACY_AGENT_BIN` и `go test ./internal/nodedispatch -run '^TestLegacyAgentBinaryCurrentDispatch$' -count=1 -v`. Вызов sync проходит через старый агент, но команды BIRD выполняет только тестовая подмена. В Linux CI включён race detector; таймаут учитывает задержку завершения race-instrumented helper-процессов.
|
||||
|
||||
## Доступные свидетельства
|
||||
|
||||
- В локальном Git имеются теги `v1.0.0`–`v2.54.0` и исходники `cmd/evobgp-node`, `internal/nodecli`, `internal/bundle` для `v1.0.0` (`6ca34b900859665eede22044d734b698059bdf84`). Это исторические исходники, а не сохранённые исполняемые файлы или подтверждение установленной версии.
|
||||
- CLI в `v1.0.0` запрашивает `GET /v1/speakers/{speaker_id}/revisions/latest`, затем `GET /v1/speakers/{speaker_id}/bundle/{revision_id}` с `Authorization: Bearer`. Ответ latest читается по `revision_id`. Публичный ключ передаётся CLI отдельно в base64/hex; текущий CP предоставляет `GET /v1/bundle/signing-public-key` для роли с `bgp:network:read`.
|
||||
- Формат `v1.0.0` — gzip/tar с `manifest.json`, `bundle.sig` и файлами конфигурации. В манифесте: `revision_id`, необязательный `speaker_id`, `created_at`, `files` (`path`, `sha256`), `signature_algorithm=ed25519`, `public_key_base64`. Подпись Ed25519 считается по точным байтам `manifest.json`; содержимое файлов сверяется по SHA-256. Сопоставление исходников `v1.0.0` и текущего `internal/bundle` показывает ту же структуру, но само по себе не доказывает совместимость реального бинарника или синтаксиса BIRD.
|
||||
- `internal/agentserver` и `internal/nodedispatch` в дереве `v1.0.0` отсутствуют. Проверки их минимальных ответов ниже — характеристика текущего протокола и имитация legacy-формы, а не тест агента релиза `v1.0.0`.
|
||||
|
||||
## Автоматические проверки этой основы
|
||||
|
||||
| Тест | Что проверяет | Предел вывода |
|
||||
|---|---|
|
||||
| `internal/bundle/legacy_compatibility_test.go` | Текущий builder создаёт прежний набор полей и порядок tar entries; независимая проверка Ed25519/SHA-256 по полям старого манифеста принимает результат. Замороженный подписанный архив `internal/bundle/testdata/current_baseline_signed_bundle.tar.gz` проверяется отдельно | Фикстура создана текущим builder с тестовым ключом, не извлечена из старого релиза; исторический бинарник не запускается |
|
||||
| `internal/httpapi/node_legacy_compatibility_test.go` | Node bearer auth и tenant scope для latest revision и bundle; получение ключа с операторским токеном; проверяемый подписанный bundle без новых обязательных полей | In-memory HTTP; не проверяет TLS, внешнюю БД и CLI-процесс |
|
||||
| `internal/nodedispatch/legacy_compatibility_test.go` | Sync отправляет только старое поле `revision_id` и Bearer secret; health с отсутствующими дополнительными полями остаётся читаемым | Ответы агента моделируются, реальный старый агент не запущен |
|
||||
|
||||
В первой реализации B01 подстановка запрошенного ID удалена: `2xx` без `applied_revision_id` остаётся совместимой успешной доставкой с `confirmation=unconfirmed`. Явный совпадающий ID даёт `agent_reported`; он не означает независимую проверку фактического BIRD-конфига. Несовпадающий или устаревший ACK не меняет applied. Запрос sync по-прежнему содержит только `revision_id`, новые поля от старого агента не требуются. Старый health без времени наблюдения принимается; время опроса фиксирует CP, а его источник указан явно.
|
||||
|
||||
Публикация и обработка ACK теперь дополнены persistent deployment progress и атомарными проверками lease/generation в PostgreSQL. Реальная проверка нескольких CP writers и restart остаётся открытым integration gate. Смешанные старый и новый CP не поддерживаются при миграции очереди; удалённые агенты 2.56.2 при этом сохраняют прежний протокол, см. [порядок обновления](durable-jobs.md).
|
||||
|
||||
Агент 2.56.2 не сериализует параллельные sync. Сохранённая reservation на две минуты после неоднозначного ответа уменьшает риск перекрывающейся доставки, но timeout не доказывает завершение старой операции. Поэтому CP сохраняет unknown/unconfirmed и сверяет последующий health; exactly-once, отсутствие любых перекрытий и подтверждённый BIRD config hash для старого агента не обещаются.
|
||||
|
||||
## Что ещё нужно до release gate B08
|
||||
|
||||
### Ограничения новых сборок bundle/node
|
||||
|
||||
Полная таблица и настройки: [bundle-limits.md](bundle-limits.md).
|
||||
|
||||
Формат manifest, Ed25519-подписи и HTTP endpoints сохраняются. Новые сборки ограничивают сжатый архив 128 MiB, распакованный tar и суммарное содержимое 512 MiB, отдельный payload 256 MiB, manifest 2 MiB, число записей 4096 и длину пути 1024 байт. Лимиты проверяются также при создании bundle; downloader ограничивает metadata latest до 64 KiB и текст ошибки до 4 KiB. Для встраиваемого вызова доступны `bundle.Limits` и `SyncConfig.BundleLimits`; отдельные operator CLI flags пока не добавлены.
|
||||
|
||||
Это верхние защитные пределы, а не обещание низкого RSS на малом VPS: архив и проверенные файлы ещё хранятся в памяти. Тест с миллионом повторяющихся строк IPv6 проверяет вместимость типичного размера текста, не валидность миллиона уникальных маршрутов или нагрузку BIRD. Повторные проверки source-built node/agent 2.56.2 с текущим builder прошли.
|
||||
|
||||
Новые verifier/extractor отклоняют дубликаты, неописанные manifest файлы, небезопасные пути и ссылки, проверяют gzip trailer. Эти защиты действуют в новых бинарниках; уже установленный 2.56.2 не получает их без обновления. Поддержка его прежнего протокола сохраняется.
|
||||
|
||||
### Оставшиеся проверки
|
||||
|
||||
1. Для обязательной версии **2.56.2** подтвердить установленные image digests `evobgp-agent`/`evobgp-node`, BIRD, режим `serve` или pull-only и источник ключа подписи. Сохранить инвентарь смешанного парка; source commit сам по себе не определяет digest опубликованного образа.
|
||||
2. Сохранить реальные старые binaries/images и подписанные bundle-артефакты с подтверждённым происхождением. На стенде запустить matrix: новый CP + старый и текущий agent/CLI, предыдущий CP + новый agent, смешанный парк. Проверить pull/sync, auth/TLS, подпись, BIRD parse/apply/rollback и offline/reconnect.
|
||||
3. Ввести согласованные capabilities в OpenAPI и совместимый fallback. Новые ACK, config hash, telemetry и формат бандла отправлять по объявленным возможностям. `404/405` документированного endpoint может обозначать legacy; timeout, auth/TLS error и повреждённый ответ — ошибка, без ослабления защиты.
|
||||
4. Завершить проверку deploy: разделение `unconfirmed`/`agent_reported`, persistent progress и fencing реализованы; `verified` требует нового подтверждения и проверки хеша. Выполнить PostgreSQL concurrent/restart tests и стендовую проверку поздних ACK, неоднозначных timeout, восстановления и ограниченного повторения доставки.
|
||||
5. Проверить BIRD-синтаксис на реально установленной версии и canary без BGP flap; сохранить путь отката CP и предыдущее поколение схемы БД на окно rollback.
|
||||
|
||||
Пока эти шаги не выполнены, тесты подтверждают только локальный baseline совместимости формата и HTTP-форм, а не готовность смешанного production-парка.
|
||||
@@ -0,0 +1,460 @@
|
||||
# EvoBGP · План развития backend
|
||||
|
||||
> **Дата:** 26 октября 2026 · **Основа анализа:** checkout `924f111`
|
||||
> **Фокус:** достоверная статистика, надёжное применение BGP-конфигураций, устойчивые фоновые задачи, экономное использование VPS.
|
||||
|
||||
## 1. Главный вывод
|
||||
|
||||
EvoBGP уже имеет содержательное ядро: сбор префиксов из AS/CDN/доменов/ручных списков, материализованные снимки, агрегацию CIDR, ревизии, подписанные бандлы, управление BIRD, удалённые агенты, PostgreSQL-мониторинг и обслуживание БД. Основная задача следующей итерации — укрепить гарантии между этими частями и сделать наблюдаемое состояние достоверным.
|
||||
|
||||
**Рекомендуемый порядок:** исправление статусов и выполнения задач → единый сбор телеметрии → снижение фоновой нагрузки → новые операторские функции.
|
||||
|
||||
**Обязательное условие:** обновление control plane не должно требовать одновременного обновления удалённых спикеров. Уже развёрнутые старые агенты/CLI должны продолжать получать, проверять и применять совместимые бандлы. Новые возможности включаются по capabilities конкретной ноды; ограничения наблюдаемости показываются явно. Политика перехода и проверки — B08.
|
||||
|
||||
| Приоритет | Значение | Правило исполнения |
|
||||
|---|---|---|
|
||||
| **P0** | Неверное подтверждение сетевого изменения, небезопасное применение | Выполнить до расширения автоматического deploy |
|
||||
| **P1** | Потеря/повтор задач, недостоверная статистика, ошибки изоляции и защиты | Ближайший инженерный этап |
|
||||
| **P2** | Производительность, эксплуатация, полезные функции | После стабилизации зависимостей |
|
||||
| **P3** | Расширенные возможности | По подтверждённой потребности |
|
||||
|
||||
### Границы исследования
|
||||
|
||||
Первоначальный анализ исходников, миграций, Compose и CI выполнен тремя субагентами **Astra**. Реализация ведётся субагентами **Sol / medium**, проверка — **Astra**; фактический прогресс отмечен ниже. Production, реальные RSS/CPU/IO, размер опубликованных образов и нагрузка операторов не измерялись. Docker CLI в локальной среде отсутствует. Поэтому проценты экономии и гарантированная вместимость VPS здесь не заявляются.
|
||||
|
||||
Найденные при анализе устаревшие описания очереди и Svelte UI в `docs/architecture.md` и `docs/overview.md` исправлены на этапе 0. План учитывает фактическую реализацию и подключённый **ReUI PRO**.
|
||||
|
||||
## 2. Что уже есть и что следует сохранить
|
||||
|
||||
### Статус реализации · 26.10.2026
|
||||
|
||||
**Этап 0 «Проверяемая основа» и первая очередь runtime-исправлений.** Реализация: Sol / medium; независимая проверка: Astra. Старый формат запроса node/sync и формат бандла сохраняются. Полный этап 0 остаётся открытым до стендовых проверок ниже.
|
||||
|
||||
- [x] Добавлен CI job `postgres-integration`: временная PostgreSQL 16, последовательное применение миграций и repository tests с `-race`; проверяется успех каждого из 23 обязательных тестов, skip/отсутствие теста не даёт зелёный результат.
|
||||
- [x] Добавлены тесты повторного применения миграций, конкурентного SQL claim, сохранения строки job при повторном открытии pool и idempotency row; исправлен невалидный tenant fixture в существующем CRUD-тесте.
|
||||
- [x] Добавлены воспроизведения двух дефектов live-status: stale Established и ложный healthy без poll. После исправления S01 tag `backend_repro` удалён; проверки включены в обязательный набор.
|
||||
- [x] Зафиксирован локальный baseline совместимости: исторические исходники `v1.0.0`, минимальные node/sync/health wire contracts, независимая проверка подписи/хешей и замороженный signed bundle текущего builder. Подробности: [совместимость](backend-compatibility.md).
|
||||
- [x] Добавлен read-only PowerShell collector ресурcных snapshots с whitelist полей и offline проверками multi-container stats, отсутствия утечки env и неполного результата: [инструкция](backend-baseline.md).
|
||||
- [x] Уточнены architecture/overview: гибридная Registry + PostgreSQL очередь с ограничениями, реальный tenant-refresh endpoint и текущий React/ReUI UI.
|
||||
- [x] Исправлен локальный Go lint runner: проверяет пакеты текущего модуля вместо посторонних вложенных worktree и возвращает ошибки vet/linter.
|
||||
- [ ] Подтвердить новый integration job реальным запуском CI/PostgreSQL. Локальный запуск без DSN компилирует тесты, но DB-проверки пропускаются; это не интеграционный успех.
|
||||
- [ ] Воспроизвести взаимодействие локального Registry и DB claim при ожидании дольше grace, повторном idempotency key и настоящем restart worker/jobs API. SQL-level тесты выше не доказывают эти гарантии.
|
||||
- [ ] Инвентаризировать реально установленные версии/digests speakers и прогнать матрицу на старых binaries/images. Source-derived контракт и текущая fixture не заменяют эту матрицу.
|
||||
- [ ] Снять effective Compose и реальные CPU/RAM/IO, выполнить workload/24h soak на целевом VPS; локально Docker отсутствует.
|
||||
|
||||
Результаты локальной валидации и ограничения среды приведены в [backend-baseline.md](backend-baseline.md).
|
||||
|
||||
**Первая очередь runtime: B01 / B02 / S01 / B08.** Отметки ниже относятся к реализованным частям, а не к закрытию пунктов целиком.
|
||||
|
||||
- [x] B01/B08: bare HTTP 2xx означает доставку без подтверждения (`unconfirmed`); applied обновляется только после явного ACK нужной опубликованной ревизии либо успешного локального apply. Формат запроса старому агенту сохранён.
|
||||
- [x] B01: результат job содержит результаты по speakers и счётчики published/applied/unconfirmed/failed; ошибки доставки больше не превращаются в общий успешный apply.
|
||||
- [x] S01: неопрошенное состояние и отсутствующие счётчики возвращаются как unknown/null; сохранённый Established не подменяет текущее наблюдение. Добавлены quality/source/timestamps и проверки назначенного speaker.
|
||||
- [x] B02: реализованы атомарное переключение `bird.conf` внутри mounted ActiveDir, межпроцессная блокировка, проверяемые поколения и rollback с отдельным context. Astra проверила исправления inline/commented include bypass; переносимые тесты прошли.
|
||||
- [x] B01: отмена останавливает публикацию следующим speakers, игнорирует поздний ACK и сохраняет частичный результат со статусом cancelled; добавлены регрессии отмены dispatch и legacy empty 204.
|
||||
- [ ] B02: выполнить Linux fault/process-lock тесты и реальный `bird -p` CI; проверить configure/recovery с настоящим daemon, ENOSPC и аварийное завершение между фазами. Cross-compilation и fake birdc не закрывают эти проверки.
|
||||
- [x] S01/B01 UI: качество и время наблюдения, last-known подсказки, unknown вместо ложного down/0, результаты неподтверждённого deploy. Исправлены знаменатель deployment, disabled peers и явный unhealthy; Astra проверила. Typecheck/lint/build — exit 0, Vitest — 9/9. Визуальный browser review ещё не выполнен; полный F01 этим не закрыт.
|
||||
- [x] B01: добавлены persistent progress по speakers и атомарные lease/generation проверки публикации/ACK; `/jobs/{job_id}/report` читает сохранённые результаты после перезапуска, включая связанные recovery jobs. Проверены восстановление отчёта, приоритет собственного прогресса recovery и tenant isolation.
|
||||
- [x] B01: bounded reconciliation сохраняет порядок исходных намерений, дедуплицирует recovery и ограничивает его тремя раундами. Health observation token отклоняет запоздалые результаты; timeout/отсутствующее поле не восстанавливает устаревший статус. Memory tests порядка/retry budget прошли; SQL-регрессии добавлены в CI.
|
||||
- [ ] B01: выполнить PostgreSQL restart/fencing/health gate и стенд legacy agent. Двухминутная reservation после неоднозначного ответа 2.56.2 ограничивает повторы, но не доказывает остановку удалённой операции; exactly-once и состояние BIRD не заявляются.
|
||||
- [x] B08 executables: обязательная legacy-версия **2.56.2**; её pinned source-built CLI прошёл pull/auth/signature против текущего CP, агент serve — текущий dispatch/ACK/health/protocols с подписанным bundle и fake BIRD. Добавлена CI-матрица 2.56.2 и дополнительного CLI 1.0.0; реальный BIRD этим не проверен.
|
||||
- [ ] B08: реальные image digests 2.56.2, agent/BIRD apply/rollback и смешанный парк по-прежнему требуют стендовой проверки; source-built CLI не закрывает всю матрицу.
|
||||
- [x] B05 transport/proxy: CDN URL проверяется при dial/redirect, DNS-ответ проверяется целиком, соединение выполняется с проверенным IP; CDN proxy отключён. Приватный node transport отделён. Forwarded-заголовки доверены только `EVOBGP_TRUSTED_PROXY_CIDRS`; auth limiter ограничен 4096 активными IP без вытеснения действующих счётчиков. Astra проверила; полный Go test и lint прошли.
|
||||
- [x] B05 CDN body: ingest/preview отклоняют распакованный ответ больше 8 MiB вместо обрезания; ограничено чтение error-body при retry. Проверены точный предел/+1, gzip и реальный TLS redirect с контролируемым DNS на private target.
|
||||
- [x] B05 CSV: превышение 8 MiB, 10000 строк данных или 64 полей в записи возвращает 413 до создания записей. Убрано тихое обрезание, ограничено усиление памяти CSV-парсером, отмена останавливает последующие записи. Тесты exact/+1, неизвестной длины, корректного префикса перед превышением и quoted/multiline полей прошли.
|
||||
- [x] B05 bundle: ограничены compressed/expanded/logical bytes, payload, manifest, число файлов и пути; закрыты sparse tar, gzip trailing expansion, дубликаты/неподписанные файлы и выход через ссылки. HTTP retry читает не более 64 KiB error-body. Astra проверила; bundle/nodecli/httpclient/pipeline tests и повторные legacy 2.56.2 tests прошли. [Пределы и настройки](bundle-limits.md).
|
||||
- [x] B05 HTTP: общий предел входного тела 8 MiB проверяется после auth до любых handler side effects; до 4 body-запросов одновременно с удержанием памяти до завершения handler. Добавлены локальные tenant/global concurrency и minute quotas preview/lookup, 429 + CORS-visible Retry-After. Refresh/retry сохраняют replay и shared queue quotas; legacy GET не занимают body slots. Astra review и focused HTTP tests прошли.
|
||||
- [x] B05 внешние ответы: RIPE 32/4 MiB и DoH 2/1 MiB читаются полностью с проверкой +1, включая gzip; введены default deadlines без ослабления caller/profile deadline. После явного разрешения пользователя восстановлены внутренние DoH/RIPE через отдельный pool; CDN остаётся защищённым и переиспользует соединения. Bootstrap private-DoH и TLS connection-reuse tests прошли; [политика](outbound-policy-decision.md).
|
||||
- [x] B05/B06 circuit cache: не более 1024 hostname, canonical keys, отказ до dial при заполнении, idle expiry закрытых записей и удаление Prometheus label set. Активные/open записи не вытесняются. Тесты capacity, aliases, lease/ref lifecycle и metric cleanup прошли.
|
||||
- [ ] B05: выполнить symlink extraction regression в Linux CI (локально Windows пропускает из-за привилегий) и стендовые проверки нагрузки. Локальные квоты не доказывают экономию RSS или общую кластерную квоту; старые правила redirects/proxy для DoH сохранены по разрешённой политике.
|
||||
- [x] S06 основа: добавлены явные inventory/stored метрики, сохранены старые имена и raw labels. При ошибке чтения БД выдаётся collection_success=0 без ложных нулевых агрегатов; добавлены alert и [описание семантики](metrics-semantics.md).
|
||||
- [ ] S06: desired/published/confirmed applied/observed live группы и общий статус обязательных размещений ещё не завершены.
|
||||
- [x] S02 реализация: persistent snapshots, bounded collector с lease/fencing, независимые BGP/health, local authority и atomic discoveries; GET не опрашивает ноды. Coalesced POST refresh подключён к UI. `expires_at`, ошибки GET и offline скрывают устаревшие значения, включая memoized графики. Astra не обнаружила блокеров; 30 web tests, typecheck/lint/build и scoped backend tests прошли. Протокол 2.56.2 сохранён, source-built binary smoke пройден.
|
||||
- [x] S02 PostgreSQL: lease/fencing, discovery rollback и coalesced refresh исполнены на временном PostgreSQL 16.15; все 23 DB/repository tests пройдены без skip (состояние до manifest migration 000037).
|
||||
- [ ] S02 release gate: Linux/race и реальный mixed-speaker/BIRD стенд остаются проверками B08.
|
||||
- [x] B04 миграции: закреплённое соединение, advisory lock до создания schema_migrations, SQL+version в одной транзакции, bounded cleanup и отмена ожидания. Проверка SQL закрывает comment/dollar-identifier/строковые обходы transaction control; Astra повторно проверила.
|
||||
- [x] B04 acquisition: отменяемое ожидание и явные ошибки перенесены в атомарный snapshot RMW; отдельные lock sessions заменены transaction advisory lock на том же соединении, что выполняет запись.
|
||||
- [x] B04 scheduler scope: reference Compose использует trusted DB enqueue; HTTP-режим ограничен явным tenant. Проверены два tenants, idempotency, system-tenant exclusion и отмена до работы; Astra review и полный Go suite прошли. ListTenantIDs/ListModules ещё требуют context, поэтому общий deadline пока не ограничивает все DB reads.
|
||||
- [x] B04 snapshot I/O/RMW: context и ошибки чтения/записи проходят в pipeline/lookup; snapshot read-modify-write и module hash атомарны на одном соединении. Memory concurrency/rollback и обработка partial reads проверены; полный Go test, vet/lint и независимый Astra review прошли.
|
||||
- [ ] B04 manifest: фиксировать единую версию входов tenant-refresh и не допускать публикации результата по устаревшей конфигурации. PostgreSQL connection-loss/rollback/lock-contention tests прошли на изолированной БД; новые manifest-сценарии потребуют отдельного прогона.
|
||||
- [x] B04 PostgreSQL до 000036: 23 обязательных DB/repository tests прошли на изолированном PostgreSQL 16.15 под Windows, без skips; race не запускался. [Протокол проверки](backend-baseline.md).
|
||||
- [ ] B04 PostgreSQL 000037+: после manifest изменений повторить SQL runtime gate; отдельно выполнить Linux/race CI.
|
||||
- [x] B03: единый durable enqueue/claim, lease/heartbeat/fencing, shared jobs API, квоты и graceful shutdown; replay исходного запроса предшествует динамическому подбору модулей и rate limit. Scoped Go tests прошли, Astra выявленные ошибки replay/seed/метрик устранены.
|
||||
- [x] B03: при одинаковом CreatedAt выбор и список задач используют стабильные RequestSequence/ID. Добавлена регрессия с 32 одинаковыми timestamps; jobs/store прошли 20 повторов, независимая проверка Astra — 50 повторов regression. Это не обещание порядка завершения параллельных workers или snapshot-пагинации.
|
||||
- [ ] B03: выполнить SQL/race/restart gate и закрыть разрыв между CRUD и enqueue transactional outbox. Заголовок enqueue-failed делает ошибку видимой, но не устраняет crash gap.
|
||||
- [x] D02/D04 конфигурация: NATS вынесен в optional профиль, Prometheus получил named volume/retention, deploy worker переведён в static runtime, Go build context сужен, зеркало передаёт digest и обновляется явно без фиксированных пауз. Существующие Docker log caps сохранены. Static review Astra и YAML-проверки прошли; [перенос истории Prometheus](docker-resources.md) описан.
|
||||
- [x] D02 CLI flags: агент принимает флаги до/после subcommand и отклоняет неизвестные/лишние аргументы watch/serve. В трёх Compose watch-командах флаги идут перед subcommand для совместимости с 2.56.2; argv-тесты и YAML-проверка прошли.
|
||||
- [ ] D02/D04 runtime: Docker build/config engine, реальные замеры и перенос данных ещё не выполнены. Watch hash-gating, host log replay/rotation, согласование BIRD и SBOM остаются следующими частями Docker-этапа.
|
||||
|
||||
| Подсистема | Существующая реализация | Направление развития |
|
||||
|---|---|---|
|
||||
| Источники префиксов | `AS_PREFIXES`, `CDN_CIDRS`, `DOMAINS`, `IP_RANGES`; кэши, conditional HTTP, снимки | Контроль свежести, происхождения и качества данных |
|
||||
| Pipeline | Batch-запись снимков, input hash, агрегация CIDR, ревизии и diff | Согласованный снимок tenant, отмена, лимиты ресурсов |
|
||||
| BIRD / узлы | Parse-check, LKG, подписанные бандлы, pull/apply, dispatch | Подтверждённый applied, сериализация и надёжное восстановление |
|
||||
| Jobs | Локальный Registry, ограничение активных workers, PostgreSQL audit/claim/reclaim | Одна авторитетная очередь, lease, идемпотентность, чтение после restart |
|
||||
| Наблюдаемость | Prometheus, live BGP polling, PostgreSQL snapshots, runtime logs | Фоновый collector, история BGP, качество измерений |
|
||||
| Доступ | API keys, роли, JWT permissions, tenant context | Проверка доверенных прокси, URL transport policy, интеграционные проверки изоляции |
|
||||
| Эксплуатация | Maintenance policies, dry-run, pruning, аудит обслуживания | Предсказуемые бюджеты, backup/restore, контроль деградации |
|
||||
| UI | React, shadcn/ui, ReUI PRO, Frame kit | Использовать существующие блоки для новых backend-возможностей |
|
||||
|
||||
Не нужно заново создавать очередь, CDN-кэш, revision diff, rollback, lookup, maintenance или мониторинг PostgreSQL: они уже существуют. Нужно усилить их контракт и завершить недостающие части.
|
||||
|
||||
## 3. План улучшения backend
|
||||
|
||||
### B01 · Достоверный жизненный цикл deploy — P0
|
||||
|
||||
**Основание:** `internal/jobs/worker.go:493` вызывает `SetLastAppliedRevision` до удалённого dispatch. Ошибка dispatch сохраняется в metadata, однако job завершается `Succeed` (`:545`, `:573`). Это позволяет считать удалённую ревизию применённой без подтверждения ноды.
|
||||
|
||||
**Работы:**
|
||||
|
||||
1. Разделить `desired_revision`, `published_revision`, `dispatched_revision`, `applied_revision`, `verified_revision`.
|
||||
2. Ввести deployment и результаты по каждому speaker: `pending → dispatched → applying → applied → verified`, отдельно `failed`, `unknown`, `timed_out`.
|
||||
3. Обновлять `applied_revision` только по явному аутентифицированному сообщению ноды о конкретной revision ID. Для новых агентов дополнительно проверять deployment ID/generation и хеш фактической конфигурации; хеш обязателен для уровня `verified`, но не становится новым обязательным полем старого протокола. Для legacy-агента сохранять уровень подтверждения `agent_reported`; без явного revision ID оставлять результат `unconfirmed`, продолжая совместимую доставку. Старое подтверждение не должно перезаписывать новое поколение deploy (см. B08).
|
||||
4. Dispatch трактовать как доставку запроса. Для недоступной ноды сохранять desired state и повторять доставку с ограниченным backoff.
|
||||
5. В результате массовой операции показывать частичный успех и ошибки по узлам. Не переопределять молча семантику существующего `202`: обновить OpenAPI и UI согласованно.
|
||||
|
||||
**Приёмка:** недоступная нода никогда не получает новый `applied`; потеря ответа и повторный dispatch не применяют один и тот же deployment дважды; после перезапуска CP корректно восстанавливает прогресс; 1 ошибка из 5 узлов явно видна в результате.
|
||||
|
||||
### B02 · Безопасное применение BIRD и восстановление — P0
|
||||
|
||||
**Основание:** `internal/birddeploy/apply.go:69–97` игнорирует ошибки создания LKG, меняет файлы по одному, а восстановление после ошибки использует исходный context. При отменённом context повторный configure также может не выполниться. Локальная функция не содержит блокировки конкурирующих apply.
|
||||
|
||||
**Работы:** сериализовать операции на один BIRD instance, включая несколько процессов; использовать отдельный staging на deployment; проверять полный набор файлов и реальные include paths; выполнять переключение согласованного дерева на одном filesystem; не продолжать без пригодного LKG; удалять исключённые из новой ревизии фрагменты; выполнять rollback с отдельным ограниченным recovery-context; сохранять отдельно исходную ошибку и результат восстановления.
|
||||
|
||||
**Приёмка:** тесты двух конкурентных apply, отмены, заполненного диска, ошибки parse/configure и падения между фазами; после каждого сценария BIRD использует целиком старую или целиком новую проверенную конфигурацию. Статус `restored` выдаётся только после подтверждённого восстановления.
|
||||
|
||||
### B03 · Довести PostgreSQL-очередь до надёжного контракта — P1
|
||||
|
||||
**Основание:** `internal/jobs/job.go:351–410`, `internal/jobs/durable.go:62–131`, `internal/repository/job_audit.go:23–70`. Enqueue запускает локального worker; сохранение в БД — best-effort. Повтор idempotency key создаёт новый UUID локально, а SQL conflict сохраняет старый ID строки. Ошибки SQL игнорируются. Reclaim ориентируется на возраст running, без полноценного протокола lease.
|
||||
|
||||
**Работы:**
|
||||
|
||||
1. Для PostgreSQL сделать `INSERT/claim` авторитетным источником выполнения; HTTP `202` возвращать после успешной фиксации задания. Memory-режим оставить явным режимом разработки.
|
||||
2. Определить семантику idempotency: одинаковый ключ и payload возвращают одну операцию в пределах retention; другой payload с тем же ключом — конфликт; намеренный повтор — новый ключ.
|
||||
3. Добавить `owner_id`, `lease_until`, heartbeat, номер попытки и fencing token. Claim выполнять только при наличии свободной worker capacity.
|
||||
4. Реализовать ограниченные retries для временных ошибок, terminal failure и управляемый повтор оператором. Модель доставки — at-least-once с идемпотентными эффектами, без обещания exactly-once.
|
||||
5. Ограничить размер ожидающей очереди и число заданий tenant. Текущий semaphore ограничивает активных workers, но не число ожидающих goroutine.
|
||||
6. List/get/cancel/report читать из общего хранилища: сейчас `routes.go:870–944` обращается к локальному Registry.
|
||||
7. При shutdown прекратить claim, завершить либо отпустить lease и закрыть БД после остановки workers.
|
||||
|
||||
**Приёмка:** две API-реплики, restart и повтор запроса дают один логический job; живой долгий worker не перехватывается; сбой записи в БД не даёт ложного `202`; job виден и отменяется через любую реплику; нагрузочный burst не вызывает неограниченный рост памяти.
|
||||
|
||||
`SKIP LOCKED` подходит для конкурирующих потребителей очереди, но сам по себе не решает lease и идемпотентность: [официальная документация PostgreSQL](https://www.postgresql.org/docs/18/sql-select.html#SQL-FOR-UPDATE-SHARE).
|
||||
|
||||
### B04 · Миграции, блокировки и tenant isolation — P1
|
||||
|
||||
**Основание:** `internal/db/apply.go:34–50` выполняет проверку версии, SQL и запись версии отдельно; миграции запускаются при открытии pool. В `internal/repository/module_snapshot.go:177` используется advisory lock с background-context. Standalone scheduler обходит tenants, но HTTP refresh отправляет с одним bearer (`internal/scheduler/run.go:70–116`).
|
||||
|
||||
**Работы:**
|
||||
|
||||
- Один владелец миграций: dedicated migrate step либо advisory lock на закреплённом соединении; транзакция на поддерживаемую миграцию, явный отдельный путь для non-transactional DDL; проверка совместимости schema при readiness.
|
||||
- Передавать context через store/repository I/O; lock acquisition обязан иметь timeout и возвращать ошибку, а не продолжать без блокировки.
|
||||
- Сделать tenant-refresh согласованным: сериализация render на tenant и фиксация версий входных снимков. Начать с транзакционного manifest входных версий, затем оптимизировать.
|
||||
- Для scheduler выбрать явную модель: доверенный DB worker либо строго ограниченная service identity с tenant-scoped запросами. Не использовать пользовательский bearer как неявный доступ ко всем tenants.
|
||||
- Проверить tenant scope для jobs, revisions, node acknowledgements, monitoring и maintenance. Глобальный мониторинг БД должен быть доступен только соответствующей административной роли.
|
||||
|
||||
**Приёмка:** параллельный старт API/workers не ломает миграции; неудачная миграция не записана как успешная; отмена операции прерывает ожидание lock; тесты минимум с двумя tenants доказывают отсутствие смешивания данных.
|
||||
|
||||
### B05 · Защита исходящих запросов и ограничение нагрузки — P1
|
||||
|
||||
**Основание:** CDN URL уже проверяется, но DNS precheck с кэшем (`internal/pipeline/cdn_url.go:96–132`) отделён от фактического соединения, а общий HTTP transport не проверяет redirect/dial (`internal/httpclient/httpclient.go:18–25`). `clientIP` доверяет X-Forwarded-For (`routes_audit.go:134`), а rate limiter не удаляет старые IP-ключи (`ratelimit.go:36–60`). Это подтверждённые пробелы защиты; эксплуатация в текущей сети не проверялась.
|
||||
|
||||
**Работы:** проверять целевые адреса при соединении и каждом redirect; определить поведение при HTTP proxy; разделить публичные CDN/DoH и разрешённые приватные адреса агентов; ограничить размеры скачивания/распаковки/CSV и длительность операций; добавить квоты дорогостоящих refresh/preview/lookup; доверять forwarded headers только от настроенных proxy; ограничить и очищать rate-limit cache.
|
||||
|
||||
**Приёмка:** тесты redirect на private address, смены DNS-ответа, IPv4-mapped IPv6, oversized/decompression payload; корректный доступ к явно разрешённому private agent; поддельный XFF не обходит лимит; cardinality IP не увеличивает память без границы.
|
||||
|
||||
### B06 · Ускорить lookup, API и pipeline на больших наборах — P2
|
||||
|
||||
**Основание:** `internal/lookup/lookup.go:193` перебирает модули и snapshots; для домена поиск повторяется по каждому resolved IP (`:173–185`). Ошибка DNS сейчас не отражена отдельно от отсутствия IP-совпадений. Размер выигрыша требует benchmark.
|
||||
|
||||
**Работы:**
|
||||
|
||||
- Добавить repository-операцию membership lookup с tenant predicate и индексом по сетевым префиксам; выбрать PostgreSQL `inet/cidr` и индекс по результатам EXPLAIN. Для memory backend — компактный префиксный индекс, если профиль оправдает сложность.
|
||||
- Batch lookup всех resolved IP; лимиты/пагинация совпадений; `resolution_status` и используемый resolver; единая семантика с DoH pipeline.
|
||||
- Для list endpoints — пагинация в SQL, а не после загрузки всей коллекции; summary DTO без тяжёлых snapshots и BIRD-конфига.
|
||||
- Профилировать allocations, COPY/merge, render и bundle; уменьшать копирование больших срезов и повторную сериализацию по конкретным pprof/benchmark данным.
|
||||
- Повтор внешних HTTP: учитывать 429/Retry-After, jitter и общий deadline; сохранять существующий circuit breaker, добавив наблюдаемость и ограничение его кэша.
|
||||
- Не менять все store-интерфейсы за один раз: начать с горячих I/O путей и cancellable методов.
|
||||
|
||||
**Приёмка:** baseline и сравнение на 10k/100k/1M префиксов в подходящем профиле; одинаковый результат IPv4/IPv6 до и после; отсутствие full snapshot scan в DB lookup; API latency не растёт пропорционально числу открытых браузеров.
|
||||
|
||||
### B07 · Интеграционные проверки и эксплуатационная готовность — P1/P2
|
||||
|
||||
**Основание:** CI запускает `go test -race`, но в `.gitea/workflows/quality.yaml` нет отдельного PostgreSQL service/DSN для repository tests. Эти тесты вызывают `t.Skip` без `EVOBGP_TEST_DATABASE_URL` (`internal/repository/backend_test.go:17`). Таким образом зелёный общий Go job не доказывает работу PostgreSQL-сценариев.
|
||||
|
||||
**Работы:** добавить обязательный integration job с временной PostgreSQL и применением миграций; отдельные сценарии durable queue, cancel, two-tenant, restart, prune и concurrent migration; стенд CP + два агента + BIRD для apply/rollback. Добавить backup/restore runbook и периодическое восстановление в изолированное окружение: БД, signing key, node credentials, конфигурации и последние рабочие ревизии. Согласовать RPO/RTO по реальным требованиям.
|
||||
|
||||
OpenTelemetry сейчас является stub (`internal/observability/otel.go:11`). Сначала завершить структурированные логи и request/job/deployment correlation IDs; полноценный tracing включать опционально с sampling, когда он нужен для расследований.
|
||||
|
||||
**Приёмка:** CI явно подтверждает исполнение DB-тестов, а не skip; аварийный сценарий воспроизводим; восстановленный стенд проверяет подписи и применяет известную ревизию; все изменения проходят существующие Go/OpenAPI/BIRD quality gates.
|
||||
|
||||
### B08 · Обратная совместимость и постепенное обновление спикеров — P1, обязательный release gate
|
||||
|
||||
**Цель:** новый CP обслуживает смешанный парк старых и новых speakers. Отсутствие нового telemetry/ACK API не должно лишать старый speaker возможности работать по существующему pull/sync протоколу. Совместимость доставки и полнота подтверждения — отдельные характеристики.
|
||||
|
||||
**Основание в текущем коде:** `internal/nodedispatch/dispatch.go` уже читает `applied_revision_id` из sync-ответа, а `health.go` — `last_applied_revision_id` и `last_sync_at`. При этом sync-клиент подставляет запрошенную revision ID, если ответ её не содержит. Такая подстановка не является подтверждением ноды и должна быть устранена без отказа от legacy dispatch. Текущий формат бандла описан в `internal/bundle/pack.go` и проверяется в `verify.go`.
|
||||
|
||||
#### Контракт совместимости
|
||||
|
||||
| Поверхность | Что сохранить | Как вводить новое |
|
||||
|---|---|---|
|
||||
| Node API control plane | Существующие `/v1/speakers/{id}/revisions/latest`, `/bundle/{revision_id}`, получение signing public key и используемые механизмы auth | Новые обязательные поля/семантика — через согласованную capability или отдельную версию API; старые запросы остаются допустимыми |
|
||||
| Agent API | Текущие sync/health запросы и известные поля ответа | Не отправлять новые поля старым strict-decoders; расширенный ACK и telemetry — только после определения возможностей |
|
||||
| Signed bundle | Для legacy сохранить tar.gz, layout файлов, manifest, Ed25519/SHA-256 и проверку доверенного ключа | Новый формат выдавать только совместимому клиенту; legacy-вариант создавать и подписывать отдельно, не менять уже подписанные байты |
|
||||
| BIRD-конфигурация | Синтаксис и capabilities реально установленной на speaker версии | Рендерить по профилю совместимости узла; новую директиву не отправлять неподдерживающему BIRD |
|
||||
| Credentials / TLS | Действующие ключи доступа, сохранённый signing key и проверка TLS | Ротация через отдельный переход с периодом перекрытия там, где протокол его поддерживает; не обходить подпись/auth ради совместимости |
|
||||
| UI / monitoring | Старые узлы остаются управляемыми и видимыми | Явные `legacy`, `unsupported`, `unconfirmed`; отсутствие новой метрики не означает неисправность BGP |
|
||||
|
||||
#### Определение возможностей и fallback
|
||||
|
||||
1. Перед изменением протокола инвентаризировать реальные версии/дигесты `evobgp-agent`, `evobgp-node`, BIRD и режимы работы: agent serve, pull по расписанию/CLI, иные используемые варианты. Зафиксировать поддерживаемый legacy baseline, а не предполагать, что все ноды равны текущему checkout.
|
||||
2. Новые агенты объявляют protocol version и capabilities, например `deployment_ack`, `config_hash`, `telemetry_v2`, `bundle_v2`. Конкретные имена закрепить в OpenAPI. Возможности важнее сравнения строк версии.
|
||||
3. Отсутствие capability metadata в корректном старом ответе либо документированный `404/405` capability endpoint означает legacy. Timeout, `401/403`, TLS error и некорректный ответ означают ошибку связи/доступа; они не должны автоматически запускать downgrade или обход проверки безопасности.
|
||||
4. Для legacy использовать существующие request/response shapes и signed bundle. Если старый агент явно сообщает совпадающую revision ID, записывать `agent_reported`, но не утверждать проверку config hash. Голый `2xx` подтверждает только запрос; без подтверждения ревизии показывать `dispatched/unconfirmed`. Если старый health сообщает revision ID без времени наблюдения, показывать ограниченную свежесть, не выдавая его за ACK нового deployment.
|
||||
5. Для нод без generation-aware ACK сериализовать deploy на speaker, не допускать неоднозначных перекрывающихся операций. Устаревший ответ не меняет текущий desired state; неоднозначность остаётся `unconfirmed` до новой проверки.
|
||||
6. Старые pull-only узлы продолжают получать published revision без обязательного push/callback. Отсутствие ACK не вызывает бесконечный повтор apply: bounded retry относится к доставке, наблюдение — к reconciliation с backoff.
|
||||
7. Новые health-gated rollout/auto-rollback допускают только узлы с достаточным уровнем подтверждения. Legacy-узел можно обновить прежним способом с явным ограничением автоматической верификации; его не блокируют глобально из-за отсутствующей новой функции.
|
||||
|
||||
#### Порядок перехода
|
||||
|
||||
- **Сначала CP:** добавить совместимые адаптеры, schema expansion и распознавание возможностей; новые флаги включать по speaker/cohort. Старый путь доставки остаётся рабочим.
|
||||
- **Затем canary-агент:** проверить новый протокол на одной ноде, затем обновлять остальные поочерёдно. Смешанный парк — поддерживаемое рабочее состояние, а не только короткий момент установки.
|
||||
- **Откат:** проверить новый CP со старым агентом и новый агент с предыдущим поддерживаемым CP. Сохранить старый digest, совместимый bundle generation и предыдущий протокол. Миграции БД — expand/contract: удаление/переименование полей откладывается за окно rollback; обратная совместимость схемы проверяется запуском предыдущего CP на обновлённой схеме.
|
||||
- **Срок поддержки:** поддерживать инвентаризированные развёрнутые legacy-версии в рамках этого плана. Точный minimum supported version и окно deprecation зафиксировать после инвентаризации. Удаление legacy-пути — отдельное решение владельца проекта после миграции нод; не отключать его автоматически при первом новом релизе. Для опасного неподдерживаемого релиза — адресный план обновления, без бессрочного сохранения уязвимого поведения.
|
||||
|
||||
**Приёмка:** integration matrix использует реальные сохранённые старые binaries/images и golden bundles, а не только mocks текущего агента. Обязательные пары: новый CP + самый старый поддерживаемый агент/CLI; новый CP + текущий агент; предыдущий CP + новый агент; смешанный парк. Проверить pull/sync, подпись, auth, parse/apply/rollback, отсутствующие capabilities/ACK, reconnect после offline и поздний ответ. Обновление только CP не требует изменений на legacy-ноде и не вызывает BGP flap; недоступные новые функции явно обозначены. Проверка совместимости входит в CI/release gate каждого изменения node API, bundle, auth и BIRD render.
|
||||
|
||||
## 4. Сбор статистики: целевая модель
|
||||
|
||||
### S01 · Отделить текущее состояние от последнего известного — P1
|
||||
|
||||
**Основание:** `internal/httpapi/peers_live.go:275–283` не заменяет сохранённый session state, если live-проверка не нашла состояние. В `speakers_live.go:117–126` отсутствующий результат превращается в пустой view, а `:24–48` считает отсутствие ошибки успешным polling.
|
||||
|
||||
Для каждого измерения хранить `observed_at`, `received_at`, `last_success_at`, `source`, `quality`, `error_code`. Состояния качества: `ok`, `stale`, `unreachable`, `unknown`, `disabled`. Отдельно хранить последнее подтверждённое BGP state. Неизвестное значение — `null`/gap, а не ноль и не зелёный статус.
|
||||
|
||||
**Приёмка:** выключение агента переводит данные в stale/unreachable; удалённая сессия не остаётся Established; не настроенный collector не считается успешным; UI показывает возраст измерения и причину отсутствия данных.
|
||||
|
||||
### S02 · Перенести polling из чтения API в фоновый collector — P1
|
||||
|
||||
Исходно live-опрос был привязан к HTTP-чтению и короткому BGP cache, а health опрашивался в `collectSpeakerLiveStatus`. В реализации S02 эти пути удалены: GET читает persistent snapshots, collector выполняет ограниченные фоновые опросы.
|
||||
|
||||
**Целевая цепочка:**
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
N[Agent / BIRD] --> C[Collector: jitter, limits, deadlines]
|
||||
C --> L[Последнее состояние и качество]
|
||||
C --> E[История событий]
|
||||
C --> M[Prometheus metrics]
|
||||
L --> API[Read API]
|
||||
E --> API
|
||||
API --> UI[ReUI PRO: dashboard и диагностика]
|
||||
```
|
||||
|
||||
Начальный интервал — 15–30 секунд как проверяемая настройка, не гарантированный SLA. Добавить ограничение параллельных опросов, jitter, backoff для недоступных узлов и единственного владельца опроса в нескольких CP-репликах. Обычный GET отдаёт snapshot сразу. Кнопка «Обновить» ставит объединяемый запрос, не создаёт отдельный fan-out на каждого клиента.
|
||||
|
||||
**Приёмка:** при переходе с 1 на 20 UI-клиентов число poll к одному speaker практически неизменно; просроченные данные отмечаются явно; недоступный speaker не задерживает всю страницу; очередь collector ограничена.
|
||||
|
||||
### S03 · История BGP и событий — P2
|
||||
|
||||
Добавить историю переходов BGP state, длительности недоступности, flap count, времени последнего established, prefix counts по AFI/SAFI там, где их надёжно отдаёт BIRD. Отделить BGP FSM от состояния самого агента и транспорта управления.
|
||||
|
||||
Рекомендуемое разделение: PostgreSQL — события, deployment, аудит и ограниченные агрегаты; Prometheus — временные ряды с ограниченной cardinality. На минимальном VPS разрешить режим только событий и последнего snapshot. Не записывать каждый неизменившийся poll как полноценное событие.
|
||||
|
||||
Начальные retention-параметры для проверки: детальные события 30 дней, пятиминутные агрегаты 90 дней; это настраиваемые предложения, не требование к любому VPS. Оценивать объём по `число рядов × 86400 / интервал × дни`, затем измерять фактический размер с индексами/WAL. Partitioning вводить при подтверждённом объёме, не автоматически.
|
||||
|
||||
**Приёмка:** разрывы данных видны на графике; restart collector не создаёт ложный flap; повторные события дедуплицируются; очистка истории ограничена по времени/пакету; экспорт не блокирует ingest.
|
||||
|
||||
### S04 · Статистика качества источников и pipeline — P1/P2
|
||||
|
||||
Для каждого источника: время последней попытки и успеха, latency, HTTP status, ETag/cache hit, valid/invalid/duplicate prefix counts, возраст использованных данных, число последовательных ошибок. Для каждой ревизии — manifest источников и их версий, raw/aggregated counts, добавленные/удалённые префиксы, предупреждения о stale fallback.
|
||||
|
||||
Настроить пороги изменений: внезапный пустой ответ или массовое удаление префиксов переводят ревизию в `needs_review`; ручное исключение оставляет audit trail. Порог зависит от модуля, а не от одной глобальной константы.
|
||||
|
||||
**Приёмка:** оператор видит, почему данные не обновились; временная ошибка upstream не выдаётся за новый успешный ingest; опасное сокращение списка не публикуется автоматически без установленной политики.
|
||||
|
||||
### S05 · Правильная аналитика PostgreSQL и алерты — P2
|
||||
|
||||
Не смешивать накопительные статистики PostgreSQL с показателями за окно. Для rate/delta учитывать `stats_reset`, временной интервал и исчезновение query ID. В корреляции показывать только реально измеренные точки: текущий `internal/pgmonitor/correlation.go` объединяет историю jobs с одной текущей точкой cache hit, а `RecordCorrelationSnapshot` — заглушка.
|
||||
|
||||
Расширить существующие Prometheus alerts на потерю телеметрии, stale источники, очередь/возраст jobs, lease failure, deployment drift и ошибки БД. Предусмотреть окно обслуживания, задержку срабатывания, дедупликацию и восстановление. Правило «нет Established» отдельно от «не удалось опросить BIRD».
|
||||
|
||||
Не использовать job ID, revision ID, полный URL, домен или префикс как неограниченные labels. Это соответствует рекомендациям [Prometheus по cardinality](https://next.prometheus.io/docs/practices/naming/). Детали хранить в событиях и логах.
|
||||
|
||||
**Приёмка:** outage collector вызывает отдельный alert; графики не рисуют отсутствующие значения как нули; периодические snapshots не дублируются каждой API-репликой; количество рядов имеет проверяемый бюджет.
|
||||
|
||||
### S06 · Разделить смысл метрик — P1
|
||||
|
||||
`materialized_prefixes_max/sum` агрегируют сохранённые ревизии (`internal/repository/postgres.go:78–89`), а `bgp_peer_sessions` берёт сохранённый `meta_json.session_state` (`:100–116`). Локальная метрика Established не описывает все remote speakers. Это разные показатели: их нельзя объединять в один KPI «маршруты сейчас» или «сеть здорова».
|
||||
|
||||
Добавить явные группы **inventory / desired / published / confirmed applied / observed live**; для multi-speaker считать отдельно «есть Established хотя бы где-то» и «здоровы все обязательные размещения». Общий статус — healthy/degraded/down/unknown по задокументированной политике. При смене метрик сохранить переходный период и обновить dashboards/alerts. Ошибка DB-агрегации должна иметь collection error, а не выглядеть достоверным нулём.
|
||||
|
||||
**Приёмка:** добавление старой ревизии в историю не увеличивает KPI текущих маршрутов; outage одного обязательного speaker даёт degraded даже при Established на другом; fresh live и сохранённая конфигурация явно различаются в API.
|
||||
|
||||
## 5. Отдельный план улучшения и добавления функций
|
||||
|
||||
| ID / приоритет | Функция | Польза и объём MVP | Зависимости / критерий готовности |
|
||||
|---|---|---|---|
|
||||
| F01 · P1 | **Центр состояния сети** | Пиры, speakers, stale источники, deployment drift и очередь проблем на одном экране | S01–S02, B01; каждый статус имеет источник и возраст |
|
||||
| F02 · P1 | **Паспорт ревизии** | Продолжение существующих preview/diff: происхождение данных, impact по узлам, warnings и подтверждения apply | B01, S04; можно объяснить каждое существенное изменение |
|
||||
| F03 · P2 | **Управляемая раскатка** | Canary speaker, этапы rollout, лимит параллелизма, pause/resume, окно обслуживания | B01–B03/B08; ошибка canary останавливает продвижение; legacy-путь доступен отдельно от автоматической верификации |
|
||||
| F04 · P2 | **История инцидентов и уведомления** | Flap, outage, stale, deploy failure; acknowledge, silence, webhook и один выбранный канал уведомлений | S03–S05; дедупликация, recovery notification и повтор доставки |
|
||||
| F05 · P2 | **Объяснение принадлежности IP/домена** | Развитие existing lookup: источник → модуль → community → ревизия → desired/applied speaker | B06, F02; DNS failure отделён от «совпадений нет» |
|
||||
| F06 · P2 | **Проверка источника до включения** | Развитие CDN preview: тип ответа, нормализация, ошибки строк, ожидаемый diff, тест расписания | S04, B05; тест не меняет опубликованную ревизию |
|
||||
| F07 · P2 | **Backup и восстановление из панели** | Статус последних backup/restore drill, экспорт manifest, запуск разрешённой backup job | B03/B07; секреты не попадают в обычный экспорт UI |
|
||||
| F08 · P2 | **Диагностический пакет** | Версии CP/agent/BIRD, job/deploy timeline, redacted logs, config hashes | B07; токены и приватные ключи исключены; лимит размера |
|
||||
| F09 · P3 | **RPKI и защитные BGP-политики** | Warning-only проверка origin, max-prefix guard, затем опциональное применение политики | Отдельный дизайн и BIRD-тесты; отсутствие валидатора не меняет маршрутизацию молча |
|
||||
| F10 · P3 | **Трафик по интерфейсам/потокам** | Если нужен именно трафик: SNMP/agent counters для bps/pps; NetFlow/IPFIX — отдельный сервис с sampling/retention | Отдельный бюджет и потребность; BGP counters нельзя выдавать за объём пользовательского трафика |
|
||||
|
||||
**ReUI PRO уже подключён.** Новые экраны строить на существующем `surface: frame` и `apps/web/src/components/reui-kit/`: `OpsDashboard` для F01, `ResourcePage` для событий/источников, `DetailPanel` для ревизии, `SettingsShell` для политик. Сохранять DataGrid/Filters и семантические Badge. Показывать stale/unknown/partial failure, а не только success/error. Переустановка UI-библиотеки или редизайн shell не нужны. Реализация UI должна следовать [контракту проекта](ui-design-contract.md) и обязательным typecheck/lint/build.
|
||||
|
||||
## 6. Отдельный план оптимизации Docker и ресурсов хоста
|
||||
|
||||
> **Меньший образ** сокращает загрузку, распаковку и место на диске. **Меньше RAM/CPU/IO** достигается также сокращением процессов, работы collectors, повторных configure, логирования и конкуренции. Эти результаты измеряются отдельно.
|
||||
|
||||
### Что уже оптимизировано
|
||||
|
||||
В `deploy/docker/gobinary/Dockerfile:26` уже используются multi-stage, `CGO_ENABLED=0`, `-trimpath`, `-s -w`; scheduler/ingest/render используют distroless targets. BuildKit registry cache и общая Go/web сборка присутствуют в `docker-bake.hcl`. `.dockerignore` исключает тяжёлые лишние каталоги. nginx уже работает с одним worker; Docker stdout rotation настроена. Повторное добавление этих механизмов не даст заявляемого нового выигрыша.
|
||||
|
||||
### D00 · Измеримый baseline — P1, до изменения лимитов
|
||||
|
||||
Зафиксировать image digests, effective Compose, vCPU/RAM, filesystem/cgroup, число tenants/modules/prefixes/speakers/peers. Собрать следующие сценарии на отдельном стенде:
|
||||
|
||||
| Сценарий | Что измерять | Зачем |
|
||||
|---|---|---|
|
||||
| Idle 15–30 минут | CPU, RSS/cgroup memory, SQL connections, число poll/configure, bytes logs | Найти постоянную лишнюю работу |
|
||||
| Refresh → render → apply | Длительность, peak memory, allocations/GC, DB waits, BIRD parse/configure | Не оптимизировать idle ценой пикового OOM |
|
||||
| Несколько refresh + UI | p95/p99 API, queue wait, CPU throttling, IO PSI | Проверить backpressure |
|
||||
| Недоступные upstream/agents | Retries, goroutines, stale age, рост очереди | Убедиться в ограниченности деградации |
|
||||
| Restart и 24h soak | Recovery, leaks, WAL/log growth, OOM/restarts, BGP flaps | Проверить эксплуатационный результат |
|
||||
| Cold/warm image build и pull | Compressed bytes, unique/shared layers, build/pull/extract/start time | Отделить CI, сеть, диск и runtime |
|
||||
|
||||
Использовать effective `docker compose config`, `docker inspect` для фактических cgroup limits, `docker stats`, `docker system df -v`, cgroup memory/events/cpu.stat, PostgreSQL statistics и BIRD counters. `shm_size` — допустимая ёмкость, не автоматически занятая RAM. Shared layers не считать несколько раз.
|
||||
|
||||
### D01 · Исправить ресурсную модель профилей — P1
|
||||
|
||||
`docker-compose.microvps-full.yaml:9` обещает около 800 MiB суммы лимитов. С учётом наследуемого edge получается **1032M**: all 280 + PostgreSQL 256 + BIRD 96 + agent 48 + NATS 48 + web 48 + Prometheus 128 + Traefik 128. Это сумма верхних границ, **не измеренная RAM**, но заявленного резерва для ОС на 1 GiB VPS она не обеспечивает.
|
||||
|
||||
Production example и remote-speaker Compose не имеют аналогичных CPU/RAM caps. Подготовить согласованную матрицу ресурсов:
|
||||
|
||||
| Профиль | Предлагаемый состав | Политика |
|
||||
|---|---|---|
|
||||
| **microvps-core** — новый вариант | all + PostgreSQL + нужные BIRD/agent/edge; UI по потребности | Минимум фоновых процессов; внешний scrape либо ограниченный локальный мониторинг |
|
||||
| **microvps-full** | Core + UI + локальная история/мониторинг | Допуск на 1 GiB только после workload-теста; если бюджет не сходится — честно поднять минимальные требования |
|
||||
| **reference** | API и отдельные workers | Сохранить изоляцию процессов, но суммировать pools и concurrency |
|
||||
| **remote-speaker** | BIRD + agent serve + защищённый endpoint | Отдельный бюджет на маршруты, reconfigure и reconnect; CP workload сюда не переносить |
|
||||
|
||||
Оставлять измеренный запас ОС/Docker и на пики BIRD/subprocesses. Не ужимать BIRD лимиты до появления flap/OOM. Согласовать значения с реальным cgroup, а не только YAML. Основа: [Docker resource constraints](https://docs.docker.com/engine/containers/resource_constraints/).
|
||||
|
||||
### D02 · Сократить лишние процессы и повторяющуюся работу — P1/P2
|
||||
|
||||
| Изменение | Основание | Ожидаемый эффект | Проверка |
|
||||
|---|---|---|---|
|
||||
| Вынести **NATS** в optional профиль | `internal/broker/connect.go:12–18` только логирует URL; Compose запускает сервис и зависимости | Удаление лишнего процесса и его RAM/IO в минимальном профиле | Внутренних потребителей нет; перед production проверить внешних клиентов; удалить зависимости/env согласованно |
|
||||
| Change-driven или hash-gated **BIRD configure** | `cmd/evobgp-agent/main.go:86–94` безусловно configure в watch-цикле, обычно каждые 30s | Меньше работы при неизменной конфигурации | Реальный change применяется; restart/manual change покрываются reconciliation; учитывать, что remote serve — другой режим |
|
||||
| Проверить CLI flags | `flag.Parse` до positional subcommand, Compose передаёт флаги после `watch` | Настройки interval/socket реально действуют | Тест argv для watch/serve; неверный flag не игнорируется молча |
|
||||
| Ограничить **дублирование логов** | Production log collector делает `docker logs -f` в host-файлы; reconnect без cursor может повторять историю | Меньше disk IO и роста host logs | Cursor/rotation, dedup replay; Runtime Logs API продолжает работать |
|
||||
| Использовать существующий cleanup осознанно | `internal/runtimelogs/policy.go:32–37` default disabled | Быстрый контроль накопления | Проверить политику/retention; cron threshold не выдавать за жёсткий byte cap |
|
||||
|
||||
### D03 · Настроить Go, PostgreSQL и мониторинг как общий бюджет — P2
|
||||
|
||||
- На текущем Go 1.24 провести A/B с `GOMAXPROCS` по CPU quota и `GOMEMLIMIT` с запасом для не-Go памяти и subprocesses. `GOMEMLIMIT` — мягкий лимит runtime, не полный RSS контейнера. Отдельно оценить обновление toolchain, где изменено container-aware поведение; не смешивать обновление версии с первыми correctness fixes. См. [Go GC guide](https://go.dev/doc/gc-guide) и [Go 1.25 runtime](https://go.dev/doc/go1.25#runtime).
|
||||
- Настроить уже существующие `EVOBGP_DB_MAX_CONNS/MIN_CONNS` (`internal/db/open.go:28–36`) по роли. Сумма pools reference-профиля должна укладываться в PostgreSQL max_connections. `work_mem` оценивать с учётом числа одновременно выполняемых операций, а не как единственный буфер процесса.
|
||||
- Ограничить общий job concurrency и отдельные бюджеты сетевого ingest/render/deploy/maintenance. Maintenance не должен вытеснять live collection и подтверждение deploy.
|
||||
- Для Prometheus добавить явный named data volume, retention по времени и размеру с запасом для WAL; проверить recreate без потери истории. В текущем Compose mount `/prometheus` явно не закреплён. Для microvps проверить scrape 30–60s вместо 15s либо external scrape; оценить влияние на detection latency. Сокращение retention не означает пропорционального сокращения head RAM. См. [Prometheus storage](https://prometheus.io/docs/prometheus/latest/storage/).
|
||||
|
||||
### D04 · Уменьшить образы и ускорить сборку — P2
|
||||
|
||||
1. **Кандидат на облегчение deploy worker.** Target `evobgp-deploy` наследует birdc runtime (`docker-bake.hcl:238–240`), хотя `internal/deploy/run.go` выполняет наблюдение drift. Проверить startup/dependency paths и перевести в уже существующий static runtime, если BIRD tools действительно не вызываются. Считать unique-layer disk отдельно от размера одного image.
|
||||
2. **Согласовать BIRD toolchain.** API/all/node используют сборку BIRD 2.14 на Debian, daemon/agent — Ubuntu package (`gobinary/Dockerfile:50–58`, `bird2/Dockerfile:4–12`). Сверить реальные версии/capabilities и parse-check на тех же конфигурациях. Затем унифицировать версию/источник и минимальные runtime libraries для новых образов, сохранив render/validation profile старых удалённых BIRD по B08. Обновление CP image не требует синхронного обновления всех speakers. Не переносить динамический BIRD в scratch без зависимостей.
|
||||
3. **Сузить Go build inputs.** Текущая общая сборка видит root context. Проверить инвалидирование при web-only изменении; отделить Go sources/go.mod/go.sum/migrations и необходимые embed assets. Сохранить общую компиляцию targets, если benchmark не докажет пользу другого подхода. Проверить cold ephemeral builder: layer cache и cache mounts — разные механизмы. См. [Docker cache optimization](https://docs.docker.com/build/cache/optimize/).
|
||||
4. **Обновляемые base digests.** Mirror script пропускает существующий tag и содержит пять фиксированных `sleep 8` (`mirror-base-images.sh:46–69`). Сделать контролируемый digest refresh и задержки только при retry/rate limit. Это улучшение CI/воспроизводимости, не экономия RAM приложения.
|
||||
5. **Проверки поставки.** Фиксировать digest, проверять зависимости и базовые образы, генерировать SBOM при сборке; не включать tooling сборки в runtime. Не применять UPX и случайную смену libc ради красивого image-size числа без benchmark и совместимости.
|
||||
|
||||
### Приёмка Docker-этапа
|
||||
|
||||
До/после используют одинаковый workload и аппаратный профиль. Уменьшение compressed image size, unique disk, steady/peak memory, CPU и logs/day отражается раздельно. Обязательные ограничения: нет новых OOM/restarts и BGP flaps; наборы prefix/community совпадают; подпись, apply и rollback работают; p95/p99 и время refresh не ухудшены сверх согласованного допуска (начальная гипотеза — 5%, уточнить по шуму стенда).
|
||||
|
||||
Rollout: лаборатория → canary → остальные узлы; immutable tag/digest и предыдущий рабочий digest готовы. Пересоздавать только изменяемые сервисы с сохранением volumes. Экономия места не должна включать удаление рабочих данных или истории без retention-политики.
|
||||
|
||||
## 7. Очерёдность реализации и оценка объёма
|
||||
|
||||
Оценки ниже — предварительные **человеко-дни** одного опытного инженера, включая тесты и документацию. Это не календарное обещание. После первых воспроизведённых сценариев уточнить оценки; заложить резерв 25–35% на совместимость, миграции и fault injection. Работы могут идти параллельно только при разделении областей и соблюдении зависимостей.
|
||||
|
||||
| Этап | Результат | Задачи | Оценка |
|
||||
|---|---|---|---|
|
||||
| **0 · Проверяемая основа** | PostgreSQL integration CI, воспроизведения проблем, baseline ресурсов, инвентаризация legacy | B07, D00, начало B08, актуализация architecture | 3–5 дней |
|
||||
| **1 · Доверие и защита** | Честные live/applied статусы, безопасный BIRD recovery, закрытие URL/proxy gaps | S01/S06, минимальный B01, B02, B05 | 8–14 дней |
|
||||
| **2 · Устойчивое исполнение** | Общая durable очередь и jobs API, миграции/locks, multi-tenant scheduler | B03–B04, завершение B01 | 10–16 дней |
|
||||
| **3 · Статистика и ресурсный бюджет** | Независимый collector, история, alerts, измеренная Docker-конфигурация | S02–S05, D01–D04, B06 | 10–18 дней |
|
||||
| **4 · Операторские функции** | Dashboard, паспорт ревизии, rollout, incidents | F01–F04/F06, по очереди | 8–14 дней |
|
||||
|
||||
**Основной объём:** ориентировочно 39–67 человеко-дней до резерва. F05/F07/F08 и особенно F09/F10 оценивать отдельно после уточнения глубины; они не должны задерживать correctness и эксплуатационные исправления. Наличие ReUI PRO сокращает работу над типовыми экранами, но не заменяет backend-контракты и проверки отказов.
|
||||
|
||||
**Дополнение по совместимости B08:** предварительно ещё 4–8 человеко-дней на adapters, version matrix и тесты реальных legacy-артефактов; итого 43–75 человеко-дней до резерва. Уточнить после инвентаризации. B08 выполняется сквозным потоком этапов 0–3 и обязателен до выпуска изменённых протоколов B01/S02 или BIRD toolchain D04.
|
||||
|
||||
### Первая итерация: конкретный backlog
|
||||
|
||||
1. Добавить PostgreSQL integration job и regression tests: повтор idempotency key, queued > grace при двух workers, restart/history, stale Established, непроведённый poll.
|
||||
2. Исправить модель live-status; добавить freshness/quality в OpenAPI и UI.
|
||||
3. Прекратить преждевременную запись remote `applied`; показывать pending/unknown до ACK.
|
||||
4. Сериализовать BIRD apply и сделать ошибки LKG/recovery явными.
|
||||
5. Исправить transport-level CDN URL policy и trusted-proxy обработку.
|
||||
6. Снять baseline effective Compose/RAM/CPU/IO, подтвердить бюджет microvps; подготовить optional NATS и bounded logs изменения.
|
||||
7. Зафиксировать legacy baseline и golden bundles; добавить проверки «обновили только CP — старый speaker продолжает pull/sync/apply». Выполнить до выпуска изменений подтверждения apply и telemetry.
|
||||
|
||||
На первой итерации допустимо временно ограничить конкурентный deploy и число API-writers, пока lease/fencing не готовы. Это должно быть явным ограничением конфигурации и runbook, а не скрытой надеждой на один процесс.
|
||||
|
||||
### Зависимости, которые нельзя пропускать
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[PG integration и воспроизведение отказов] --> B[Миграции и transactional enqueue]
|
||||
B --> C[Lease / fencing / durable jobs API]
|
||||
C --> D[Надёжный deployment workflow]
|
||||
E[Сериализация BIRD и recovery] --> D
|
||||
D --> F[Canary / автоматический rollout]
|
||||
G[Честные статусы и timestamps] --> H[Фоновый collector]
|
||||
H --> I[История / alerts / операторская аналитика]
|
||||
J[Baseline ресурсов] --> K[Docker и runtime tuning]
|
||||
H --> K
|
||||
```
|
||||
|
||||
## 8. Измеримые результаты и правила готовности
|
||||
|
||||
| Направление | Контрольная проверка |
|
||||
|---|---|
|
||||
| Достоверность | Unknown/stale не отображаются как Established/healthy; applied всегда имеет подтверждение |
|
||||
| Очередь | При DB outage запрос не принят ложно; restart восстанавливает задания; lease и эффект защищены от повторов |
|
||||
| Сеть | Конкурентный apply и сбой rollback не скрываются; old/new поколения не смешиваются |
|
||||
| Статистика | Число UI клиентов не определяет частоту polling; история имеет coverage/gaps и ограниченный retention |
|
||||
| Производительность | Сохранён baseline; p95/p99, peak memory и GC сравниваются на фиксированном наборе данных |
|
||||
| VPS | Effective limits проверены, достаточный запас памяти подтверждён пиковым workload и 24h soak |
|
||||
| Доступ | Два tenant, несколько ролей, node ACK, redirect и forged forwarded headers покрыты тестами |
|
||||
| Восстановление | Успешно выполнен restore drill со схемой БД, ключами и проверкой бандла |
|
||||
| Контракт | OpenAPI обновлён до реализации; docs соответствуют реальному startup и режимам работы |
|
||||
| Обратная совместимость | Новый CP обслуживает поддерживаемые старые speakers; смешанный парк проходит version matrix; отсутствие новых ACK/метрик не ломает доставку и не создаёт ложный verified |
|
||||
|
||||
Для Go-изменений — `gofmt`, `go vet ./...`, `scripts/lint-go.ps1`, `go test ./... -race -count=1`; для API — OpenAPI lint/codegen; для BIRD — scenarios и `bird -p`; для UI — обязательные typecheck/lint/build и релевантные тесты. Фактически выполненные проверки приведены в [backend-baseline.md](backend-baseline.md); наличие команды в плане не означает её успешный прогон.
|
||||
|
||||
## 9. Решения, которые стоит отложить до измерений
|
||||
|
||||
- Переход на микросервисы, Kafka/NATS/Redis как обязательную инфраструктуру: текущая PostgreSQL-очередь сначала должна получить корректные гарантии.
|
||||
- Выделенный time-series кластер: сначала посчитать ряды, частоту и retention; для малого профиля PostgreSQL events + Prometheus достаточно как стартовая архитектура.
|
||||
- Переписывание Go backend или замена PostgreSQL: аудит не выявил оснований для такого масштаба изменений.
|
||||
- Автоматический rollback по одному неуспешному poll: сначала нужны quality/coverage, hysteresis и проверенный recovery.
|
||||
- RPKI enforcement и сбор NetFlow: отдельный продуктовый этап с сетевым тестированием и самостоятельным ресурсным бюджетом.
|
||||
|
||||
## 10. Навигация по источникам проекта
|
||||
|
||||
| Область | Основные файлы |
|
||||
|---|---|
|
||||
| Контракт API / доступ | [openapi.yaml](openapi.yaml), [access.md](access.md), [permissions.go](../internal/httpapi/permissions.go) |
|
||||
| Queue / persistence | [job.go](../internal/jobs/job.go), [durable.go](../internal/jobs/durable.go), [job_audit.go](../internal/repository/job_audit.go), [bootstrap.go](../internal/httpapi/bootstrap.go) |
|
||||
| Deploy / recovery | [worker.go](../internal/jobs/worker.go), [apply.go](../internal/birddeploy/apply.go), [nodedispatch](../internal/nodedispatch/) |
|
||||
| BGP live | [peers_live.go](../internal/httpapi/peers_live.go), [speakers_live.go](../internal/httpapi/speakers_live.go), [bgp_sessions.go](../internal/birdfmt/bgp_sessions.go) |
|
||||
| Метрики / PostgreSQL | [metrics.go](../internal/observability/metrics.go), [pgmonitor](../internal/pgmonitor/), [alerts.yml](../deploy/prometheus/alerts.yml) |
|
||||
| Источники / lookup | [pipeline](../internal/pipeline/), [lookup.go](../internal/lookup/lookup.go), [httpclient.go](../internal/httpclient/httpclient.go) |
|
||||
| Docker / CI | [docker-bake.hcl](../deploy/docker/docker-bake.hcl), [Compose](../deploy/compose/docker-compose.yaml), [microvps override](../deploy/compose/docker-compose.microvps-full.yaml), [quality.yaml](../.gitea/workflows/quality.yaml) |
|
||||
|
||||
Номера строк в тексте относятся к исследованному checkout; при реализации ориентироваться также на имена функций. Приоритеты — инженерная оценка по последствиям возможного отказа, а не утверждение о произошедших production-инцидентах.
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# Ограничения подписанных bundles
|
||||
|
||||
Формат `.tar.gz`, поля manifest, порядок файлов и Ed25519-подпись не изменены. Замороженный legacy fixture и проверка структуры старого manifest остаются тестами совместимости. Новые ограничения применяются до больших выделений памяти и не требуют обновления wire-протокола speakers 2.56.2.
|
||||
|
||||
| Ограничение по умолчанию | Значение |
|
||||
| --- | --- |
|
||||
| Сжатый bundle / HTTP download / локальный файл | 128 MiB |
|
||||
| Весь распакованный tar, включая headers, PAX и хвост gzip | 512 MiB |
|
||||
| Один payload-файл | 256 MiB |
|
||||
| `manifest.json` | 2 MiB |
|
||||
| Файловые записи tar, включая manifest и signature; служебные PAX ограничены общим объёмом tar | 4096 |
|
||||
| Длина относительного пути | 1024 bytes |
|
||||
| Ответ latest revision | 64 KiB |
|
||||
| Диагностическое тело HTTP-ошибки | 4 KiB |
|
||||
| Чтение error-body перед HTTP retry | 64 KiB на попытку |
|
||||
|
||||
Это верхние пределы обработки недоверенного ввода, а не гарантия работы на устройстве с малым RAM. Проверка пока хранит сжатый bundle и распакованные payloads в памяти. Большие конфигурации, включая порядок миллиона IPv6 route-строк, не ограничены маленьким лимитом отдельного CDN-источника. Размерный regression test использует повторяющиеся типовые строки и не заменяет benchmark уникальных префиксов или BIRD.
|
||||
|
||||
В Go API `bundle.Limits` позволяет явно менять бюджеты: `BuildGzippedTarWithLimits`, `VerifyGzippedTarWithLimits`, `WriteExtractedFilesWithLimits` и `ReadCompressed`. Нулевые поля выбирают defaults; отрицательные значения отклоняются. `nodecli.SyncConfig.BundleLimits` передаёт одинаковые бюджеты в download, verify и extract. Обычные команды CLI используют defaults.
|
||||
|
||||
Verifier проверяет gzip checksum, включая данные после tar EOF, ограничивает внутреннее чтение tar/PAX, отвергает дубликаты путей, ссылки и другие nonregular entries, а также payloads вне подписанного manifest. Пути должны быть каноническими относительными POSIX-путями: absolute, traversal, backslash, Windows drive/stream и device names отклоняются. Извлечение использует directory confinement `os.Root` из Go 1.24 и не следует существующим symlinks; существующий hard link удаляется до exclusive-create нового файла, сохраняя внешний файл.
|
||||
|
||||
Sparse entries отклоняются, а суммарный логический размер файлов дополнительно ограничен 512 MiB: физический размер sparse tar не отражает объём создаваемого содержимого.
|
||||
|
||||
Эти проверки действуют в новых binaries, не изменяют уже установленный agent 2.56.2 и не делают переключение всего дерева конфигурации атомарным: безопасное применение BIRD и восстановление описываются отдельно.
|
||||
@@ -0,0 +1,17 @@
|
||||
# Ресурсы и данные Docker
|
||||
|
||||
Этот файл сопровождает профили в `deploy/compose/`. Текущие значения `deploy.resources.limits` — верхние границы, а не измеренное потребление. Сумма лимитов `microvps-full` с edge и без NATS — 984M. Её нельзя считать подтверждением пригодности для VPS с 1 ГиБ RAM: нужны измерения ОС, Docker, BIRD и пиков refresh/apply. Лимиты BIRD и agent до таких измерений не снижены.
|
||||
|
||||
## Дополнительный NATS
|
||||
|
||||
Во всех основных Compose-вариантах NATS запускается только через `--profile nats` (или `COMPOSE_PROFILES=nats` для standalone stack). В коде `EVOBGP_BROKER_URL` только записывается в лог: внутренняя очередь не использует NATS. Перед отключением NATS на существующем хосте проверьте внешних клиентов порта 4222. Профиль не удаляет данные NATS и не меняет BIRD, WireGuard, agent или их тома.
|
||||
|
||||
## Данные Prometheus
|
||||
|
||||
Prometheus монтирует named volume `prometheus_data` в `/prometheus`. Retention по времени: 7 дней для `microvps-full`/standalone и 15 дней для `reference`; дополнительный предел по размеру задаёт `EVOBGP_PROMETHEUS_RETENTION_SIZE` (по умолчанию `2GB`). Это предел данных TSDB, а не строгий предел диска процесса: WAL и head block требуют отдельного запаса. Для самого маленького хоста сначала измерьте прирост диска и число серий, затем установите размер под доступную ёмкость.
|
||||
|
||||
**Обновление существующего стека:** прежний образ Prometheus мог хранить историю в анонимном Docker volume. Простое пересоздание контейнера с новым named volume покажет пустую историю. До `up` найдите текущий mount через `docker inspect <prometheus-container>`: у записи с `Destination=/prometheus` сохраните `Name`/`Source` и сделайте резервную копию. Для файлового копирования сначала остановите Prometheus либо используйте его поддерживаемый snapshot API. Перенесите содержимое старого тома в новый том с сохранением прав владельца; фактическое имя нового тома содержит префикс Compose project (проверьте через `docker volume ls` и `docker inspect`). Можно осознанно начать новую историю после проверки бэкапа. Проверьте запросы Prometheus до и после recreate. Не запускайте `docker compose down -v`: это удаляет также PostgreSQL, BIRD и другие тома проекта. В production перенос выполняйте отдельно от обновления BIRD/agent.
|
||||
|
||||
## Проверка эффекта
|
||||
|
||||
На одном оборудовании и одинаковом наборе маршрутов сравните `docker compose config`, `docker inspect` (фактические лимиты и mount), `docker stats`, `docker system df -v`, размер логов за сутки, cold/warm build и pull. После изменения проверьте отсутствие OOM/restarts и BGP flaps, совпадение prefix/community, работу apply/rollback и latency API. Локальная среда разработки без Docker не подтверждает экономию RAM или диска.
|
||||
@@ -0,0 +1,20 @@
|
||||
# Общая очередь задач
|
||||
|
||||
В production PostgreSQL является источником состояния jobs. HTTP возвращает `202` после фиксации enqueue; повтор ключа с тем же исходным запросом возвращает сохранённую операцию, включая terminal status. Другой запрос с тем же ключом получает `409`. Ключи старых записей без сохранённого request hash также дают `409`: исходный запрос нельзя надёжно восстановить из изменявшихся результатов. Для нового намеренного запуска нужен новый ключ.
|
||||
|
||||
API/all резервируют локальную worker capacity до claim. Lease содержит owner, срок и fencing token; heartbeat продлевает lease и сохраняет текущую metadata каждые 15 секунд. List/get/cancel/report используют общее хранилище. Потеря heartbeat/terminal write оставляет lease для восстановления; максимум три попытки, затем `failed`. Операторский `POST /v1/jobs/{job_id}/retry` сохраняет ID и исходные параметры, сбрасывает бюджет и проверяет квоты. При graceful shutdown незавершённые jobs освобождаются без расхода попытки; БД закрывается после остановки claim и сохранения результатов workers.
|
||||
|
||||
`EVOBGP_JOB_MAX_PENDING` (по умолчанию 10000) и `EVOBGP_JOB_MAX_TENANT_PENDING` (1000) ограничивают queued+running; `EVOBGP_JOB_MAX_CONCURRENT` ограничивает исполнителей одного процесса. Memory — явный режим разработки, без сохранности после перезапуска. Доставка — at-least-once: обработчики и побочные эффекты должны выдерживать повтор.
|
||||
|
||||
CRUD и автоматический enqueue пока не составляют одну SQL-транзакцию. Если изменение сохранено, а enqueue вернул ошибку, ответ содержит `X-EvoBGP-Job-Status: enqueue-failed`, а аудит — `bgp.job.enqueue_failed`. Повторять создание объекта не нужно; следует явно запустить refresh/reconcile. Падение процесса между CRUD и enqueue требует transactional outbox для полной гарантии восстановления.
|
||||
|
||||
## Обновление control plane
|
||||
|
||||
1. Остановить приём новых мутаций и дождаться завершения/отмены jobs старого control plane.
|
||||
2. Остановить все старые процессы API/all и прочие CP writers. Старый best-effort протокол не поддерживает lease/fencing: смешанная работа старого и нового CP не поддерживается.
|
||||
3. Выполнить migration `000034` и последующие migrations, затем запускать новый control plane. Legacy running rows получают 15 минут grace до reclaim; это средство восстановления записей, а не разрешение оставить старые writers работающими.
|
||||
4. Проверить `/v1/ready`, общую видимость jobs, queue metrics и явный replay запроса.
|
||||
|
||||
Удалённые speakers с агентом 2.56.2 не являются writers очереди control plane; их останавливать или обновлять для этой миграции не требуется. Обратная migration удаляет данные протокола владения; rollback схемы требует полной остановки CP и заранее проверенного backup, а не смешанного rolling rollback.
|
||||
|
||||
PostgreSQL integration tests выполняются только с `EVOBGP_TEST_DATABASE_URL`. Пропуск этих тестов локально не подтверждает поведение SQL; CI требует их успешного выполнения без skips.
|
||||
@@ -0,0 +1,21 @@
|
||||
# Смысл метрик хранилища
|
||||
|
||||
Метрики `/metrics` с префиксом `evobgp_` описывают разные источники данных. Их нельзя складывать в один показатель «маршруты сейчас» или «сеть здорова».
|
||||
|
||||
| Метрика | Источник и смысл |
|
||||
| --- | --- |
|
||||
| `stored_revision_materialized_prefixes_max`, `stored_revision_materialized_prefixes_sum` | Максимум и сумма `materialized_prefix_count` **по всем сохранённым ревизиям**, включая историю. Не текущие маршруты, не опубликованная или применённая конфигурация. |
|
||||
| `inventory_bgp_peers` | Число настроенных BGP peers в хранилище, независимо от `enabled` и фактической сессии. |
|
||||
| `stored_bgp_peer_session_state{state}` | Число peers по сохранённому `meta_json.session_state`. Это последнее записанное значение или intent, а не свежий опрос BIRD. Значения `state` ограничены стандартными состояниями BGP, `unknown` и `other`. |
|
||||
| `store_metrics_collection_success` | `1`, если агрегаты хранилища прочитаны; `0`, если чтение БД завершилось ошибкой. При `0` остальные метрики хранилища в данном scrape отсутствуют. |
|
||||
| `bird_bgp_sessions_established`, `bird_protocols_scrape_success` | Локальный опрос BIRD процесса API; не охватывает все remote speakers. При ошибке опроса число Established не следует трактовать как достоверный нуль. |
|
||||
|
||||
Старые `materialized_prefixes_max/sum`, `bgp_peers_configured_total` и `bgp_peer_sessions{state}` продолжают экспортироваться с прежним смыслом на переходный период. Они устаревают; правила и dashboards нужно перевести на явные имена перед удалением старых рядов. Исторические ревизии могут увеличить оба ряда `*_sum`, поэтому использовать их как KPI текущих маршрутов нельзя.
|
||||
|
||||
Группы desired, published, confirmed applied и observed live, а также общий статус multi-speaker требуют отдельной реализации и политики свежести. Здесь эти метрики пока не объявлены.
|
||||
|
||||
## Circuit breaker внешних запросов
|
||||
|
||||
`upstream_breaker_open{host}` — последнее наблюдение breaker при запросе, не фоновая проверка доступности сервера. Истечение cooldown само по себе не обновляет gauge до следующего обращения; по этой метрике нельзя выводить свежий outage upstream.
|
||||
|
||||
Cache ограничен 1024 hostname на процесс, имена нормализованы в нижний регистр без завершающей точки; порты одного hostname разделяют breaker. Новая identity при заполненном cache отклоняется до соединения. Закрытые записи после 10 минут простоя удаляются вместе с текущим label set при следующем обслуживании cache; активные запросы и ещё открытый cooldown не вытесняются. Это ограничивает текущую память и scrape cardinality, но не удаляет исторические series из TSDB Prometheus.
|
||||
+213
-9
@@ -10,6 +10,20 @@ info:
|
||||
**Соглашения:** префикс `/v1`; идентификаторы - UUID v7 или ULID (строки); время - ISO 8601 UTC.
|
||||
Ошибки - `application/problem+json` ([RFC 9457](https://www.rfc-editor.org/rfc/rfc9457)).
|
||||
Пагинация списков - `cursor` + `limit`; ответ содержит `items`, `next_cursor`, `has_more`.
|
||||
**Общий предел request body:** после аутентификации каждый непубличный `/v1/...`
|
||||
запрос проверяется целиком до выполнения handler: максимум 8 MiB (8388608 bytes),
|
||||
независимо от HTTP-метода, Content-Type и наличия Content-Length. Превышение,
|
||||
включая whitespace после JSON, возвращает `413 application/problem+json`
|
||||
до мутаций. Ошибка чтения тела возвращает `400`, отмена/таймаут чтения — `408`.
|
||||
Пустые node GET/pull и streaming SSE responses не меняются. CSV дополнительно
|
||||
сохраняет ограничения числа строк и полей своей операции.
|
||||
Запросы с телом занимают ограниченный слот API instance до окончания handler:
|
||||
по умолчанию 4 одновременно, `EVOBGP_API_BODY_MAX_CONCURRENT` задаёт 1..64.
|
||||
Свободных слотов нет — немедленный `429` с `Retry-After: 1`, без очереди ожидания.
|
||||
Пустые запросы (`http.NoBody`) слота не занимают.
|
||||
Если CRUD-изменение сохранено, но автоматическая refresh/reconcile задача не поставлена,
|
||||
успешный ответ содержит `X-EvoBGP-Job-Status: enqueue-failed`; ошибка также попадает в аудит.
|
||||
Изменение уже сохранено: следует явно запустить refresh/reconcile, а не повторять создание объекта.
|
||||
|
||||
**Аутентификация (dual):**
|
||||
- **API key** — `Authorization: Bearer <token>` из `EVOBGP_API_KEYS` / таблицы `api_key` (роли `viewer`/`editor`/`operator`/`node`/`firewall`).
|
||||
@@ -226,6 +240,16 @@ components:
|
||||
Основные значения: `module_refresh`, `peer_reconcile`, `deploy_apply`, `revision_rollback`, `bird_reload`.
|
||||
|
||||
responses:
|
||||
ExpensiveOperationLimited:
|
||||
description: Локальная квота процесса API — по умолчанию 4 активных preview/lookup, 2 на tenant; 120 lookup и по 30 CDN/revision preview в минуту на tenant. Квоты умножаются на число реплик. Повторить после Retry-After, без очереди ожидания.
|
||||
headers:
|
||||
Retry-After:
|
||||
description: Минимальная задержка повторной попытки в секундах; не гарантия свободного места.
|
||||
schema: {type: integer, minimum: 1}
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Problem"
|
||||
Unauthorized:
|
||||
description: Нет или невалидный Bearer-токен.
|
||||
content:
|
||||
@@ -263,7 +287,10 @@ components:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Problem"
|
||||
DefaultProblem:
|
||||
description: Ошибка (см. тело Problem).
|
||||
description: >
|
||||
Ошибка (см. тело Problem). Общие для authenticated `/v1` ответы middleware:
|
||||
413 при request body свыше 8 MiB, 400 при ошибке чтения тела, 408 при отмене/таймауте чтения.
|
||||
429 с Retry-After: 1 при занятых слотах обработки request body.
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
@@ -903,6 +930,21 @@ components:
|
||||
connected_speaker_label:
|
||||
type: string
|
||||
description: Человекочитаемая метка ноды из live-опроса.
|
||||
last_known_session_state:
|
||||
type: ["string", "null"]
|
||||
description: Последнее сохранённое состояние; не является текущим наблюдением.
|
||||
session_state:
|
||||
type: ["string", "null"]
|
||||
description: >
|
||||
При live-ответе текущее наблюдение: BGP state, absent после успешного
|
||||
пустого опроса или null, если актуальное состояние неизвестно.
|
||||
live_observation:
|
||||
$ref: "#/components/schemas/LiveObservation"
|
||||
description: >
|
||||
Качество BGP-наблюдений при GET /v1/peers?live=1. Для назначенного
|
||||
пира учитывается только bgp_speaker_id; для общего — все speakers.
|
||||
Смешанный успех и сбой не дают quality=ok или session_state=absent.
|
||||
Timestamps относятся только к ожидаемым speakers.
|
||||
session_on_speakers:
|
||||
type: array
|
||||
description: Состояние протокола пира на каждой опрошенной ноде.
|
||||
@@ -915,6 +957,10 @@ components:
|
||||
type: string
|
||||
state:
|
||||
type: string
|
||||
poll_error:
|
||||
type: string
|
||||
observation:
|
||||
$ref: "#/components/schemas/LiveObservation"
|
||||
established_on_speakers:
|
||||
type: array
|
||||
description: Ноды, где сессия в состоянии Established (один пир может быть на нескольких).
|
||||
@@ -1001,7 +1047,11 @@ components:
|
||||
description: Человекочитаемая метка ноды (agent domain или CP master).
|
||||
agent_ok:
|
||||
type: boolean
|
||||
description: true если agent /v1/agent/health успешен (master — local birdc poll).
|
||||
description: true только после успешного agent /v1/agent/health (master — local birdc poll).
|
||||
agent_observation:
|
||||
$ref: "#/components/schemas/LiveObservation"
|
||||
bgp_observation:
|
||||
$ref: "#/components/schemas/LiveObservation"
|
||||
agent_error:
|
||||
type: string
|
||||
agent_last_sync_at:
|
||||
@@ -1015,15 +1065,49 @@ components:
|
||||
bgp_poll_error:
|
||||
type: string
|
||||
bgp_sessions_total:
|
||||
type: integer
|
||||
type: ["integer", "null"]
|
||||
description: null при отсутствии успешного опроса.
|
||||
bgp_established:
|
||||
type: integer
|
||||
type: ["integer", "null"]
|
||||
description: null при отсутствии успешного опроса.
|
||||
sessions:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/BgpSessionLive"
|
||||
additionalProperties: true
|
||||
|
||||
LiveObservation:
|
||||
type: object
|
||||
description: >
|
||||
Метаданные конкретного опроса. observed_at — время завершения опроса;
|
||||
received_at — время получения результата CP; last_success_at — время
|
||||
последнего успешного опроса. Старые агенты не обязаны передавать эти поля:
|
||||
CP формирует их по собственным часам. При отсутствии опроса timestamps null.
|
||||
required: [observed_at, received_at, last_success_at, source, quality]
|
||||
properties:
|
||||
observed_at:
|
||||
type: ["string", "null"]
|
||||
format: date-time
|
||||
received_at:
|
||||
type: ["string", "null"]
|
||||
format: date-time
|
||||
last_success_at:
|
||||
type: ["string", "null"]
|
||||
format: date-time
|
||||
expires_at:
|
||||
type: ["string", "null"]
|
||||
format: date-time
|
||||
description: Срок актуальности snapshot по часам CP и настроенному интервалу collector; клиент не должен считать cached quality=ok актуальным после этого времени. Для агрегата — самый ранний срок источников.
|
||||
source:
|
||||
type: string
|
||||
enum: [none, cp_birdc, agent_bird_protocols, agent_health, mixed]
|
||||
quality:
|
||||
type: string
|
||||
enum: [ok, stale, unreachable, unknown, disabled]
|
||||
error_code:
|
||||
type: string
|
||||
enum: [not_configured, not_polled, poll_failed, reported_unhealthy, snapshot_expired, configuration_changed, ambiguous_authority]
|
||||
|
||||
BgpSessionLive:
|
||||
type: object
|
||||
properties:
|
||||
@@ -1052,9 +1136,12 @@ components:
|
||||
ok:
|
||||
type: boolean
|
||||
session_count:
|
||||
type: integer
|
||||
type: ["integer", "null"]
|
||||
description: null, если успешного опроса не было.
|
||||
poll_error:
|
||||
type: string
|
||||
observation:
|
||||
$ref: "#/components/schemas/LiveObservation"
|
||||
additionalProperties: true
|
||||
|
||||
PostgresOverview:
|
||||
@@ -2108,6 +2195,8 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/LookupResponse"
|
||||
"429":
|
||||
$ref: "#/components/responses/ExpensiveOperationLimited"
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"401":
|
||||
@@ -2115,6 +2204,29 @@ paths:
|
||||
default:
|
||||
$ref: "#/components/responses/DefaultProblem"
|
||||
|
||||
/v1/telemetry/refresh:
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/TenantId"
|
||||
post:
|
||||
tags: [Speakers]
|
||||
summary: Запросить фоновое обновление телеметрии tenant
|
||||
description: Требует bgp:network:read. Объединяет повторные запросы в пределах 10 секунд; HTTP handler не опрашивает агентов или BIRD. GET speakers/peers/discoveries читает сохранённые snapshots, включая live=1. Запрос во время выполняющегося poll сохраняется для следующего цикла. Не изменяет конфигурацию speaker и не требует нового протокола агента.
|
||||
operationId: requestTelemetryRefresh
|
||||
responses:
|
||||
"202":
|
||||
description: Запрос принят либо объединён с уже принятым.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [requested]
|
||||
properties:
|
||||
requested:
|
||||
type: boolean
|
||||
description: true при постановке нового запроса; false при объединении или отсутствии целей.
|
||||
default:
|
||||
$ref: "#/components/responses/DefaultProblem"
|
||||
|
||||
/v1/router-lists/catalog:
|
||||
get:
|
||||
tags: [Modules]
|
||||
@@ -2789,6 +2901,9 @@ paths:
|
||||
- `ipRange,community`
|
||||
|
||||
В поле `community` можно передавать либо ID community, либо её значение.
|
||||
Предел запроса — 8 MiB (8388608 байт), 10000 строк данных без заголовка и 64 поля в записи.
|
||||
Превышение возвращает 413 до создания записей; большой импорт следует разбить на части.
|
||||
Ошибка в данных или БД после начала создания записей может оставить частичный импорт.
|
||||
operationId: importModuleEntriesCsv
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/IdempotencyKey"
|
||||
@@ -2812,6 +2927,12 @@ paths:
|
||||
minimum: 0
|
||||
module_type:
|
||||
type: string
|
||||
"413":
|
||||
description: Превышен лимит байт, строк или полей CSV; записи не созданы.
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Problem"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
"422":
|
||||
@@ -2898,6 +3019,8 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CdnPreviewResponse"
|
||||
"429":
|
||||
$ref: "#/components/responses/ExpensiveOperationLimited"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
@@ -3146,7 +3269,7 @@ paths:
|
||||
type: string
|
||||
enum: ["1"]
|
||||
description: >
|
||||
Опрос birdc на CP и GET /v1/agent/bird/protocols на репликах; обогащает session_state и connected_speaker_*.
|
||||
Сохранённые результаты фонового опроса birdc на CP и agent protocols на репликах; обогащает session_state и connected_speaker_*. GET не запускает опрос; live=1 сохранён для совместимости.
|
||||
responses:
|
||||
"200":
|
||||
description: Успешно.
|
||||
@@ -3167,7 +3290,7 @@ paths:
|
||||
live_speaker_poll:
|
||||
type: array
|
||||
description: >
|
||||
При live=1 — результат опроса каждой ноды (CP birdc + agent protocols).
|
||||
Сохранённый результат фонового опроса каждой ноды (CP birdc + agent protocols); live=1 не запускает новый опрос.
|
||||
items:
|
||||
$ref: "#/components/schemas/LiveSpeakerPoll"
|
||||
default:
|
||||
@@ -3204,7 +3327,7 @@ paths:
|
||||
summary: Список обнаруженных (неодобренных) пиров
|
||||
description: >
|
||||
Dynamic BGP-сессии (`evobgp_dyn_*`), которых ещё нет в `bgp_peer`.
|
||||
По умолчанию возвращает `status=pending`. При листинге выполняет live-опрос birdc/agent и upsert pending.
|
||||
По умолчанию возвращает `status=pending`. Читает сохранённые discoveries. Фоновый collector выполняет опрос и fenced upsert; GET не опрашивает ноды и не изменяет данные.
|
||||
operationId: listDiscoveredPeers
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/TenantId"
|
||||
@@ -3380,7 +3503,7 @@ paths:
|
||||
type: string
|
||||
enum: ["1"]
|
||||
description: >
|
||||
Live-опрос agent /v1/agent/health и BGP protocols на репликах; CP — local birdc.
|
||||
Сохранённые результаты фоновых опросов agent health/BGP и local birdc. GET не запускает опрос.
|
||||
Обогащает каждый item полем `live`.
|
||||
responses:
|
||||
"200":
|
||||
@@ -3640,6 +3763,8 @@ paths:
|
||||
description: Только чтение, без apply.
|
||||
operationId: getRevisionPreview
|
||||
responses:
|
||||
"429":
|
||||
$ref: "#/components/responses/ExpensiveOperationLimited"
|
||||
"200":
|
||||
description: Текст или структурированное представление - формат задаётся реализацией.
|
||||
content:
|
||||
@@ -3881,6 +4006,58 @@ paths:
|
||||
default:
|
||||
$ref: "#/components/responses/DefaultProblem"
|
||||
|
||||
/v1/jobs/{job_id}/report:
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/TenantId"
|
||||
- $ref: "#/components/parameters/JobId"
|
||||
get:
|
||||
tags: [Jobs]
|
||||
summary: Отчёт задачи и сохранённый прогресс доставки
|
||||
description: Требует bgp:operations:read. Прогресс хранится независимо от meta задачи. Для reconciliation без собственного прогресса возвращается прогресс исходной задачи в том же tenant; после начала восстановления приоритет имеет собственный прогресс. agent_reported не означает проверку состояния BIRD.
|
||||
operationId: getJobReport
|
||||
responses:
|
||||
"200":
|
||||
description: Успешно.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [job_id, kind, status, deployment_progress]
|
||||
properties:
|
||||
job_id: {type: string}
|
||||
kind: {type: string}
|
||||
status: {type: string}
|
||||
meta: {type: [object, "null"], additionalProperties: true}
|
||||
error: {type: [string, "null"]}
|
||||
created_at: {type: string, format: date-time}
|
||||
revision_id: {type: string}
|
||||
log_entries: {type: array, items: {type: object, additionalProperties: true}}
|
||||
log_total: {type: integer}
|
||||
source_deployment_job_id: {type: string}
|
||||
deployment_progress:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
required: [job_id, speaker_id, revision_id, generation, state, confirmation, dispatch_attempts, retry_round]
|
||||
properties:
|
||||
job_id: {type: string}
|
||||
speaker_id: {type: string}
|
||||
revision_id: {type: string}
|
||||
generation: {type: integer, format: int64}
|
||||
state: {type: string}
|
||||
confirmation: {type: string}
|
||||
error: {type: string}
|
||||
dispatch_attempts: {type: integer}
|
||||
retry_round: {type: integer}
|
||||
health_received_at: {type: [string, "null"], format: date-time}
|
||||
health_revision_id: {type: string}
|
||||
health_source_at: {type: string}
|
||||
health_error: {type: string}
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
default:
|
||||
$ref: "#/components/responses/DefaultProblem"
|
||||
|
||||
/v1/jobs/{job_id}/cancel:
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/TenantId"
|
||||
@@ -3904,6 +4081,33 @@ paths:
|
||||
default:
|
||||
$ref: "#/components/responses/DefaultProblem"
|
||||
|
||||
/v1/jobs/{job_id}/retry:
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/TenantId"
|
||||
- $ref: "#/components/parameters/JobId"
|
||||
post:
|
||||
tags: [Jobs]
|
||||
summary: Повторить завершившуюся ошибкой задачу
|
||||
description: Операторский повтор сохраняет ID и исходный запрос, сбрасывая бюджет попыток.
|
||||
operationId: postJobRetry
|
||||
responses:
|
||||
"202":
|
||||
description: Повтор поставлен в очередь.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Job"
|
||||
"409":
|
||||
$ref: "#/components/responses/Conflict"
|
||||
"429":
|
||||
description: Очередь задач заполнена.
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Problem"
|
||||
default:
|
||||
$ref: "#/components/responses/DefaultProblem"
|
||||
|
||||
/v1/speakers/{speaker_id}/revisions/latest:
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/SpeakerId"
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# Решение по доступу worker к внутренним DoH
|
||||
|
||||
## Подтверждённое состояние и разрешение
|
||||
|
||||
В исходном `v2.56.2` (`924f111`) `httpapi.NewCDNHTTPClient` возвращал обычный `httpclient.New`: worker мог обращаться к HTTP/HTTPS DoH-профилям, включая внутренние адреса, и к RIPEstat URL из переменных окружения. Профили управляются API-key editor или JWT с `bgp:directories:write`, а не исключительно администратором.
|
||||
|
||||
Новый `NewCDNHTTPClient` защищает CDN egress. Передача этого client всему pipeline непреднамеренно распространяла CDN-ограничения на DoH/RIPE. Автоматическая проверка сначала отклонила восстановление общего транспорта как изменение egress/SSRF boundary. Пользователь затем явно разрешил его: «Разрешаю восстановить прежний доступ».
|
||||
|
||||
## Выполненное изменение
|
||||
|
||||
`BootstrapWorkers` использует общий reusable HTTP client с прежним timeout 45 секунд для явно настроенных DoH/RIPE endpoints. Для них сохраняются прежние правила private endpoints, proxy и redirects стандартного `net/http` client. Новых правил переноса auth, secret references или запрета HTTP/downgrade redirects здесь нет. Право изменения DoH-профиля сохраняет возможность инициировать запросы во внутреннюю сеть; этот доступ не является admin-only.
|
||||
|
||||
CDN ingest и preview выполняют каждый запрос через `pipeline.UpstreamHTTPDo`: HTTPS, проверенные DNS/IP при dial и redirect, запрет private targets без существующего явного override, без proxy. API preview сохраняет отдельный защищённый client. Node dispatch использует свой client и не меняется.
|
||||
|
||||
`TestBootstrapWorkerPreservesExplicitPrivateDOH` выполняет реальный DOMAINS refresh через bootstrap worker и локальный HTTP DoH; отдельная проверка в нём подтверждает отказ приватному HTTPS CDN при выключенном override. Старый wire-протокол нод 2.56.2 не менялся.
|
||||
|
||||
## Полные ответы и deadlines
|
||||
|
||||
RIPE announced-prefixes: 32 MiB; RIPE as-overview: 4 MiB. DoH dns-message: 2 MiB; DoH JSON: 1 MiB. Проверяется полный ответ с чтением одного дополнительного байта, включая декодированный transport gzip. Валидный префикс JSON/DNS с хвостом за пределом больше не принимается как усечённый успех. JSON разбирается целиком; дополнительный JSON-объект отклоняется. Для non-200 RIPE читается максимум 4 KiB диагностики, DoH — 1 KiB; retry-drain 5xx отдельно ограничен общим HTTP helper.
|
||||
|
||||
В отсутствие deadline RIPE получает 45 секунд, низкоуровневый DoH — 10 секунд. Уже заданный deadline, включая timeout выбранного DoH-профиля, сохраняется; он может быть короче. Ошибка/oversize DoH не обязательно завершает весь refresh: сохраняется существующая политика fallback. Один профиль/primary path может перейти к system DNS после ошибки или пустого результата; multi-profile union возвращает успешные результаты других профилей, failover перебирает профили и в отдельных случаях пустого результата также использует system DNS.
|
||||
|
||||
## Переиспользование transport
|
||||
|
||||
`pipeline.NewPipelineHTTPClient` владеет двумя отдельными reusable pools: обычным для настроенных DoH/RIPE и защищённым для CDN. Только CDN entry point выбирает guarded pool; выбор не зависит от эвристики URL. Новый глобальный cache клиентов не добавлен. Тест трёх последовательных TLS CDN-запросов подтверждает одно соединение, а отдельный тест проверяет разрешённый private profile и отказ CDN через тот же client.
|
||||
+1
-1
@@ -52,7 +52,7 @@ CLI `evobgp-node` поддерживает `pull-bundle`, `verify-bundle`, `appl
|
||||
|
||||
## Веб-интерфейс
|
||||
|
||||
Каталог `web/` — SvelteKit-приложение для операторов (статическая сборка в Docker-образе эталонного профиля). Для разработки UI обычно используется dev-сервер на порту Vite/SvelteKit с проксированием или прямым вызовом API; на стороне API задаётся CORS (`EVOBGP_CORS_ORIGINS`).
|
||||
Каталог `apps/web/` — React-приложение операторов: TanStack Router/Query, shadcn/ui и подключённый ReUI PRO. В Docker используется статическая сборка с nginx; локально — Vite с проксированием или прямым вызовом API и CORS (`EVOBGP_CORS_ORIGINS`). Контракт оформления — [ReUI Frame](ui-design-contract.md).
|
||||
|
||||
## Наблюдаемость
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
## Рекомендуется
|
||||
|
||||
- `EVOBGP_JOB_MAX_CONCURRENT=16`, `EVOBGP_DB_MAX_CONNS=25`, `EVOBGP_COLLECT_CONCURRENCY=16` при росте tenants.
|
||||
- `EVOBGP_JOB_MAX_CONCURRENT=16`, `EVOBGP_DB_MAX_CONNS=25`, `EVOBGP_COLLECT_CONCURRENCY=16` при росте tenants. Если `EVOBGP_DB_MAX_CONNS` не задан, пул по умолчанию 8 соединений и `MaxConnIdleTime` 5 минут (не дефолт pgx в 4). Явное значение переменной не переопределяется. Сумма пулов процессов reference-профиля должна оставаться ниже `max_connections`.
|
||||
- `EVOBGP_NODE_DISPATCH_INSECURE_TLS=0` — только валидный TLS к agent.
|
||||
- Ограничить `/metrics`: `EVOBGP_METRICS_TOKEN` (Bearer) и/или сетевая политика / reverse proxy.
|
||||
- `EVOBGP_AUTH_RATE_LIMIT` (default 60/min per IP) на `GET /v1/auth/config`.
|
||||
|
||||
+33
-8
@@ -2,6 +2,16 @@
|
||||
|
||||
Примеры команд для **PowerShell**. Репозиторий: корень `EvoBGP`, Docker Compose лежит в `deploy\compose`.
|
||||
|
||||
Фоновый collector BGP/health опрашивает speaker с интервалом 20 секунд
|
||||
(`EVOBGP_TELEMETRY_INTERVAL_SEC`, допустимо 15–300) и ограничивает параллелизм
|
||||
четырьмя workers (`EVOBGP_TELEMETRY_WORKERS`, допустимо 1–32). Данные старше
|
||||
трёх интервалов считаются устаревшими. Для локального BIRD задайте
|
||||
`EVOBGP_TELEMETRY_LOCAL_ID` как стабильный уникальный ID именно BIRD-узла,
|
||||
если несколько control-plane инстансов используют одну PostgreSQL БД;
|
||||
одинаковый ID допустим только для инстансов, читающих один и тот же BIRD.
|
||||
Без ID используется hostname контейнера и путь сокета, поэтому после пересоздания
|
||||
контейнера старый локальный snapshot может остаться до очистки.
|
||||
|
||||
## Требования
|
||||
|
||||
- **Docker** с поддержкой Compose v2 — для готового стека.
|
||||
@@ -103,7 +113,7 @@ docker compose --profile microvps down -v
|
||||
|
||||
## Вариант 1b: microVPS ~1 ГиБ RAM — полный набор + Web UI
|
||||
|
||||
Один монолит **`evobgp-all`** (те же in-process воркеры, что и в варианте 1), плюс **статическая панель** за nginx (`evobgp-web-microvps`), **NATS JetStream** (как в reference, для `EVOBGP_BROKER_URL` и будущей интеграции) и **Prometheus** со скрейпом метрик с `evobgp-all`. Отдельные контейнеры `evobgp-scheduler` / `evobgp-ingest` / … не нужны — это не дублирование логики, а тот же код в одном процессе.
|
||||
Один монолит **`evobgp-all`** (те же in-process воркеры, что и в варианте 1), плюс **статическая панель** за nginx (`evobgp-web-microvps`) и **Prometheus** со скрейпом метрик с `evobgp-all`. Отдельные контейнеры `evobgp-scheduler` / `evobgp-ingest` / … не нужны — это тот же код в одном процессе. NATS JetStream запускается только с дополнительным профилем `nats`.
|
||||
|
||||
**Рекомендация:** на хосте с ровно **1 ГиБ** включите **swap** (1–2 ГиБ), иначе при пиках возможен OOM. На **2+ ГиБ** можно поднять тот же профиль **без** второго файла — останутся более мягкие лимиты из основного `docker-compose.yaml`.
|
||||
|
||||
@@ -126,19 +136,19 @@ Copy-Item .env.web-sec.example .env.web-sec -Force
|
||||
4. Запускайте compose с двумя env-файлами:
|
||||
|
||||
```powershell
|
||||
# С ужатыми лимитами и EVOBGP_BROKER_URL=nats://… (ориентир под ~1 ГиБ RAM на хосте)
|
||||
# С текущими лимитами (пригодность для хоста ~1 ГиБ требует замера под нагрузкой)
|
||||
docker compose --env-file .env --env-file .env.web-sec -f docker-compose.yaml -f docker-compose.microvps-full.yaml --profile microvps-full pull
|
||||
docker compose --env-file .env --env-file .env.web-sec -f docker-compose.yaml -f docker-compose.microvps-full.yaml --profile microvps-full up -d
|
||||
```
|
||||
|
||||
Только профиль `microvps-full` и **без** файла `docker-compose.microvps-full.yaml` (лимиты как в базовом compose, без принудительной подстановки брокера в `evobgp-all`):
|
||||
Только профиль `microvps-full` и **без** файла `docker-compose.microvps-full.yaml` (лимиты как в базовом compose):
|
||||
|
||||
```powershell
|
||||
docker compose --env-file .env --env-file .env.web-sec --profile microvps-full pull
|
||||
docker compose --env-file .env --env-file .env.web-sec --profile microvps-full up -d
|
||||
```
|
||||
|
||||
При необходимости задайте брокер вручную для `evobgp-all` (override или `.env` рядом с compose): `EVOBGP_BROKER_URL=nats://nats:4222`.
|
||||
Если NATS нужен внешним клиентам, добавьте `--profile nats` к командам `pull` и `up`. Проверьте таких клиентов до изменения рабочего стека. `EVOBGP_BROKER_URL` в текущем приложении только записывается в лог и не включает брокерную очередь. Для standalone `stack.microvps-full.yaml` и production example сервис NATS также включается профилем `nats`. Перенос существующей истории Prometheus при обновлении описан в [docker-resources.md](docker-resources.md).
|
||||
|
||||
| Порт | Назначение |
|
||||
|------|------------|
|
||||
@@ -146,7 +156,7 @@ docker compose --env-file .env --env-file .env.web-sec --profile microvps-full u
|
||||
| **443** | HTTPS Web UI (Traefik + Let's Encrypt) |
|
||||
| **8080** | Прямой HTTP API (`evobgp-all`) |
|
||||
| **9090** | Prometheus (`prometheus-microvps`) |
|
||||
| **4222** | NATS |
|
||||
| **4222** | NATS (только с профилем `nats`) |
|
||||
| **179** | BGP (BIRD2), как в варианте 1 |
|
||||
|
||||
Проверка UI:
|
||||
@@ -188,7 +198,7 @@ docker compose --env-file .env --env-file .env.web-sec -f docker-compose.yaml -f
|
||||
|
||||
## Вариант 2: Docker, профиль reference
|
||||
|
||||
Эталонное разбиение: отдельные контейнеры `evobgp-api`, `evobgp-scheduler`, `evobgp-ingest`, `evobgp-render`, `evobgp-deploy`, сервис **NATS JetStream** (в compose; привязка очереди задач к брокеру — следующая итерация), веб UI за nginx, опционально Prometheus.
|
||||
Эталонное разбиение: отдельные контейнеры `evobgp-api`, `evobgp-scheduler`, `evobgp-ingest`, `evobgp-render`, `evobgp-deploy`, веб UI за nginx и Prometheus. **NATS JetStream** доступен по дополнительному профилю `nats`; очередь задач пока не использует брокер.
|
||||
|
||||
```powershell
|
||||
cd deploy\compose
|
||||
@@ -204,11 +214,26 @@ docker compose --profile reference up -d
|
||||
|------|------------|
|
||||
| 8080 | HTTP API (`evobgp-api`) |
|
||||
| 3000 | Веб UI (`evobgp-web` → nginx, прокси на API) |
|
||||
| 4222 | NATS |
|
||||
| 4222 | NATS (только с профилем `nats`) |
|
||||
| 9090 | Prometheus (в compose) |
|
||||
| 179 | BGP (BIRD2) |
|
||||
|
||||
**Воркеры reference:** `evobgp-scheduler` ходит в API по HTTP (`EVOBGP_CONTROL_PLANE_URL`, `EVOBGP_SCHEDULER_BEARER`); в [docker-compose.yaml](../deploy/compose/docker-compose.yaml) для локального запуска включены `EVOBGP_DEV_INSECURE=1` на API и токен `dev` у планировщика. `evobgp-ingest` обновляет ETag CDN-источников; `evobgp-render` по умолчанию не трогает `published_revision` (включите `EVOBGP_RENDER_AUTOPUBLISH=1` осознанно); `evobgp-deploy` пишет в лог расхождение applied vs published. Очередь `jobs` остаётся in-process у **evobgp-api**; общий брокер — в планах.
|
||||
**Воркеры reference:** `evobgp-scheduler` использует `EVOBGP_SCHEDULER_MODE=db` и общую PostgreSQL durable queue. Он доверенный DB-worker с доступом к tenants; API workers выполняют сохранённые задания. HTTP-вариант включается явно: `EVOBGP_SCHEDULER_MODE=http`, `EVOBGP_CONTROL_PLANE_URL`, `EVOBGP_SCHEDULER_BEARER` и `EVOBGP_SCHEDULER_TENANT_ID`. Токен должен принадлежать именно указанному tenant и иметь право `bgp:modules:write`; HTTP scheduler не перечисляет остальные tenants и не подменяет scope через заголовки. При обновлении старой HTTP-конфигурации нужно указать tenant или перейти на `db`; без tenant HTTP-режим завершится с ошибкой. `local` предназначен только для разработки без PostgreSQL, не для split deploy. `evobgp-ingest` обновляет ETag CDN-источников; `evobgp-render` по умолчанию не трогает `published_revision` (`EVOBGP_RENDER_AUTOPUBLISH=1` включается осознанно); `evobgp-deploy` пишет в лог расхождение applied vs published.
|
||||
|
||||
Reference Compose фиксирует DB-режим. Простое добавление HTTP-переменных в `.env` его **не переключает**. Для HTTP-варианта используйте отдельный Compose override с явным окружением сервиса:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
evobgp-scheduler:
|
||||
environment:
|
||||
EVOBGP_SCHEDULER_MODE: http
|
||||
EVOBGP_CONTROL_PLANE_URL: ${EVOBGP_CONTROL_PLANE_URL:?required}
|
||||
EVOBGP_SCHEDULER_BEARER: ${EVOBGP_SCHEDULER_BEARER:?required}
|
||||
EVOBGP_SCHEDULER_TENANT_ID: ${EVOBGP_SCHEDULER_TENANT_ID:?required}
|
||||
```
|
||||
|
||||
Подключайте override через дополнительный `-f` при запуске Compose. DB-режим предназначен для всех tenants, которым доверяет оператор общей БД; HTTP-режим работает только с указанным tenant и его токеном.
|
||||
|
||||
|
||||
В **evobgp-all** (microvps) те же пакеты крутятся в одном процессе и используют общий `jobs.Registry` без HTTP.
|
||||
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
package agentwatch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
maxConfigFiles = 512
|
||||
maxConfigBytes = 64 << 20
|
||||
maxFileBytes = 16 << 20
|
||||
maxIncludeDepth = 16
|
||||
maxDirectoryEntries = 4096
|
||||
)
|
||||
|
||||
var errMainNotReady = errors.New("main bird.conf is not ready")
|
||||
|
||||
// Only standalone literal include statements are hashable. Other valid BIRD
|
||||
// grammar falls back to the legacy configure-on-every-tick path.
|
||||
var includeLine = regexp.MustCompile(`(?m)^[\t ]*include[\t ]+"([^"\r\n]+)"[\t ]*;[\t ]*(?:#[^\r\n]*)?\r?$`)
|
||||
|
||||
type configScanner struct {
|
||||
ctx context.Context
|
||||
root string
|
||||
h hash.Hash
|
||||
seen map[string]bool
|
||||
files int
|
||||
bytes int64
|
||||
}
|
||||
|
||||
func hashConfigTree(ctx context.Context, activeDir string) ([sha256.Size]byte, error) {
|
||||
var zero [sha256.Size]byte
|
||||
root, err := filepath.Abs(activeDir)
|
||||
if err != nil {
|
||||
return zero, fmt.Errorf("agentwatch: active directory: %w", err)
|
||||
}
|
||||
root, err = filepath.EvalSymlinks(root)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return zero, fmt.Errorf("agentwatch: %w: %v", errMainNotReady, err)
|
||||
}
|
||||
return zero, fmt.Errorf("agentwatch: resolve active directory: %w", err)
|
||||
}
|
||||
s := &configScanner{ctx: ctx, root: filepath.Clean(root), h: sha256.New(), seen: make(map[string]bool)}
|
||||
if err := s.visit(filepath.Join(s.root, "bird.conf"), 0); err != nil {
|
||||
return zero, err
|
||||
}
|
||||
var digest [sha256.Size]byte
|
||||
copy(digest[:], s.h.Sum(nil))
|
||||
return digest, nil
|
||||
}
|
||||
|
||||
func (s *configScanner) visit(path string, depth int) error {
|
||||
if err := s.ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if depth > maxIncludeDepth {
|
||||
return fmt.Errorf("agentwatch: include depth exceeds %d", maxIncludeDepth)
|
||||
}
|
||||
path = filepath.Clean(path)
|
||||
rel, err := filepath.Rel(s.root, path)
|
||||
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
|
||||
return fmt.Errorf("agentwatch: include %q escapes active directory", path)
|
||||
}
|
||||
if s.seen[path] {
|
||||
return nil
|
||||
}
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
if depth == 0 && errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("agentwatch: %w: %v", errMainNotReady, err)
|
||||
}
|
||||
return fmt.Errorf("agentwatch: inspect %q: %w", path, err)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return fmt.Errorf("agentwatch: include %q is not a regular file", path)
|
||||
}
|
||||
resolved, err := filepath.EvalSymlinks(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("agentwatch: resolve %q: %w", path, err)
|
||||
}
|
||||
if _, err := s.withinRoot(resolved); err != nil {
|
||||
return err
|
||||
}
|
||||
if info.Size() > maxFileBytes {
|
||||
return fmt.Errorf("agentwatch: include %q exceeds %d bytes", path, maxFileBytes)
|
||||
}
|
||||
if s.files >= maxConfigFiles || s.bytes+info.Size() > maxConfigBytes {
|
||||
return fmt.Errorf("agentwatch: configuration tree exceeds %d files or %d bytes", maxConfigFiles, maxConfigBytes)
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("agentwatch: open %q: %w", path, err)
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
limit := int64(maxFileBytes)
|
||||
if remaining := int64(maxConfigBytes) - s.bytes; remaining < limit {
|
||||
limit = remaining
|
||||
}
|
||||
content, err := readBounded(f, limit)
|
||||
if err != nil {
|
||||
return fmt.Errorf("agentwatch: read %q: %w", path, err)
|
||||
}
|
||||
s.seen[path] = true
|
||||
s.files++
|
||||
s.bytes += int64(len(content))
|
||||
writeHash(s.h, filepath.ToSlash(rel), content)
|
||||
patterns, err := includePatterns(string(content))
|
||||
if err != nil {
|
||||
return fmt.Errorf("agentwatch: %s: %w", rel, err)
|
||||
}
|
||||
for _, pattern := range patterns {
|
||||
if strings.ContainsAny(pattern, `[\`) {
|
||||
return fmt.Errorf("agentwatch: unsupported escaped include %q", pattern)
|
||||
}
|
||||
candidate := pattern
|
||||
if !filepath.IsAbs(candidate) {
|
||||
candidate = filepath.Join(filepath.Dir(path), filepath.FromSlash(candidate))
|
||||
}
|
||||
candidate = filepath.Clean(candidate)
|
||||
// The pattern itself changes the digest even when its glob currently matches nothing.
|
||||
writeHash(s.h, "include", []byte(filepath.ToSlash(candidate)))
|
||||
if _, err := s.withinRoot(candidate); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.ContainsAny(candidate, "*?") {
|
||||
if strings.ContainsAny(filepath.Dir(candidate), "*?") {
|
||||
return fmt.Errorf("agentwatch: unsupported include directory glob %q", pattern)
|
||||
}
|
||||
resolvedDir, err := filepath.EvalSymlinks(filepath.Dir(candidate))
|
||||
if err != nil {
|
||||
return fmt.Errorf("agentwatch: resolve include directory %q: %w", pattern, err)
|
||||
}
|
||||
if _, err := s.withinRoot(resolvedDir); err != nil {
|
||||
return err
|
||||
}
|
||||
matches, err := boundedGlob(candidate)
|
||||
if err != nil {
|
||||
return fmt.Errorf("agentwatch: include glob %q: %w", pattern, err)
|
||||
}
|
||||
for _, match := range matches {
|
||||
if err := s.visit(match, depth+1); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := s.visit(candidate, depth+1); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readBounded(r io.Reader, limit int64) ([]byte, error) {
|
||||
content, err := io.ReadAll(io.LimitReader(r, limit+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if int64(len(content)) > limit {
|
||||
return nil, fmt.Errorf("configuration tree changed or exceeded size limit")
|
||||
}
|
||||
return content, nil
|
||||
}
|
||||
|
||||
// boundedGlob only handles * and ? in a filename. Broader BIRD glob grammar
|
||||
// falls back to every-tick configure instead of risking a false unchanged hash.
|
||||
func boundedGlob(pattern string) ([]string, error) {
|
||||
dir, base := filepath.Dir(pattern), filepath.Base(pattern)
|
||||
f, err := os.Open(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
names, err := f.Readdirnames(maxDirectoryEntries + 1)
|
||||
if err != nil && !errors.Is(err, io.EOF) {
|
||||
return nil, err
|
||||
}
|
||||
if len(names) > maxDirectoryEntries {
|
||||
return nil, fmt.Errorf("directory exceeds %d entries", maxDirectoryEntries)
|
||||
}
|
||||
matches := make([]string, 0)
|
||||
for _, name := range names {
|
||||
match, err := filepath.Match(base, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if match {
|
||||
matches = append(matches, filepath.Join(dir, name))
|
||||
if len(matches) > maxConfigFiles {
|
||||
return nil, fmt.Errorf("glob exceeds %d matches", maxConfigFiles)
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(matches)
|
||||
return matches, nil
|
||||
}
|
||||
|
||||
func (s *configScanner) withinRoot(path string) (string, error) {
|
||||
rel, err := filepath.Rel(s.root, path)
|
||||
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
|
||||
return "", fmt.Errorf("agentwatch: include %q escapes active directory", path)
|
||||
}
|
||||
return rel, nil
|
||||
}
|
||||
|
||||
func writeHash(h hash.Hash, name string, content []byte) {
|
||||
var size [8]byte
|
||||
binary.LittleEndian.PutUint64(size[:], uint64(len(name)))
|
||||
_, _ = h.Write(size[:])
|
||||
_, _ = h.Write([]byte(name))
|
||||
binary.LittleEndian.PutUint64(size[:], uint64(len(content)))
|
||||
_, _ = h.Write(size[:])
|
||||
_, _ = h.Write(content)
|
||||
}
|
||||
|
||||
func includePatterns(content string) ([]string, error) {
|
||||
tokens, unsupportedQuotedText := includeTokenOffsets(content)
|
||||
if unsupportedQuotedText {
|
||||
return nil, fmt.Errorf("unsupported quoted text or escape")
|
||||
}
|
||||
tokenSet := make(map[int]bool, len(tokens))
|
||||
for _, token := range tokens {
|
||||
tokenSet[token] = true
|
||||
}
|
||||
var patterns []string
|
||||
for _, match := range includeLine.FindAllStringSubmatchIndex(content, -1) {
|
||||
keyword := match[0] + strings.Index(content[match[0]:match[1]], "include")
|
||||
if !tokenSet[keyword] {
|
||||
continue
|
||||
} // text inside a block comment
|
||||
delete(tokenSet, keyword)
|
||||
patterns = append(patterns, content[match[2]:match[3]])
|
||||
}
|
||||
if len(tokenSet) != 0 {
|
||||
return nil, fmt.Errorf("unsupported include syntax")
|
||||
}
|
||||
return patterns, nil
|
||||
}
|
||||
|
||||
// Recognize include keywords outside BIRD comments and quoted strings.
|
||||
func includeTokenOffsets(config string) ([]int, bool) {
|
||||
var offsets []int
|
||||
quote := byte(0)
|
||||
lineComment, blockComment := false, false
|
||||
for i := 0; i < len(config); {
|
||||
c := config[i]
|
||||
if lineComment {
|
||||
if c == '\n' {
|
||||
lineComment = false
|
||||
}
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if blockComment {
|
||||
if c == '*' && i+1 < len(config) && config[i+1] == '/' {
|
||||
blockComment = false
|
||||
i += 2
|
||||
} else {
|
||||
i++
|
||||
}
|
||||
continue
|
||||
}
|
||||
if quote != 0 {
|
||||
if c == '\\' {
|
||||
return offsets, true
|
||||
}
|
||||
if c == quote {
|
||||
quote = 0
|
||||
}
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if c == '#' || (c == '/' && i+1 < len(config) && config[i+1] == '/') {
|
||||
lineComment = true
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if c == '/' && i+1 < len(config) && config[i+1] == '*' {
|
||||
blockComment = true
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
if c == '"' || c == '\'' {
|
||||
quote = c
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '_' {
|
||||
start := i
|
||||
for i < len(config) && ((config[i] >= 'a' && config[i] <= 'z') || (config[i] >= 'A' && config[i] <= 'Z') || (config[i] >= '0' && config[i] <= '9') || config[i] == '_') {
|
||||
i++
|
||||
}
|
||||
if config[start:i] == "include" {
|
||||
offsets = append(offsets, start)
|
||||
}
|
||||
continue
|
||||
}
|
||||
i++
|
||||
}
|
||||
return offsets, quote != 0 || blockComment
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// Package agentwatch reloads BIRD when its active configuration tree changes.
|
||||
package agentwatch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Control is the subset of BIRD operations required by the watch loop.
|
||||
type Control interface {
|
||||
ParseCheck(context.Context, string) error
|
||||
Configure(context.Context) error
|
||||
}
|
||||
|
||||
// Config sets bounded polling and reconciliation behavior.
|
||||
type Config struct {
|
||||
ActiveDir string
|
||||
Every time.Duration
|
||||
ReconcileEvery time.Duration
|
||||
CommandTimeout time.Duration
|
||||
Logf func(string, ...any)
|
||||
}
|
||||
|
||||
type watcher struct {
|
||||
ctl Control
|
||||
cfg Config
|
||||
hash [sha256.Size]byte
|
||||
haveHash bool
|
||||
lastConfigured time.Time
|
||||
}
|
||||
|
||||
// Run starts with one check, then polls until ctx is cancelled. A successful
|
||||
// birdc exit advances the remembered hash; it is not proof of BIRD ACK.
|
||||
func Run(ctx context.Context, ctl Control, cfg Config) error {
|
||||
if ctl == nil || cfg.Every <= 0 || cfg.ReconcileEvery <= 0 || cfg.CommandTimeout <= 0 {
|
||||
return fmt.Errorf("agentwatch: control and positive intervals/timeouts are required")
|
||||
}
|
||||
if cfg.ActiveDir == "" {
|
||||
cfg.ActiveDir = "/etc/bird"
|
||||
}
|
||||
if cfg.Logf == nil {
|
||||
cfg.Logf = log.Printf
|
||||
}
|
||||
w := &watcher{ctl: ctl, cfg: cfg}
|
||||
if err := w.step(ctx, time.Now()); err != nil && ctx.Err() == nil {
|
||||
cfg.Logf("evobgp-agent watch: %v", err)
|
||||
}
|
||||
ticker := time.NewTicker(cfg.Every)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case now := <-ticker.C:
|
||||
if err := w.step(ctx, now); err != nil && ctx.Err() == nil {
|
||||
cfg.Logf("evobgp-agent watch: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *watcher) step(ctx context.Context, now time.Time) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
digest, scanErr := hashConfigTree(ctx, w.cfg.ActiveDir)
|
||||
if scanErr != nil {
|
||||
if errors.Is(scanErr, errMainNotReady) {
|
||||
return fmt.Errorf("configuration not ready: %w", scanErr)
|
||||
}
|
||||
// Unsupported/unhashable trees retain the old every-tick configure cadence.
|
||||
w.cfg.Logf("evobgp-agent watch: cannot hash complete config tree (%v); using every-tick parse/configure", scanErr)
|
||||
} else if w.haveHash && digest == w.hash && now.Sub(w.lastConfigured) < w.cfg.ReconcileEvery {
|
||||
return nil
|
||||
}
|
||||
main := filepath.Join(w.cfg.ActiveDir, "bird.conf")
|
||||
if err := w.command(ctx, func(cctx context.Context) error { return w.ctl.ParseCheck(cctx, main) }); err != nil {
|
||||
return fmt.Errorf("parse check: %w", err)
|
||||
}
|
||||
if scanErr == nil {
|
||||
stable, err := hashConfigTree(ctx, w.cfg.ActiveDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("configuration changed during parse check: %w", err)
|
||||
}
|
||||
if stable != digest {
|
||||
return fmt.Errorf("configuration changed during parse check; retrying")
|
||||
}
|
||||
}
|
||||
if err := w.command(ctx, w.ctl.Configure); err != nil {
|
||||
return fmt.Errorf("configure: %w", err)
|
||||
}
|
||||
w.haveHash = scanErr == nil
|
||||
if w.haveHash {
|
||||
w.hash = digest
|
||||
}
|
||||
w.lastConfigured = now
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *watcher) command(parent context.Context, run func(context.Context) error) error {
|
||||
ctx, cancel := context.WithTimeout(parent, w.cfg.CommandTimeout)
|
||||
defer cancel()
|
||||
return run(ctx)
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
package agentwatch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type fakeControl struct {
|
||||
parsed int
|
||||
configured int
|
||||
failConfigure int
|
||||
onParse func()
|
||||
}
|
||||
|
||||
func (f *fakeControl) ParseCheck(ctx context.Context, _ string) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
f.parsed++
|
||||
if f.onParse != nil {
|
||||
f.onParse()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeControl) Configure(ctx context.Context) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
f.configured++
|
||||
if f.failConfigure > 0 {
|
||||
f.failConfigure--
|
||||
return errors.New("birdc unavailable")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func testConfig(t *testing.T) (string, string) {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
if err := os.Mkdir(filepath.Join(root, "bird.d"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
main := filepath.Join(root, "bird.conf")
|
||||
if err := os.WriteFile(main, []byte("router id 192.0.2.1;\ninclude \"bird.d/*.conf\";\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fragment := filepath.Join(root, "bird.d", "peer.conf")
|
||||
if err := os.WriteFile(fragment, []byte("# peer v1\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return root, fragment
|
||||
}
|
||||
|
||||
func testWatcher(root string, ctl Control) *watcher {
|
||||
return &watcher{ctl: ctl, cfg: Config{ActiveDir: root, Every: 30 * time.Second, ReconcileEvery: 5 * time.Minute, CommandTimeout: time.Second, Logf: func(string, ...any) {}}}
|
||||
}
|
||||
|
||||
func TestHashConfigTreeTracksMainIncludesAndGlobMembership(t *testing.T) {
|
||||
root, fragment := testConfig(t)
|
||||
first, err := hashConfigTree(context.Background(), root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(fragment, []byte("# peer v2\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := hashConfigTree(context.Background(), root)
|
||||
if err != nil || second == first {
|
||||
t.Fatalf("included edit not detected: digest=%x err=%v", second, err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "bird.d", "manual.conf"), []byte("# manual\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
third, err := hashConfigTree(context.Background(), root)
|
||||
if err != nil || third == second {
|
||||
t.Fatalf("new glob member not detected: digest=%x err=%v", third, err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "bird.conf"), []byte("router id 192.0.2.2;\ninclude \"bird.d/*.conf\";\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fourth, err := hashConfigTree(context.Background(), root)
|
||||
if err != nil || fourth == third {
|
||||
t.Fatalf("main edit not detected: digest=%x err=%v", fourth, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatchSkipsUnchangedRetriesErrorsAndForcesReconciliation(t *testing.T) {
|
||||
root, fragment := testConfig(t)
|
||||
ctl := &fakeControl{}
|
||||
w := testWatcher(root, ctl)
|
||||
now := time.Now()
|
||||
if err := w.step(context.Background(), now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := w.step(context.Background(), now.Add(30*time.Second)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ctl.configured != 1 || ctl.parsed != 1 {
|
||||
t.Fatalf("unchanged tree configured again: %+v", ctl)
|
||||
}
|
||||
if err := os.WriteFile(fragment, []byte("# changed\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctl.failConfigure = 1
|
||||
if err := w.step(context.Background(), now.Add(time.Minute)); err == nil {
|
||||
t.Fatal("expected configure failure")
|
||||
}
|
||||
if !w.haveHash || ctl.configured != 2 {
|
||||
t.Fatalf("failure changed remembered state: %+v", w)
|
||||
}
|
||||
if err := w.step(context.Background(), now.Add(90*time.Second)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ctl.configured != 3 {
|
||||
t.Fatalf("failed change was not retried: %+v", ctl)
|
||||
}
|
||||
if err := w.step(context.Background(), now.Add(7*time.Minute)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ctl.configured != 4 {
|
||||
t.Fatalf("forced reconciliation missing: %+v", ctl)
|
||||
}
|
||||
// A fresh watcher must reconcile once even when another process already loaded the same bytes.
|
||||
restarted := testWatcher(root, ctl)
|
||||
if err := restarted.step(context.Background(), now.Add(8*time.Minute)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ctl.configured != 5 {
|
||||
t.Fatalf("restart did not reconcile: %+v", ctl)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatchRejectsChangingTreeBeforeConfigure(t *testing.T) {
|
||||
root, fragment := testConfig(t)
|
||||
ctl := &fakeControl{onParse: func() { _ = os.WriteFile(fragment, []byte("# raced\n"), 0o644) }}
|
||||
w := testWatcher(root, ctl)
|
||||
if err := w.step(context.Background(), time.Now()); err == nil || !strings.Contains(err.Error(), "changed during parse check") {
|
||||
t.Fatalf("want race retry, got %v", err)
|
||||
}
|
||||
if ctl.configured != 0 {
|
||||
t.Fatal("configured a tree that changed after parse check")
|
||||
}
|
||||
ctl.onParse = nil
|
||||
if err := w.step(context.Background(), time.Now()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ctl.configured != 1 {
|
||||
t.Fatal("stable tree was not configured")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnsupportedIncludeUsesLegacyEveryTickCadence(t *testing.T) {
|
||||
root, _ := testConfig(t)
|
||||
if err := os.WriteFile(filepath.Join(root, "bird.conf"), []byte("include variable;\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctl := &fakeControl{}
|
||||
w := testWatcher(root, ctl)
|
||||
now := time.Now()
|
||||
for i := 0; i < 3; i++ {
|
||||
if err := w.step(context.Background(), now.Add(time.Duration(i)*30*time.Second)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if ctl.parsed != 3 || ctl.configured != 3 || w.haveHash {
|
||||
t.Fatalf("unsupported tree was skipped: %+v", ctl)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingIncludedFileFallsBackButMissingMainWaits(t *testing.T) {
|
||||
root, fragment := testConfig(t)
|
||||
if err := os.WriteFile(filepath.Join(root, "bird.conf"), []byte("include \"bird.d/peer.conf\";\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Remove(fragment); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctl := &fakeControl{}
|
||||
w := testWatcher(root, ctl)
|
||||
now := time.Now()
|
||||
for i := 0; i < 2; i++ {
|
||||
if err := w.step(context.Background(), now.Add(time.Duration(i)*30*time.Second)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if ctl.configured != 2 {
|
||||
t.Fatalf("missing include skipped legacy cadence: %+v", ctl)
|
||||
}
|
||||
if err := os.Remove(filepath.Join(root, "bird.conf")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := w.step(context.Background(), now.Add(time.Minute)); !errors.Is(err, errMainNotReady) {
|
||||
t.Fatalf("missing main should wait, got %v", err)
|
||||
}
|
||||
if ctl.configured != 2 {
|
||||
t.Fatal("configured without main bird.conf")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashConfigTreeBoundsAndEscapes(t *testing.T) {
|
||||
root, fragment := testConfig(t)
|
||||
if err := os.WriteFile(fragment, make([]byte, maxFileBytes+1), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := hashConfigTree(context.Background(), root); err == nil || !strings.Contains(err.Error(), "exceeds") {
|
||||
t.Fatalf("want size limit, got %v", err)
|
||||
}
|
||||
if err := os.WriteFile(fragment, []byte("# small\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
outside := filepath.Join(t.TempDir(), "outside.conf")
|
||||
if err := os.WriteFile(outside, []byte("# outside\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "bird.conf"), []byte("include \""+filepath.ToSlash(outside)+"\";\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := hashConfigTree(context.Background(), root); err == nil || !strings.Contains(err.Error(), "escapes active directory") {
|
||||
t.Fatalf("want outside include rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashConfigTreeRejectsSymlinkInclude(t *testing.T) {
|
||||
root, fragment := testConfig(t)
|
||||
if err := os.Remove(fragment); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
outside := filepath.Join(t.TempDir(), "outside.conf")
|
||||
if err := os.WriteFile(outside, []byte("# outside\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outside, fragment); err != nil {
|
||||
t.Skipf("symlinks unavailable: %v", err)
|
||||
}
|
||||
if _, err := hashConfigTree(context.Background(), root); err == nil || !strings.Contains(err.Error(), "not a regular file") {
|
||||
t.Fatalf("want symlink rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIncludeCommentsAndUnsupportedForms(t *testing.T) {
|
||||
patterns, err := includePatterns("# include \"ignored.conf\";\n/*\ninclude \"also-ignored.conf\";\n*/\ninclude \"bird.d/*.conf\";\n")
|
||||
if err != nil || len(patterns) != 1 || patterns[0] != "bird.d/*.conf" {
|
||||
t.Fatalf("comment handling failed: patterns=%v err=%v", patterns, err)
|
||||
}
|
||||
if _, err := includePatterns("if true then include \"dynamic.conf\";\n"); err == nil {
|
||||
t.Fatal("inline include should fall back to legacy cadence")
|
||||
}
|
||||
if _, err := includePatterns("include \"bird.d/[!a].conf\";\n"); err != nil {
|
||||
// Bracket glob is rejected by the scanner after lexical parsing.
|
||||
t.Fatalf("literal include statement should parse before glob validation: %v", err)
|
||||
}
|
||||
root, _ := testConfig(t)
|
||||
if err := os.WriteFile(filepath.Join(root, "bird.conf"), []byte("include \"bird.d/[!a].conf\";\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := hashConfigTree(context.Background(), root); err == nil || !strings.Contains(err.Error(), "unsupported") {
|
||||
t.Fatalf("bracket glob should fall back, got %v", err)
|
||||
}
|
||||
if _, err := includePatterns("define note = \"single\\\";\ninclude \"bird.d/real.conf\";\n"); err == nil {
|
||||
t.Fatal("backslash in quoted text must force conservative fallback")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoundedGlobLimitsMatches(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
for i := 0; i <= maxConfigFiles; i++ {
|
||||
name := filepath.Join(root, fmt.Sprintf("peer-%03d.conf", i))
|
||||
if err := os.WriteFile(name, nil, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := boundedGlob(filepath.Join(root, "*.conf")); err == nil || !strings.Contains(err.Error(), "exceeds") {
|
||||
t.Fatalf("want bounded glob rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadBoundedStopsAfterLimit(t *testing.T) {
|
||||
r := strings.NewReader("abcdefghij")
|
||||
if _, err := readBounded(r, 3); err == nil {
|
||||
t.Fatal("growing file exceeded limit")
|
||||
}
|
||||
if got := r.Len(); got != 6 {
|
||||
t.Fatalf("reader consumed beyond limit+1: %d bytes remain", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunStopsOnCancellation(t *testing.T) {
|
||||
root, _ := testConfig(t)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
ctl := &fakeControl{}
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- Run(ctx, ctl, Config{ActiveDir: root, Every: time.Hour, ReconcileEvery: 5 * time.Minute, CommandTimeout: time.Second, Logf: func(string, ...any) {}})
|
||||
}()
|
||||
cancel()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("watch did not stop after cancellation")
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,8 @@ const DefaultASOverviewURL = "https://stat.ripe.net/data/as-overview/data.json"
|
||||
|
||||
// AnnouncedPrefixes returns currently announced IPv4/IPv6 prefixes for the ASN (best-effort via RIPEstat).
|
||||
func AnnouncedPrefixes(ctx context.Context, hc *http.Client, asn int64) ([]netip.Prefix, error) {
|
||||
ctx, cancel := ripeRequestContext(ctx)
|
||||
defer cancel()
|
||||
if hc == nil {
|
||||
hc = httpclient.New(httpclient.DefaultTimeout)
|
||||
}
|
||||
@@ -43,13 +45,14 @@ func AnnouncedPrefixes(ctx context.Context, hc *http.Client, asn int64) ([]netip
|
||||
return nil, fmt.Errorf("ripestat fetch AS%d: %w", asn, err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10))
|
||||
return nil, fmt.Errorf("ripestat AS%d: HTTP %s: %s", asn, resp.Status, truncateForErr(body, 200))
|
||||
}
|
||||
body, err := httpclient.ReadResponseBody(resp, 32<<20)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ripestat AS%d response: %w", asn, err)
|
||||
}
|
||||
|
||||
var wrap struct {
|
||||
Status string `json:"status"`
|
||||
@@ -85,6 +88,8 @@ func AnnouncedPrefixes(ctx context.Context, hc *http.Client, asn int64) ([]netip
|
||||
|
||||
// ASHolderName returns the holder / organization label for the ASN from RIPEstat as-overview (best-effort).
|
||||
func ASHolderName(ctx context.Context, hc *http.Client, asn int64) (string, error) {
|
||||
ctx, cancel := ripeRequestContext(ctx)
|
||||
defer cancel()
|
||||
if hc == nil {
|
||||
hc = httpclient.New(httpclient.DefaultTimeout)
|
||||
}
|
||||
@@ -105,13 +110,14 @@ func ASHolderName(ctx context.Context, hc *http.Client, asn int64) (string, erro
|
||||
return "", fmt.Errorf("ripestat as-overview AS%d: %w", asn, err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10))
|
||||
return "", fmt.Errorf("ripestat as-overview AS%d: HTTP %s: %s", asn, resp.Status, truncateForErr(body, 200))
|
||||
}
|
||||
body, err := httpclient.ReadResponseBody(resp, 4<<20)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("ripestat as-overview AS%d response: %w", asn, err)
|
||||
}
|
||||
|
||||
var wrap struct {
|
||||
Status string `json:"status"`
|
||||
@@ -135,3 +141,10 @@ func truncateForErr(b []byte, n int) string {
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func ripeRequestContext(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||
if _, ok := ctx.Deadline(); ok {
|
||||
return context.WithCancel(ctx)
|
||||
}
|
||||
return context.WithTimeout(ctx, httpclient.DefaultTimeout)
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package asnresolve
|
||||
|
||||
import (
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"evobgp/internal/httpclient"
|
||||
)
|
||||
|
||||
type ripeRoundTrip func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f ripeRoundTrip) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
|
||||
|
||||
type ripeCountingBody struct {
|
||||
io.Reader
|
||||
read int
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (b *ripeCountingBody) Read(p []byte) (int, error) {
|
||||
n, e := b.Reader.Read(p)
|
||||
b.read += n
|
||||
return n, e
|
||||
}
|
||||
func (b *ripeCountingBody) Close() error { b.closed = true; return nil }
|
||||
|
||||
func TestRIPECompleteResponseLimits(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
limit int
|
||||
prefix string
|
||||
call func(context.Context, *http.Client) error
|
||||
}{
|
||||
{"announced", 32 << 20, `{"status":"ok","data":{"prefixes":[]}}`, func(ctx context.Context, hc *http.Client) error { _, e := AnnouncedPrefixes(ctx, hc, 64496); return e }},
|
||||
{"holder", 4 << 20, `{"status":"ok","data":{"holder":"Example"}}`, func(ctx context.Context, hc *http.Client) error { _, e := ASHolderName(ctx, hc, 64496); return e }},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
for _, known := range []bool{true, false} {
|
||||
for _, extra := range []int{0, 1} {
|
||||
body := &ripeCountingBody{Reader: strings.NewReader(tc.prefix + strings.Repeat(" ", tc.limit-len(tc.prefix)+extra))}
|
||||
hc := &http.Client{Transport: ripeRoundTrip(func(r *http.Request) (*http.Response, error) {
|
||||
if _, ok := r.Context().Deadline(); !ok {
|
||||
t.Error("missing request deadline")
|
||||
}
|
||||
length := int64(-1)
|
||||
if known {
|
||||
length = int64(tc.limit + extra)
|
||||
}
|
||||
return &http.Response{StatusCode: 200, Body: body, ContentLength: length, Header: make(http.Header)}, nil
|
||||
})}
|
||||
err := tc.call(context.Background(), hc)
|
||||
if extra == 0 && err != nil {
|
||||
t.Fatalf("exact limit rejected known=%v: %v", known, err)
|
||||
}
|
||||
if extra == 1 && !errors.Is(err, httpclient.ErrResponseTooLarge) {
|
||||
t.Fatalf("oversized valid prefix accepted known=%v: %v", known, err)
|
||||
}
|
||||
if body.read > tc.limit+1 || !body.closed {
|
||||
t.Fatalf("read=%d closed=%v", body.read, body.closed)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRIPEErrorBodySmallAndContextPreserved(t *testing.T) {
|
||||
body := &ripeCountingBody{Reader: strings.NewReader(strings.Repeat("x", 1<<20))}
|
||||
hc := &http.Client{Transport: ripeRoundTrip(func(*http.Request) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: 400, Status: "400 Bad Request", Body: body, Header: make(http.Header)}, nil
|
||||
})}
|
||||
if _, err := AnnouncedPrefixes(context.Background(), hc, 1); err == nil || body.read > 4<<10 || !body.closed {
|
||||
t.Fatalf("non200 unbounded read=%d close=%v err=%v", body.read, body.closed, err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
|
||||
defer cancel()
|
||||
hc.Transport = ripeRoundTrip(func(r *http.Request) (*http.Response, error) { <-r.Context().Done(); return nil, r.Context().Err() })
|
||||
if _, err := ASHolderName(ctx, hc, 1); !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("caller deadline lost: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRIPEGzipExpandedLimit(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Encoding", "gzip")
|
||||
gz := gzip.NewWriter(w)
|
||||
_, _ = io.WriteString(gz, `{"status":"ok","data":{"holder":"Example"}}`+strings.Repeat(" ", 4<<20))
|
||||
_ = gz.Close()
|
||||
}))
|
||||
defer server.Close()
|
||||
t.Setenv("EVOBGP_RIPESTAT_AS_OVERVIEW_URL", server.URL)
|
||||
if _, err := ASHolderName(context.Background(), server.Client(), 1); !errors.Is(err, httpclient.ErrResponseTooLarge) {
|
||||
t.Fatalf("gzip bypassed decoded limit: %v", err)
|
||||
}
|
||||
}
|
||||
+382
-101
@@ -1,152 +1,433 @@
|
||||
// Package birddeploy applies revision preview fragments to a shared BIRD volume with parse-check and LKG (plan §13).
|
||||
package birddeploy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"evobgp/internal/birdfmt"
|
||||
"evobgp/internal/store"
|
||||
)
|
||||
|
||||
// Config drives two-phase deploy from a revision in the store.
|
||||
const recoveryTimeout = 30 * time.Second
|
||||
|
||||
var includeLine = regexp.MustCompile(`(?m)^([ \t]*include[ \t]+")([^"]+)("[ \t]*;[ \t]*(?:#[^\n]*)?$)`)
|
||||
|
||||
// Config identifies the BIRD instance. StagingDir remains for caller compatibility;
|
||||
// generations live inside ActiveDir so referenced files survive process restart.
|
||||
type Config struct {
|
||||
ActiveDir string // e.g. /etc/bird (live config tree)
|
||||
StagingDir string // e.g. /tmp/evobgp-staging (same host/volume as ActiveDir)
|
||||
ActiveDir string
|
||||
StagingDir string
|
||||
BirdBin string
|
||||
BirdcBin string
|
||||
Socket string // birdc -s
|
||||
Socket string
|
||||
writeFile func(string, []byte) error // test-only fault injection; nil uses synced writes
|
||||
}
|
||||
|
||||
// ApplyRevision writes preview fragments to staging, runs bird -p, swaps into active, runs birdc configure; restores LKG on failure.
|
||||
// ApplyRevision installs a checked generation through one atomic bird.conf rename.
|
||||
func ApplyRevision(ctx context.Context, ctl *birdfmt.BirdCtl, rev *store.Revision, cfg Config) error {
|
||||
if rev == nil || len(rev.PreviewFragments) == 0 {
|
||||
return fmt.Errorf("birddeploy: no preview fragments")
|
||||
}
|
||||
active := strings.TrimSpace(cfg.ActiveDir)
|
||||
staging := strings.TrimSpace(cfg.StagingDir)
|
||||
if active == "" || staging == "" {
|
||||
return fmt.Errorf("birddeploy: ActiveDir and StagingDir required")
|
||||
if strings.TrimSpace(cfg.ActiveDir) == "" {
|
||||
return fmt.Errorf("birddeploy: ActiveDir required")
|
||||
}
|
||||
if err := os.MkdirAll(staging, 0o755); err != nil {
|
||||
return err
|
||||
active, err := filepath.Abs(cfg.ActiveDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("birddeploy: resolve active directory: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(active, "bird.d"), 0o755); err != nil {
|
||||
return err
|
||||
if err := os.MkdirAll(active, 0o755); err != nil {
|
||||
return fmt.Errorf("birddeploy: create active directory: %w", err)
|
||||
}
|
||||
lkg := filepath.Join(active, ".evobgp_lkg")
|
||||
|
||||
// Phase 1: write staging tree
|
||||
for rel, content := range rev.PreviewFragments {
|
||||
rel = strings.TrimPrefix(rel, "/")
|
||||
if !isDeployableBirdFragment(rel) {
|
||||
continue
|
||||
lock, err := lockTarget(ctx, filepath.Join(active, ".evobgp-apply.lock"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("birddeploy: acquire apply lock: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := lock.Close(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "birddeploy: release apply lock: %v\n", err)
|
||||
}
|
||||
dst := filepath.Join(staging, rel)
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(dst, []byte(content), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
mainConf := filepath.Join(staging, "bird.conf")
|
||||
if _, err := os.Stat(mainConf); err != nil {
|
||||
return fmt.Errorf("birddeploy: staging missing bird.conf: %w", err)
|
||||
}()
|
||||
if err := ctx.Err(); err != nil {
|
||||
return fmt.Errorf("birddeploy: apply cancelled: %w", err)
|
||||
}
|
||||
bc := ctl
|
||||
if bc == nil {
|
||||
bc = &birdfmt.BirdCtl{Bird: cfg.BirdBin, Birdc: cfg.BirdcBin, Socket: cfg.Socket}
|
||||
}
|
||||
if err := bc.ParseCheck(ctx, mainConf); err != nil {
|
||||
return fmt.Errorf("birddeploy: bird -p failed: %w", err)
|
||||
writeFile := cfg.writeFile
|
||||
if writeFile == nil {
|
||||
writeFile = writeSynced
|
||||
}
|
||||
|
||||
// Snapshot LKG (best-effort copy of current active bird.conf + bird.d)
|
||||
_ = os.RemoveAll(lkg)
|
||||
_ = snapshotBirdTree(active, lkg)
|
||||
|
||||
// Phase 2: atomic swap into active
|
||||
for rel, content := range rev.PreviewFragments {
|
||||
rel = strings.TrimPrefix(rel, "/")
|
||||
if !isDeployableBirdFragment(rel) {
|
||||
continue
|
||||
mainPath := filepath.Join(active, "bird.conf")
|
||||
mainInfo, err := os.Lstat(mainPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("birddeploy: inspect current bird.conf: %w", err)
|
||||
}
|
||||
if !mainInfo.Mode().IsRegular() {
|
||||
return fmt.Errorf("birddeploy: current bird.conf must be a regular file; refusing to replace %s", mainInfo.Mode())
|
||||
}
|
||||
oldMain, err := os.ReadFile(mainPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("birddeploy: read current bird.conf: %w", err)
|
||||
}
|
||||
if err := bc.ParseCheck(ctx, mainPath); err != nil {
|
||||
return fmt.Errorf("birddeploy: current configuration is not a usable LKG: %w", err)
|
||||
}
|
||||
gen, err := os.MkdirTemp(active, ".evobgp-generation-")
|
||||
if err != nil {
|
||||
return fmt.Errorf("birddeploy: create generation: %w", err)
|
||||
}
|
||||
activated := false
|
||||
defer func() {
|
||||
if !activated {
|
||||
if err := os.RemoveAll(gen); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "birddeploy: remove unactivated generation %s: %v\n", gen, err)
|
||||
}
|
||||
}
|
||||
dst := filepath.Join(active, rel)
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
_ = restoreLKG(lkg, active)
|
||||
return err
|
||||
}
|
||||
tmp := dst + ".tmp"
|
||||
if err := os.WriteFile(tmp, []byte(content), 0o644); err != nil {
|
||||
_ = restoreLKG(lkg, active)
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmp, dst); err != nil {
|
||||
_ = restoreLKG(lkg, active)
|
||||
return err
|
||||
}()
|
||||
if err := writeGenerationWith(active, gen, rev.PreviewFragments, writeFile); err != nil {
|
||||
return err
|
||||
}
|
||||
newMain, err := os.ReadFile(filepath.Join(gen, "bird.conf"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("birddeploy: read generated bird.conf: %w", err)
|
||||
}
|
||||
if err := bc.ParseCheck(ctx, filepath.Join(gen, "bird.conf")); err != nil {
|
||||
return fmt.Errorf("birddeploy: staged bird -p failed: %w", err)
|
||||
}
|
||||
if err := writeFile(filepath.Join(gen, "previous-bird.conf"), oldMain); err != nil {
|
||||
return fmt.Errorf("birddeploy: save LKG: %w", err)
|
||||
}
|
||||
if err := syncDir(gen); err != nil {
|
||||
return fmt.Errorf("birddeploy: sync LKG generation: %w", err)
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return fmt.Errorf("birddeploy: apply cancelled: %w", err)
|
||||
}
|
||||
swapped, err := replaceMain(active, newMain)
|
||||
activated = swapped
|
||||
if err != nil {
|
||||
if swapped {
|
||||
return recoverPrevious(bc, active, gen, oldMain, fmt.Errorf("activate generation: %w", err))
|
||||
}
|
||||
return fmt.Errorf("birddeploy: activate generation: %w", err)
|
||||
}
|
||||
if err := bc.Configure(ctx); err != nil {
|
||||
_ = restoreLKG(lkg, active)
|
||||
_ = bc.Configure(ctx)
|
||||
return fmt.Errorf("birddeploy: birdc configure failed, restored LKG: %w", err)
|
||||
return recoverPrevious(bc, active, gen, oldMain, err)
|
||||
}
|
||||
if err := pruneGenerations(active, gen, oldMain); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "birddeploy: configuration applied; generation cleanup failed: %v\n", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isDeployableBirdFragment skips UI-only preview keys (e.g. flattened full config) not referenced from bird.conf.
|
||||
func isDeployableBirdFragment(rel string) bool {
|
||||
return rel != "" && !strings.HasPrefix(rel, "_")
|
||||
func recoverPrevious(bc *birdfmt.BirdCtl, active, gen string, oldMain []byte, cause error) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), recoveryTimeout)
|
||||
defer cancel()
|
||||
if _, err := replaceMain(active, oldMain); err != nil {
|
||||
return fmt.Errorf("birddeploy: configure failed: %w; restore failed: %v", cause, err)
|
||||
}
|
||||
if err := bc.Configure(ctx); err != nil {
|
||||
return fmt.Errorf("birddeploy: configure failed: %w; LKG restored on disk but recovery configure failed: %v", cause, err)
|
||||
}
|
||||
if err := os.RemoveAll(gen); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "birddeploy: LKG restored; remove failed generation %s: %v\n", gen, err)
|
||||
}
|
||||
return fmt.Errorf("birddeploy: configure failed: %w; LKG restored and recovery configure succeeded", cause)
|
||||
}
|
||||
|
||||
func snapshotBirdTree(activeRoot, dstRoot string) error {
|
||||
if err := os.MkdirAll(dstRoot, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
mc := filepath.Join(activeRoot, "bird.conf")
|
||||
if b, err := os.ReadFile(mc); err == nil {
|
||||
_ = os.WriteFile(filepath.Join(dstRoot, "bird.conf"), b, 0o644)
|
||||
}
|
||||
bd := filepath.Join(activeRoot, "bird.d")
|
||||
if st, err := os.Stat(bd); err == nil && st.IsDir() {
|
||||
_ = filepath.WalkDir(bd, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return err
|
||||
}
|
||||
suffix, _ := filepath.Rel(activeRoot, path)
|
||||
out := filepath.Join(dstRoot, suffix)
|
||||
_ = os.MkdirAll(filepath.Dir(out), 0o755)
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return os.WriteFile(out, b, 0o644)
|
||||
})
|
||||
}
|
||||
return nil
|
||||
func writeGeneration(active, gen string, fragments map[string]string) error {
|
||||
return writeGenerationWith(active, gen, fragments, writeSynced)
|
||||
}
|
||||
|
||||
func restoreLKG(lkg, active string) error {
|
||||
if _, err := os.Stat(filepath.Join(lkg, "bird.conf")); err != nil {
|
||||
return err
|
||||
func writeGenerationWith(active, gen string, fragments map[string]string, writeFile func(string, []byte) error) error {
|
||||
main, ok := fragments["bird.conf"]
|
||||
if !ok {
|
||||
return fmt.Errorf("birddeploy: preview missing bird.conf")
|
||||
}
|
||||
_ = filepath.WalkDir(lkg, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return err
|
||||
}
|
||||
suffix, _ := filepath.Rel(lkg, path)
|
||||
out := filepath.Join(active, suffix)
|
||||
_ = os.MkdirAll(filepath.Dir(out), 0o755)
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
oldBirdD := filepath.Join(active, birdfmt.DirBirdD)
|
||||
newBirdD := filepath.Join(gen, birdfmt.DirBirdD)
|
||||
if err := os.MkdirAll(newBirdD, 0o755); err != nil {
|
||||
return fmt.Errorf("birddeploy: create generation bird.d: %w", err)
|
||||
}
|
||||
if err := filepath.WalkDir(oldBirdD, func(path string, d fs.DirEntry, walkErr error) error {
|
||||
if errors.Is(walkErr, fs.ErrNotExist) && path == oldBirdD {
|
||||
return nil
|
||||
}
|
||||
return os.WriteFile(out, b, 0o644)
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
rel, err := filepath.Rel(oldBirdD, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.Type()&os.ModeSymlink != 0 {
|
||||
return fmt.Errorf("operator snippet %q is a symlink", rel)
|
||||
}
|
||||
if rel == "." {
|
||||
if !d.IsDir() {
|
||||
return fmt.Errorf("birddeploy: bird.d is not a directory")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if d.IsDir() {
|
||||
return os.MkdirAll(filepath.Join(newBirdD, rel), 0o755)
|
||||
}
|
||||
if !d.Type().IsRegular() {
|
||||
return fmt.Errorf("operator snippet %q is not a regular file", rel)
|
||||
}
|
||||
_, replaced := fragments[filepath.ToSlash(filepath.Join(birdfmt.DirBirdD, rel))]
|
||||
if strings.HasPrefix(filepath.Base(rel), "evobgp_") || replaced {
|
||||
return nil
|
||||
}
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if countIncludeTokens(string(b)) > 0 {
|
||||
return fmt.Errorf("operator snippet %q has a nested include; cannot pin the complete tree", rel)
|
||||
}
|
||||
return writeFile(filepath.Join(newBirdD, rel), b)
|
||||
}); err != nil {
|
||||
return fmt.Errorf("birddeploy: copy operator snippets: %w", err)
|
||||
}
|
||||
for rel, content := range fragments {
|
||||
if rel == "bird.conf" || strings.HasPrefix(rel, "_") {
|
||||
continue
|
||||
}
|
||||
if countIncludeTokens(content) > 0 {
|
||||
return fmt.Errorf("birddeploy: nested include in preview fragment %q is unsupported", rel)
|
||||
}
|
||||
clean := filepath.Clean(filepath.FromSlash(rel))
|
||||
if filepath.IsAbs(clean) || clean == "." || clean == ".." || strings.HasPrefix(clean, ".."+string(os.PathSeparator)) || !strings.HasPrefix(clean, birdfmt.DirBirdD+string(os.PathSeparator)) || filepath.Ext(clean) != ".conf" {
|
||||
return fmt.Errorf("birddeploy: unsafe preview path %q", rel)
|
||||
}
|
||||
dst := filepath.Join(gen, clean)
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
return fmt.Errorf("birddeploy: create fragment directory: %w", err)
|
||||
}
|
||||
if err := writeFile(dst, []byte(content)); err != nil {
|
||||
return fmt.Errorf("birddeploy: write %s: %w", rel, err)
|
||||
}
|
||||
}
|
||||
var rewriteErr error
|
||||
if !standaloneIncludesOnly(main) {
|
||||
return fmt.Errorf("birddeploy: unsupported include syntax in bird.conf")
|
||||
}
|
||||
rewritten := includeLine.ReplaceAllStringFunc(main, func(line string) string {
|
||||
m := includeLine.FindStringSubmatch(line)
|
||||
inc := m[2]
|
||||
if filepath.IsAbs(inc) {
|
||||
rel, err := filepath.Rel(active, inc)
|
||||
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
|
||||
rewriteErr = fmt.Errorf("birddeploy: external absolute include %q is unsupported", inc)
|
||||
return line
|
||||
}
|
||||
inc = rel
|
||||
}
|
||||
clean := filepath.Clean(filepath.FromSlash(inc))
|
||||
if clean == ".." || strings.HasPrefix(clean, ".."+string(os.PathSeparator)) || !strings.HasPrefix(clean, birdfmt.DirBirdD+string(os.PathSeparator)) {
|
||||
rewriteErr = fmt.Errorf("birddeploy: unsupported relative include %q", inc)
|
||||
return line
|
||||
}
|
||||
return m[1] + filepath.ToSlash(filepath.Join(gen, clean)) + m[3]
|
||||
})
|
||||
if rewriteErr != nil {
|
||||
return rewriteErr
|
||||
}
|
||||
if err := writeFile(filepath.Join(gen, ".evobgp-owned"), []byte("evobgp-generation-v1\n")); err != nil {
|
||||
return fmt.Errorf("birddeploy: mark generation: %w", err)
|
||||
}
|
||||
if err := writeFile(filepath.Join(gen, "bird.conf"), []byte(rewritten)); err != nil {
|
||||
return fmt.Errorf("birddeploy: write generated bird.conf: %w", err)
|
||||
}
|
||||
return syncTree(gen)
|
||||
}
|
||||
|
||||
func standaloneIncludesOnly(config string) bool {
|
||||
tokens := includeTokenOffsets(config)
|
||||
matches := includeLine.FindAllStringIndex(config, -1)
|
||||
if len(tokens) != len(matches) {
|
||||
return false
|
||||
}
|
||||
for i, match := range matches {
|
||||
word := strings.Index(config[match[0]:match[1]], "include")
|
||||
if word < 0 || tokens[i] != match[0]+word {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func countIncludeTokens(config string) int { return len(includeTokenOffsets(config)) }
|
||||
|
||||
// includeTokenOffsets skips BIRD comments and strings, retaining exact offsets
|
||||
// so a commented standalone include cannot mask a live inline include.
|
||||
func includeTokenOffsets(config string) []int {
|
||||
var offsets []int
|
||||
quote := byte(0)
|
||||
lineComment, blockComment := false, false
|
||||
for i := 0; i < len(config); {
|
||||
c := config[i]
|
||||
if lineComment {
|
||||
if c == '\n' {
|
||||
lineComment = false
|
||||
}
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if blockComment {
|
||||
if c == '*' && i+1 < len(config) && config[i+1] == '/' {
|
||||
blockComment = false
|
||||
i += 2
|
||||
} else {
|
||||
i++
|
||||
}
|
||||
continue
|
||||
}
|
||||
if quote != 0 {
|
||||
// BIRD quoted text does not use C-style backslash escapes.
|
||||
// Skipping the following quote could hide a live include.
|
||||
if c == quote {
|
||||
quote = 0
|
||||
}
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if c == '#' {
|
||||
lineComment = true
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if c == '/' && i+1 < len(config) && config[i+1] == '*' {
|
||||
blockComment = true
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
if c == '"' || c == '\'' {
|
||||
quote = c
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '_' {
|
||||
start := i
|
||||
for i < len(config) && ((config[i] >= 'a' && config[i] <= 'z') || (config[i] >= 'A' && config[i] <= 'Z') || (config[i] >= '0' && config[i] <= '9') || config[i] == '_') {
|
||||
i++
|
||||
}
|
||||
if config[start:i] == "include" {
|
||||
offsets = append(offsets, start)
|
||||
}
|
||||
continue
|
||||
}
|
||||
i++
|
||||
}
|
||||
return offsets
|
||||
}
|
||||
|
||||
func syncTree(root string) error {
|
||||
var dirs []string
|
||||
if err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() {
|
||||
dirs = append(dirs, path)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
sort.Slice(dirs, func(i, j int) bool { return len(dirs[i]) > len(dirs[j]) })
|
||||
for _, dir := range dirs {
|
||||
if err := syncDir(dir); err != nil {
|
||||
return fmt.Errorf("sync %s: %w", dir, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func pruneGenerations(active, current string, previous []byte) error {
|
||||
entries, err := os.ReadDir(active)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var stale []string
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() || !strings.HasPrefix(entry.Name(), ".evobgp-generation-") {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(active, entry.Name())
|
||||
if path == current || strings.Contains(string(previous), filepath.ToSlash(path)+"/") {
|
||||
continue
|
||||
}
|
||||
marker, err := os.ReadFile(filepath.Join(path, ".evobgp-owned"))
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if string(marker) != "evobgp-generation-v1\n" {
|
||||
continue
|
||||
}
|
||||
stale = append(stale, path)
|
||||
}
|
||||
sort.Strings(stale)
|
||||
for _, path := range stale {
|
||||
if err := os.RemoveAll(path); err != nil {
|
||||
return fmt.Errorf("remove %s: %w", path, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func replaceMain(active string, content []byte) (bool, error) {
|
||||
tmp, err := os.CreateTemp(active, ".evobgp-bird-conf-")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer func() { _ = os.Remove(tmp.Name()) }()
|
||||
if err := tmp.Chmod(0o644); err != nil {
|
||||
_ = tmp.Close()
|
||||
return false, err
|
||||
}
|
||||
if _, err := tmp.Write(content); err != nil {
|
||||
_ = tmp.Close()
|
||||
return false, err
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
_ = tmp.Close()
|
||||
return false, err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := os.Rename(tmp.Name(), filepath.Join(active, "bird.conf")); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, syncDir(active)
|
||||
}
|
||||
|
||||
func writeSynced(path string, b []byte) error {
|
||||
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := f.Write(b); err != nil {
|
||||
_ = f.Close()
|
||||
return err
|
||||
}
|
||||
if err := f.Sync(); err != nil {
|
||||
_ = f.Close()
|
||||
return err
|
||||
}
|
||||
return f.Close()
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
//go:build linux
|
||||
|
||||
package birddeploy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"evobgp/internal/birdfmt"
|
||||
"evobgp/internal/store"
|
||||
)
|
||||
|
||||
func fakeCtl(t *testing.T, active string) *birdfmt.BirdCtl {
|
||||
t.Helper()
|
||||
bin := t.TempDir()
|
||||
parse := filepath.Join(bin, "bird")
|
||||
configure := filepath.Join(bin, "birdc")
|
||||
parseScript := `#!/bin/sh
|
||||
if grep -q FAIL_PARSE "$2"; then exit 1; fi
|
||||
exit 0
|
||||
`
|
||||
configureScript := fmt.Sprintf(`#!/bin/sh
|
||||
root=%q
|
||||
if [ -e "$root/.configure-wait" ]; then
|
||||
rm "$root/.configure-wait"
|
||||
touch "$root/.configure-started"
|
||||
exec sleep 5
|
||||
fi
|
||||
if [ -e "$root/.configure-fail-once" ]; then
|
||||
rm "$root/.configure-fail-once"
|
||||
exit 1
|
||||
fi
|
||||
mkdir "$root/.configuring" || exit 1
|
||||
sleep 0.05
|
||||
rmdir "$root/.configuring"
|
||||
`, active)
|
||||
for path, script := range map[string]string{parse: parseScript, configure: configureScript} {
|
||||
if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return &birdfmt.BirdCtl{Bird: parse, Birdc: configure}
|
||||
}
|
||||
|
||||
func revision(label string) *store.Revision {
|
||||
return &store.Revision{PreviewFragments: map[string]string{
|
||||
"bird.conf": "router id 192.0.2.1;\ninclude \"bird.d/*.conf\";\n# " + label + "\n",
|
||||
"bird.d/evobgp_new.conf": "# " + label + "\n",
|
||||
}}
|
||||
}
|
||||
|
||||
func setupActive(t *testing.T) (string, *birdfmt.BirdCtl, []byte) {
|
||||
t.Helper()
|
||||
active := t.TempDir()
|
||||
if err := os.Mkdir(filepath.Join(active, "bird.d"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
old := []byte("router id 192.0.2.2;\ninclude \"bird.d/*.conf\";\n# old\n")
|
||||
if err := os.WriteFile(filepath.Join(active, "bird.conf"), old, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, content := range map[string]string{"evobgp_removed.conf": "# removed\n", "operator.conf": "# operator\n"} {
|
||||
if err := os.WriteFile(filepath.Join(active, "bird.d", name), []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return active, fakeCtl(t, active), old
|
||||
}
|
||||
|
||||
func TestApplyRevisionGenerationAndPrune(t *testing.T) {
|
||||
active, ctl, _ := setupActive(t)
|
||||
for _, label := range []string{"one", "two", "three"} {
|
||||
if err := ApplyRevision(context.Background(), ctl, revision(label), Config{ActiveDir: active}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
main, err := os.ReadFile(filepath.Join(active, "bird.conf"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(main), "# "+label) {
|
||||
t.Fatalf("wrong active generation: %s", main)
|
||||
}
|
||||
if strings.Contains(string(main), `include "bird.d/`) {
|
||||
t.Fatalf("include not pinned: %s", main)
|
||||
}
|
||||
}
|
||||
gens, err := filepath.Glob(filepath.Join(active, ".evobgp-generation-*"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(gens) != 2 {
|
||||
t.Fatalf("want active and previous generations, got %d", len(gens))
|
||||
}
|
||||
for _, gen := range gens {
|
||||
if _, err := os.Stat(filepath.Join(gen, "bird.d", "evobgp_removed.conf")); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("removed fragment retained in %s: %v", gen, err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(gen, "bird.d", "operator.conf")); err != nil {
|
||||
t.Fatalf("operator fragment missing: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyRevisionFaults(t *testing.T) {
|
||||
for _, mode := range []string{"parse", "write", "disk-full", "configure", "cancel"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
active, ctl, old := setupActive(t)
|
||||
rev := revision(mode)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
cfg := Config{ActiveDir: active}
|
||||
switch mode {
|
||||
case "parse":
|
||||
rev.PreviewFragments["bird.conf"] += "FAIL_PARSE\n"
|
||||
case "write":
|
||||
rev.PreviewFragments["../escape.conf"] = "bad"
|
||||
case "disk-full":
|
||||
cfg.writeFile = func(path string, b []byte) error {
|
||||
if filepath.Base(path) == "previous-bird.conf" {
|
||||
return syscall.ENOSPC
|
||||
}
|
||||
return writeSynced(path, b)
|
||||
}
|
||||
case "configure":
|
||||
if err := os.WriteFile(filepath.Join(active, ".configure-fail-once"), nil, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case "cancel":
|
||||
if err := os.WriteFile(filepath.Join(active, ".configure-wait"), nil, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
result := make(chan error, 1)
|
||||
go func() { result <- ApplyRevision(ctx, ctl, rev, cfg) }()
|
||||
if mode == "cancel" {
|
||||
deadline := time.After(3 * time.Second)
|
||||
for {
|
||||
if _, err := os.Stat(filepath.Join(active, ".configure-started")); err == nil {
|
||||
break
|
||||
}
|
||||
select {
|
||||
case <-deadline:
|
||||
t.Fatal("configure did not start")
|
||||
case <-time.After(10 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
cancel()
|
||||
}
|
||||
err := <-result
|
||||
if err == nil {
|
||||
t.Fatal("expected apply failure")
|
||||
}
|
||||
main, readErr := os.ReadFile(filepath.Join(active, "bird.conf"))
|
||||
if readErr != nil {
|
||||
t.Fatal(readErr)
|
||||
}
|
||||
if string(main) != string(old) {
|
||||
t.Fatalf("old config not restored: %s; error: %v", main, err)
|
||||
}
|
||||
if mode == "configure" || mode == "cancel" {
|
||||
if !strings.Contains(err.Error(), "recovery configure succeeded") {
|
||||
t.Fatalf("unconfirmed recovery: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyRevisionSerializes(t *testing.T) {
|
||||
active, ctl, _ := setupActive(t)
|
||||
var wg sync.WaitGroup
|
||||
errs := make(chan error, 2)
|
||||
for _, label := range []string{"first", "second"} {
|
||||
wg.Add(1)
|
||||
go func(label string) {
|
||||
defer wg.Done()
|
||||
errs <- ApplyRevision(context.Background(), ctl, revision(label), Config{ActiveDir: active})
|
||||
}(label)
|
||||
}
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
for err := range errs {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLockHelperProcess(t *testing.T) {
|
||||
active := os.Getenv("EVOBGP_LOCK_HELPER_ACTIVE")
|
||||
if active == "" {
|
||||
return
|
||||
}
|
||||
lock, err := lockTarget(context.Background(), filepath.Join(active, ".evobgp-apply.lock"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = lock.Close() }()
|
||||
if err := os.WriteFile(filepath.Join(active, ".helper-ready"), nil, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for {
|
||||
if _, err := os.Stat(filepath.Join(active, ".helper-done")); err == nil {
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyRevisionCrossProcessLock(t *testing.T) {
|
||||
active, ctl, old := setupActive(t)
|
||||
cmd := exec.Command(os.Args[0], "-test.run=^TestLockHelperProcess$")
|
||||
cmd.Env = append(os.Environ(), "EVOBGP_LOCK_HELPER_ACTIVE="+active)
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
_ = os.WriteFile(filepath.Join(active, ".helper-done"), nil, 0o644)
|
||||
if err := cmd.Wait(); err != nil {
|
||||
t.Errorf("lock helper: %v", err)
|
||||
}
|
||||
}()
|
||||
deadline := time.After(3 * time.Second)
|
||||
for {
|
||||
if _, err := os.Stat(filepath.Join(active, ".helper-ready")); err == nil {
|
||||
break
|
||||
}
|
||||
select {
|
||||
case <-deadline:
|
||||
t.Fatal("lock helper did not become ready")
|
||||
case <-time.After(10 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
if err := ApplyRevision(ctx, ctl, revision("blocked"), Config{ActiveDir: active}); !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("want lock timeout, got %v", err)
|
||||
}
|
||||
main, err := os.ReadFile(filepath.Join(active, "bird.conf"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(main) != string(old) {
|
||||
t.Fatalf("apply bypassed cross-process lock: %s", main)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRealBirdParseGeneration(t *testing.T) {
|
||||
bin := strings.TrimSpace(os.Getenv("EVOBGP_TEST_BIRD_BIN"))
|
||||
if bin == "" {
|
||||
t.Skip("EVOBGP_TEST_BIRD_BIN is not set")
|
||||
}
|
||||
active := t.TempDir()
|
||||
old := []byte("router id 192.0.2.2;\nprotocol device {}\n")
|
||||
if err := os.WriteFile(filepath.Join(active, "bird.conf"), old, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctl := fakeCtl(t, active)
|
||||
ctl.Bird = bin
|
||||
rev := &store.Revision{PreviewFragments: map[string]string{
|
||||
"bird.conf": "router id 192.0.2.1;\ninclude \"bird.d/evobgp_new.conf\";\nprotocol device {}\n",
|
||||
"bird.d/evobgp_new.conf": "# complete generated fragment\n",
|
||||
}}
|
||||
if err := ApplyRevision(context.Background(), ctl, rev, Config{ActiveDir: active}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ctl.ParseCheck(context.Background(), filepath.Join(active, "bird.conf")); err != nil {
|
||||
t.Fatalf("activated config fails real bird -p: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package birddeploy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"evobgp/internal/store"
|
||||
)
|
||||
|
||||
func TestWriteGenerationPinsCompleteIncludeTree(t *testing.T) {
|
||||
active := t.TempDir()
|
||||
if err := os.Mkdir(filepath.Join(active, "bird.d"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(active, "bird.d", "operator.conf"), []byte("# operator"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(active, "bird.d", "evobgp_removed.conf"), []byte("# removed"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gen := filepath.Join(active, ".evobgp-generation-test")
|
||||
if err := os.Mkdir(gen, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fragments := map[string]string{"bird.conf": "include \"bird.d/*.conf\";\n", "bird.d/evobgp_new.conf": "# new"}
|
||||
if err := writeGeneration(active, gen, fragments); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
main, err := os.ReadFile(filepath.Join(gen, "bird.conf"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(main), filepath.ToSlash(filepath.Join(gen, "bird.d", "*.conf"))) {
|
||||
t.Fatalf("include is not pinned to generation: %s", main)
|
||||
}
|
||||
for _, name := range []string{"operator.conf", "evobgp_new.conf"} {
|
||||
if _, err := os.Stat(filepath.Join(gen, "bird.d", name)); err != nil {
|
||||
t.Fatalf("%s missing: %v", name, err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(gen, "bird.d", "evobgp_removed.conf")); !os.IsNotExist(err) {
|
||||
t.Fatalf("removed fragment retained: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteGenerationRejectsUnsafePaths(t *testing.T) {
|
||||
for _, bad := range []string{"../escape.conf", "bird.d/../../escape.conf", "other.conf"} {
|
||||
t.Run(bad, func(t *testing.T) {
|
||||
active := t.TempDir()
|
||||
gen := filepath.Join(active, ".evobgp-generation-test")
|
||||
if err := os.Mkdir(gen, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fragments := map[string]string{"bird.conf": "router id 192.0.2.1;\n", bad: "bad"}
|
||||
if err := writeGeneration(active, gen, fragments); err == nil {
|
||||
t.Fatal("unsafe path accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteGenerationRejectsExternalAndNestedIncludes(t *testing.T) {
|
||||
for _, main := range []string{
|
||||
`include "/outside/bird.d/peer.conf";` + "\n",
|
||||
`include "other/peer.conf";` + "\n",
|
||||
`include "bird.d/peer.conf"; include "other.conf";` + "\n",
|
||||
`router id 192.0.2.1; include "/outside/peer.conf";` + "\n",
|
||||
"/*\ninclude \"bird.d/decoy.conf\";\n*/\nrouter id 192.0.2.1; include \"/outside/live.conf\";\n",
|
||||
} {
|
||||
active := t.TempDir()
|
||||
gen := filepath.Join(active, ".evobgp-generation-test")
|
||||
if err := os.Mkdir(gen, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := writeGeneration(active, gen, map[string]string{"bird.conf": main}); err == nil {
|
||||
t.Fatalf("include accepted: %s", main)
|
||||
}
|
||||
}
|
||||
active := t.TempDir()
|
||||
if err := os.Mkdir(filepath.Join(active, "bird.d"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(active, "bird.d", "operator.conf"), []byte(`define X=1; include "/outside/foo.conf";`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gen := filepath.Join(active, ".evobgp-generation-test")
|
||||
if err := os.Mkdir(gen, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := writeGeneration(active, gen, map[string]string{"bird.conf": "router id 192.0.2.1;\n"}); err == nil {
|
||||
t.Fatal("nested operator include accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCountIncludeTokensIgnoresCommentsAndStrings(t *testing.T) {
|
||||
content := "# include \"ignored.conf\";\n" +
|
||||
"/* include \"ignored.conf\"; */\n" +
|
||||
"define note = \"include in string\";\n" +
|
||||
"include \"bird.d/real.conf\";\n"
|
||||
if got := countIncludeTokens(content); got != 1 {
|
||||
t.Fatalf("want one include, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIncludeAfterBackslashStringCannotHideNestedInclude(t *testing.T) {
|
||||
content := `define note = "\";` + "\n" + `include "/outside/hidden.conf";` + "\n"
|
||||
if got := countIncludeTokens(content); got != 1 {
|
||||
t.Fatalf("live include hidden after quoted backslash: %d", got)
|
||||
}
|
||||
active := t.TempDir()
|
||||
gen := filepath.Join(active, ".evobgp-generation-test")
|
||||
if err := os.Mkdir(gen, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fragments := map[string]string{"bird.conf": "router id 192.0.2.1;\n", "bird.d/evobgp_test.conf": content}
|
||||
if err := writeGeneration(active, gen, fragments); err == nil {
|
||||
t.Fatal("nested external include accepted after quoted backslash")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteGenerationRewritesAbsoluteActiveInclude(t *testing.T) {
|
||||
active := t.TempDir()
|
||||
gen := filepath.Join(active, ".evobgp-generation-test")
|
||||
if err := os.Mkdir(gen, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
inc := filepath.ToSlash(filepath.Join(active, "bird.d", "peer.conf"))
|
||||
main := "include \"" + inc + "\";\n"
|
||||
if err := writeGeneration(active, gen, map[string]string{"bird.conf": main, "bird.d/peer.conf": "# peer"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := os.ReadFile(filepath.Join(gen, "bird.conf"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(b), filepath.ToSlash(filepath.Join(gen, "bird.d", "peer.conf"))) {
|
||||
t.Fatalf("absolute include not rewritten: %s", b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteGenerationRejectsSymlinkBirdDRoot(t *testing.T) {
|
||||
active := t.TempDir()
|
||||
outside := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(outside, "operator.conf"), []byte("# operator"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outside, filepath.Join(active, "bird.d")); err != nil {
|
||||
t.Skipf("symlinks unavailable: %v", err)
|
||||
}
|
||||
old := []byte("router id 192.0.2.2;\n")
|
||||
if err := os.WriteFile(filepath.Join(active, "bird.conf"), old, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gen := filepath.Join(active, ".evobgp-generation-test")
|
||||
if err := os.Mkdir(gen, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := writeGeneration(active, gen, map[string]string{"bird.conf": "router id 192.0.2.1;\n"}); err == nil {
|
||||
t.Fatal("bird.d symlink accepted")
|
||||
}
|
||||
current, err := os.ReadFile(filepath.Join(active, "bird.conf"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(current) != string(old) {
|
||||
t.Fatalf("active config changed: %s", current)
|
||||
}
|
||||
if _, err := os.ReadFile(filepath.Join(outside, "operator.conf")); err != nil {
|
||||
t.Fatalf("operator snippet changed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyRevisionRejectsSymlinkMain(t *testing.T) {
|
||||
active := t.TempDir()
|
||||
target := filepath.Join(t.TempDir(), "operator-bird.conf")
|
||||
old := []byte("router id 192.0.2.2;\n")
|
||||
if err := os.WriteFile(target, old, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(target, filepath.Join(active, "bird.conf")); err != nil {
|
||||
t.Skipf("symlinks unavailable: %v", err)
|
||||
}
|
||||
err := ApplyRevision(context.Background(), nil, &store.Revision{PreviewFragments: map[string]string{"bird.conf": "router id 192.0.2.1;\n"}}, Config{ActiveDir: active})
|
||||
if err == nil || !strings.Contains(err.Error(), "regular file") {
|
||||
t.Fatalf("want explicit symlink rejection, got %v", err)
|
||||
}
|
||||
info, err := os.Lstat(filepath.Join(active, "bird.conf"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink == 0 {
|
||||
t.Fatal("active symlink replaced")
|
||||
}
|
||||
current, err := os.ReadFile(target)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(current) != string(old) {
|
||||
t.Fatalf("operator target changed: %s", current)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
//go:build linux
|
||||
|
||||
package birddeploy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
func lockTarget(ctx context.Context, path string) (*os.File, error) {
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for {
|
||||
err = unix.Flock(int(f.Fd()), unix.LOCK_EX|unix.LOCK_NB)
|
||||
if err == nil {
|
||||
return f, nil
|
||||
}
|
||||
if !errors.Is(err, unix.EWOULDBLOCK) && !errors.Is(err, unix.EAGAIN) {
|
||||
_ = f.Close()
|
||||
return nil, err
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
_ = f.Close()
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(25 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
//go:build !linux && !windows
|
||||
|
||||
package birddeploy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
)
|
||||
|
||||
func lockTarget(context.Context, string) (*os.File, error) {
|
||||
return nil, errors.New("birddeploy: cross-process apply lock is unsupported on this platform")
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
//go:build windows
|
||||
|
||||
package birddeploy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
func lockTarget(ctx context.Context, path string) (*os.File, error) {
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var overlapped windows.Overlapped
|
||||
for {
|
||||
err = windows.LockFileEx(windows.Handle(f.Fd()), windows.LOCKFILE_EXCLUSIVE_LOCK|windows.LOCKFILE_FAIL_IMMEDIATELY, 0, 1, 0, &overlapped)
|
||||
if err == nil {
|
||||
return f, nil
|
||||
}
|
||||
if !errors.Is(err, windows.ERROR_LOCK_VIOLATION) {
|
||||
_ = f.Close()
|
||||
return nil, err
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
_ = f.Close()
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(25 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
//go:build !windows
|
||||
|
||||
package birddeploy
|
||||
|
||||
import "os"
|
||||
|
||||
func syncDir(path string) error {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
return f.Sync()
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
//go:build windows
|
||||
|
||||
package birddeploy
|
||||
|
||||
// Windows does not support syncing a directory handle through os.File.Sync.
|
||||
func syncDir(string) error { return nil }
|
||||
+77
-11
@@ -3,34 +3,100 @@ package bundle
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
pathpkg "path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// WriteExtractedFiles writes payload files from a verified bundle under root (directories created as needed).
|
||||
func WriteExtractedFiles(root string, v *VerifiedContents) error {
|
||||
return WriteExtractedFilesWithLimits(root, v, Limits{})
|
||||
}
|
||||
|
||||
// WriteExtractedFilesWithLimits confines all filesystem operations to an open
|
||||
// directory handle. Replacing existing files uses unlink + exclusive creation,
|
||||
// so existing hard links and symlinks cannot redirect writes outside the root.
|
||||
func WriteExtractedFilesWithLimits(root string, v *VerifiedContents, limits Limits) error {
|
||||
l, err := limits.Normalized()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if v == nil {
|
||||
return fmt.Errorf("bundle: nil verified contents")
|
||||
}
|
||||
if len(v.Manifest.Files) > l.MaxEntries-2 {
|
||||
return ErrLimit
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
var total int64
|
||||
for _, fe := range v.Manifest.Files {
|
||||
if err := validatePath(fe.Path, l.MaxPathBytes); err != nil {
|
||||
return err
|
||||
}
|
||||
if seen[strings.ToLower(fe.Path)] {
|
||||
return fmt.Errorf("bundle: duplicate path %q", fe.Path)
|
||||
}
|
||||
seen[strings.ToLower(fe.Path)] = true
|
||||
data, ok := v.Files[fe.Path]
|
||||
if !ok {
|
||||
return fmt.Errorf("bundle: missing file %q", fe.Path)
|
||||
}
|
||||
if int64(len(data)) > l.MaxFileBytes || int64(len(data)) > l.MaxExpandedBytes-total {
|
||||
return ErrLimit
|
||||
}
|
||||
total += int64(len(data))
|
||||
}
|
||||
root = filepath.Clean(root)
|
||||
if err := os.MkdirAll(root, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
confined, err := os.OpenRoot(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = confined.Close() }()
|
||||
for _, fe := range v.Manifest.Files {
|
||||
data, ok := v.Files[fe.Path]
|
||||
if !ok {
|
||||
return fmt.Errorf("bundle: missing file %q", fe.Path)
|
||||
}
|
||||
rel := pathpkg.Clean(strings.TrimPrefix(fe.Path, "/"))
|
||||
if rel == "." || strings.HasPrefix(rel, "..") {
|
||||
return fmt.Errorf("bundle: unsafe path %q", fe.Path)
|
||||
parts := strings.Split(fe.Path, "/")
|
||||
for i := 1; i < len(parts); i++ {
|
||||
dir := filepath.FromSlash(strings.Join(parts[:i], "/"))
|
||||
if err := confined.Mkdir(dir, 0o755); err != nil && !os.IsExist(err) {
|
||||
return err
|
||||
}
|
||||
info, err := confined.Lstat(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
|
||||
return fmt.Errorf("bundle: unsafe directory %q", dir)
|
||||
}
|
||||
}
|
||||
dest := filepath.Join(root, filepath.FromSlash(rel))
|
||||
relToRoot, err := filepath.Rel(root, dest)
|
||||
if err != nil || strings.HasPrefix(relToRoot, "..") {
|
||||
return fmt.Errorf("bundle: path escapes root: %q", fe.Path)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
||||
dest := filepath.FromSlash(fe.Path)
|
||||
info, err := confined.Lstat(dest)
|
||||
if err == nil {
|
||||
if !info.Mode().IsRegular() {
|
||||
return fmt.Errorf("bundle: unsafe destination %q", dest)
|
||||
}
|
||||
if err = confined.Remove(dest); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(dest, data, 0o644); err != nil {
|
||||
f, err := confined.OpenFile(dest, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, writeErr := f.Write(data)
|
||||
closeErr := f.Close()
|
||||
if writeErr != nil {
|
||||
return writeErr
|
||||
}
|
||||
if closeErr != nil {
|
||||
return closeErr
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
package bundle
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"crypto/ed25519"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"reflect"
|
||||
"sort"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// legacyManifestV1 mirrors the decoder fields present in the locally available
|
||||
// v1.0.0 source. It deliberately has no future capability or deployment fields.
|
||||
type legacyManifestV1 struct {
|
||||
RevisionID string `json:"revision_id"`
|
||||
SpeakerID string `json:"speaker_id"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
Files []struct {
|
||||
Path string `json:"path"`
|
||||
SHA256 string `json:"sha256"`
|
||||
} `json:"files"`
|
||||
Algorithm string `json:"signature_algorithm"`
|
||||
PublicKeyBase64 string `json:"public_key_base64"`
|
||||
}
|
||||
|
||||
func TestLegacyCompatibilityFrozenSignedCurrentBaseline(t *testing.T) {
|
||||
raw, err := os.ReadFile("testdata/current_baseline_signed_bundle.tar.gz")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pub := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{0x42}, ed25519.SeedSize)).Public().(ed25519.PublicKey)
|
||||
verified, err := VerifyGzippedTar(raw, pub)
|
||||
if err != nil {
|
||||
t.Fatalf("frozen signed baseline rejected: %v", err)
|
||||
}
|
||||
if verified.Manifest.RevisionID != "revision-legacy" || verified.Manifest.SpeakerID != "speaker-legacy" || len(verified.Files) != 2 {
|
||||
t.Fatalf("frozen baseline contents: %+v", verified.Manifest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyCompatibilityBundleWire(t *testing.T) {
|
||||
seed := bytes.Repeat([]byte{0x42}, ed25519.SeedSize)
|
||||
priv := ed25519.NewKeyFromSeed(seed)
|
||||
pub := priv.Public().(ed25519.PublicKey)
|
||||
fragments := map[string]string{
|
||||
"bird.conf": "router id 192.0.2.1;\n",
|
||||
"bird.d/evobgp_prefixes_v4.conf": "# revision\n",
|
||||
"_preview": "must not ship",
|
||||
}
|
||||
raw, err := BuildGzippedTar("revision-legacy", "speaker-legacy", fragments, priv)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gr, err := gzip.NewReader(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = gr.Close() }()
|
||||
tr := tar.NewReader(gr)
|
||||
entries := map[string][]byte{}
|
||||
var order []string
|
||||
for {
|
||||
h, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, err := io.ReadAll(tr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
order = append(order, h.Name)
|
||||
entries[h.Name] = body
|
||||
}
|
||||
wantOrder := []string{"manifest.json", "bundle.sig", "bird.conf", "bird.d/evobgp_prefixes_v4.conf"}
|
||||
if !reflect.DeepEqual(order, wantOrder) {
|
||||
t.Fatalf("legacy tar layout: got %v, want %v", order, wantOrder)
|
||||
}
|
||||
var top map[string]json.RawMessage
|
||||
if err := json.Unmarshal(entries["manifest.json"], &top); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantFields := []string{"created_at", "files", "public_key_base64", "revision_id", "signature_algorithm", "speaker_id"}
|
||||
fields := make([]string, 0, len(top))
|
||||
for k := range top {
|
||||
fields = append(fields, k)
|
||||
}
|
||||
sort.Strings(fields)
|
||||
if !reflect.DeepEqual(fields, wantFields) {
|
||||
t.Fatalf("manifest fields: got %v, want %v", fields, wantFields)
|
||||
}
|
||||
var m legacyManifestV1
|
||||
if err := json.Unmarshal(entries["manifest.json"], &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m.RevisionID != "revision-legacy" || m.SpeakerID != "speaker-legacy" || m.CreatedAt == "" || m.Algorithm != "ed25519" || m.PublicKeyBase64 != base64.StdEncoding.EncodeToString(pub) {
|
||||
t.Fatalf("legacy manifest fields: %+v", m)
|
||||
}
|
||||
if !ed25519.Verify(pub, entries["manifest.json"], entries["bundle.sig"]) {
|
||||
t.Fatal("legacy Ed25519 verifier rejected current signed manifest")
|
||||
}
|
||||
if len(m.Files) != 2 {
|
||||
t.Fatalf("legacy manifest file count: %d", len(m.Files))
|
||||
}
|
||||
for _, f := range m.Files {
|
||||
sum := sha256.Sum256(entries[f.Path])
|
||||
if hex.EncodeToString(sum[:]) != f.SHA256 {
|
||||
t.Fatalf("legacy SHA-256 mismatch for %q", f.Path)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package bundle
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ErrLimit reports that a transport, archive or extraction budget was exceeded.
|
||||
var ErrLimit = errors.New("bundle: resource limit exceeded")
|
||||
|
||||
// Limits bounds both archive transport and decoded tar data, including metadata.
|
||||
// Zero fields select defaults. These ceilings permit large (1M-prefix) configs;
|
||||
// they are safety ceilings, not a promise of suitability for a small-RAM node.
|
||||
type Limits struct {
|
||||
MaxCompressedBytes int64
|
||||
MaxExpandedBytes int64
|
||||
MaxFileBytes int64
|
||||
MaxManifestBytes int64
|
||||
MaxEntries int
|
||||
MaxPathBytes int
|
||||
}
|
||||
|
||||
// DefaultLimits permits large production configs while bounding untrusted input.
|
||||
func DefaultLimits() Limits {
|
||||
return Limits{MaxCompressedBytes: 128 << 20, MaxExpandedBytes: 512 << 20, MaxFileBytes: 256 << 20, MaxManifestBytes: 2 << 20, MaxEntries: 4096, MaxPathBytes: 1024}
|
||||
}
|
||||
|
||||
// Normalized fills zero fields with defaults and rejects invalid budgets.
|
||||
func (l Limits) Normalized() (Limits, error) {
|
||||
d := DefaultLimits()
|
||||
values := []struct {
|
||||
v *int64
|
||||
fallback int64
|
||||
}{{&l.MaxCompressedBytes, d.MaxCompressedBytes}, {&l.MaxExpandedBytes, d.MaxExpandedBytes}, {&l.MaxFileBytes, d.MaxFileBytes}, {&l.MaxManifestBytes, d.MaxManifestBytes}}
|
||||
for _, x := range values {
|
||||
if *x.v < 0 || *x.v == int64(^uint64(0)>>1) {
|
||||
return l, fmt.Errorf("bundle: invalid byte limit")
|
||||
}
|
||||
if *x.v == 0 {
|
||||
*x.v = x.fallback
|
||||
}
|
||||
}
|
||||
if l.MaxEntries < 0 || l.MaxPathBytes < 0 {
|
||||
return l, fmt.Errorf("bundle: invalid entry/path limit")
|
||||
}
|
||||
if l.MaxEntries == 0 {
|
||||
l.MaxEntries = d.MaxEntries
|
||||
}
|
||||
if l.MaxPathBytes == 0 {
|
||||
l.MaxPathBytes = d.MaxPathBytes
|
||||
}
|
||||
return l, nil
|
||||
}
|
||||
|
||||
func validatePath(name string, limit int) error {
|
||||
if name == "" || len(name) > limit || path.IsAbs(name) || path.Clean(name) != name || name == "." || strings.ContainsAny(name, "\\:\x00") {
|
||||
return fmt.Errorf("bundle: unsafe path %q", name)
|
||||
}
|
||||
for _, part := range strings.Split(name, "/") {
|
||||
if part == ".." || strings.TrimRight(part, " .") != part {
|
||||
return fmt.Errorf("bundle: unsafe path %q", name)
|
||||
}
|
||||
base := strings.ToUpper(strings.SplitN(part, ".", 2)[0])
|
||||
if base == "CON" || base == "PRN" || base == "AUX" || base == "NUL" || (len(base) == 4 && (strings.HasPrefix(base, "COM") || strings.HasPrefix(base, "LPT")) && base[3] >= '1' && base[3] <= '9') {
|
||||
return fmt.Errorf("bundle: reserved path %q", name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type limitedWriter struct {
|
||||
w io.Writer
|
||||
remaining int64
|
||||
}
|
||||
|
||||
func (w *limitedWriter) Write(p []byte) (int, error) {
|
||||
if int64(len(p)) > w.remaining {
|
||||
return 0, ErrLimit
|
||||
}
|
||||
n, err := w.w.Write(p)
|
||||
w.remaining -= int64(n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// ReadCompressed rejects oversized input while reading, including chunked HTTP
|
||||
// bodies and local files whose size changes after stat.
|
||||
func ReadCompressed(r io.Reader, limits Limits) ([]byte, error) {
|
||||
l, err := limits.Normalized()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b, err := io.ReadAll(io.LimitReader(r, l.MaxCompressedBytes+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if int64(len(b)) > l.MaxCompressedBytes {
|
||||
return nil, ErrLimit
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
package bundle
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"crypto/ed25519"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type testEntry struct {
|
||||
header tar.Header
|
||||
body []byte
|
||||
}
|
||||
|
||||
func archiveEntries(t *testing.T, entries []testEntry) []byte {
|
||||
t.Helper()
|
||||
var b bytes.Buffer
|
||||
gz := gzip.NewWriter(&b)
|
||||
tw := tar.NewWriter(gz)
|
||||
for _, entry := range entries {
|
||||
h := entry.header
|
||||
if h.Typeflag == 0 {
|
||||
h.Typeflag = tar.TypeReg
|
||||
}
|
||||
h.Size = int64(len(entry.body))
|
||||
if err := tw.WriteHeader(&h); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := tw.Write(entry.body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := tw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := gz.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return b.Bytes()
|
||||
}
|
||||
func testKey() ed25519.PrivateKey {
|
||||
return ed25519.NewKeyFromSeed(bytes.Repeat([]byte{7}, ed25519.SeedSize))
|
||||
}
|
||||
|
||||
func TestBundleResourceLimits(t *testing.T) {
|
||||
pub := testKey().Public().(ed25519.PublicKey)
|
||||
cases := []struct {
|
||||
name string
|
||||
entries []testEntry
|
||||
limits Limits
|
||||
}{
|
||||
{"file", []testEntry{{header: tar.Header{Name: "bird.conf"}, body: bytes.Repeat([]byte{'a'}, 1025)}}, Limits{MaxFileBytes: 1024}},
|
||||
{"expanded", []testEntry{{header: tar.Header{Name: "bird.conf"}, body: bytes.Repeat([]byte{'a'}, 8192)}}, Limits{MaxExpandedBytes: 4096}},
|
||||
{"manifest", []testEntry{{header: tar.Header{Name: manifestName}, body: bytes.Repeat([]byte{'a'}, 1025)}}, Limits{MaxManifestBytes: 1024}},
|
||||
{"entries", []testEntry{{header: tar.Header{Name: "a"}}, {header: tar.Header{Name: "b"}}, {header: tar.Header{Name: "c"}}}, Limits{MaxEntries: 2}},
|
||||
{"pax-metadata", []testEntry{{header: tar.Header{Name: "bird.conf", Format: tar.FormatPAX, PAXRecords: map[string]string{"comment": strings.Repeat("a", 8192)}}}}, Limits{MaxExpandedBytes: 4096}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
raw := archiveEntries(t, tc.entries)
|
||||
if _, err := VerifyGzippedTarWithLimits(raw, pub, tc.limits); !errors.Is(err, ErrLimit) {
|
||||
t.Fatalf("want limit, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
if _, err := VerifyGzippedTarWithLimits(make([]byte, 65), pub, Limits{MaxCompressedBytes: 64}); !errors.Is(err, ErrLimit) {
|
||||
t.Fatalf("compressed cap: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBundleRejectsUnsafeEntries(t *testing.T) {
|
||||
pub := testKey().Public().(ed25519.PublicKey)
|
||||
for _, name := range []string{"../escape", "/etc/bird.conf", `bird.d\escape`, "C:/escape", "bird.d/../escape", "a//b", "a/./b", "NUL", "bird.conf:stream", "a. /b"} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
raw := archiveEntries(t, []testEntry{{header: tar.Header{Name: name}, body: []byte("x")}})
|
||||
if _, err := VerifyGzippedTar(raw, pub); err == nil {
|
||||
t.Fatal("unsafe path accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
for _, kind := range []byte{tar.TypeSymlink, tar.TypeLink, tar.TypeDir, tar.TypeFifo} {
|
||||
raw := archiveEntries(t, []testEntry{{header: tar.Header{Name: "bird.conf", Typeflag: kind, Linkname: "outside"}}})
|
||||
if _, err := VerifyGzippedTar(raw, pub); err == nil {
|
||||
t.Fatalf("type %d accepted", kind)
|
||||
}
|
||||
}
|
||||
raw := archiveEntries(t, []testEntry{{header: tar.Header{Name: "bird.conf"}}, {header: tar.Header{Name: "bird.conf"}}})
|
||||
if _, err := VerifyGzippedTar(raw, pub); err == nil || !strings.Contains(err.Error(), "duplicate") {
|
||||
t.Fatalf("duplicate accepted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBundleChecksTrailerAndTrailingExpansion(t *testing.T) {
|
||||
priv := testKey()
|
||||
pub := priv.Public().(ed25519.PublicKey)
|
||||
raw, err := BuildGzippedTar("r", "s", map[string]string{"bird.conf": "router id 192.0.2.1;"}, priv)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
corrupt := append([]byte{}, raw...)
|
||||
corrupt[len(corrupt)-8] ^= 0xff
|
||||
if _, err = VerifyGzippedTar(corrupt, pub); err == nil {
|
||||
t.Fatal("gzip checksum ignored after tar EOF")
|
||||
}
|
||||
var extra bytes.Buffer
|
||||
gz := gzip.NewWriter(&extra)
|
||||
if _, err = gz.Write(bytes.Repeat([]byte{'x'}, 32<<10)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = gz.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
joined := append(append([]byte{}, raw...), extra.Bytes()...)
|
||||
if _, err = VerifyGzippedTarWithLimits(joined, pub, Limits{MaxExpandedBytes: 8 << 10}); !errors.Is(err, ErrLimit) {
|
||||
t.Fatalf("trailing gzip member bypassed budget: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBundleRejectsPAXSparseRegularFile(t *testing.T) {
|
||||
// archive/tar expands PAX sparse holes even though the returned type is a
|
||||
// regular file. Build a physical one-byte entry with a 4096-byte logical size.
|
||||
record := func(key, value string) string {
|
||||
body := key + "=" + value + "\n"
|
||||
n := len(body) + 2
|
||||
for {
|
||||
next := len(fmt.Sprint(n)) + 1 + len(body)
|
||||
if next == n {
|
||||
return fmt.Sprintf("%d %s", n, body)
|
||||
}
|
||||
n = next
|
||||
}
|
||||
}
|
||||
pax := record("GNU.sparse.size", "4096") + record("GNU.sparse.numblocks", "1") + record("GNU.sparse.map", "0,1")
|
||||
var physical bytes.Buffer
|
||||
tw := tar.NewWriter(&physical)
|
||||
for _, entry := range []testEntry{{header: tar.Header{Name: "PaxHeader", Size: int64(len(pax))}, body: []byte(pax)}, {header: tar.Header{Name: "bird.conf", Size: 1}, body: []byte("x")}} {
|
||||
if err := tw.WriteHeader(&entry.header); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := tw.Write(entry.body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := tw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data := physical.Bytes()
|
||||
data[156] = tar.TypeXHeader
|
||||
for i := 148; i < 156; i++ {
|
||||
data[i] = ' '
|
||||
}
|
||||
sum := 0
|
||||
for _, b := range data[:512] {
|
||||
sum += int(b)
|
||||
}
|
||||
copy(data[148:156], fmt.Sprintf("%06o\x00 ", sum))
|
||||
h, err := tar.NewReader(bytes.NewReader(data)).Next()
|
||||
if err != nil || h.Typeflag != tar.TypeReg || h.Size != 4096 {
|
||||
t.Fatalf("invalid sparse regression fixture %+v %v", h, err)
|
||||
}
|
||||
var compressed bytes.Buffer
|
||||
gz := gzip.NewWriter(&compressed)
|
||||
if _, err = gz.Write(data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = gz.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = VerifyGzippedTar(compressed.Bytes(), testKey().Public().(ed25519.PublicKey)); err == nil || !strings.Contains(err.Error(), "sparse") {
|
||||
t.Fatalf("PAX sparse accepted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBundleRejectsUnmanifestedPayload(t *testing.T) {
|
||||
priv := testKey()
|
||||
raw, err := BuildGzippedTar("r", "s", map[string]string{"bird.conf": "x"}, priv)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gr, err := gzip.NewReader(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = gr.Close() }()
|
||||
tr := tar.NewReader(gr)
|
||||
var entries []testEntry
|
||||
for {
|
||||
h, e := tr.Next()
|
||||
if e == io.EOF {
|
||||
break
|
||||
}
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
b, e := io.ReadAll(tr)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
entries = append(entries, testEntry{header: *h, body: b})
|
||||
}
|
||||
entries = append(entries, testEntry{header: tar.Header{Name: "unsigned.conf"}, body: []byte("unsigned")})
|
||||
if _, err = VerifyGzippedTar(archiveEntries(t, entries), priv.Public().(ed25519.PublicKey)); err == nil {
|
||||
t.Fatal("unmanifested payload accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractionDoesNotFollowLinks(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
outside := filepath.Join(t.TempDir(), "outside")
|
||||
if err := os.WriteFile(outside, []byte("preserve"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dest := filepath.Join(root, "bird.conf")
|
||||
if err := os.Link(outside, dest); err != nil {
|
||||
if runtime.GOOS == "linux" {
|
||||
t.Fatalf("Linux link regression requires hard links: %v", err)
|
||||
}
|
||||
t.Skipf("hard links unavailable: %v", err)
|
||||
}
|
||||
v := &VerifiedContents{Manifest: Manifest{Files: []FileEntry{{Path: "bird.conf"}}}, Files: map[string][]byte{"bird.conf": []byte("replacement")}}
|
||||
if err := WriteExtractedFiles(root, v); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := os.ReadFile(outside)
|
||||
if err != nil || string(got) != "preserve" {
|
||||
t.Fatalf("hard link modified outside: %s %v", got, err)
|
||||
}
|
||||
if err = os.Remove(dest); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = os.Symlink(outside, dest); err != nil {
|
||||
if runtime.GOOS == "linux" {
|
||||
t.Fatalf("Linux link regression requires symlinks: %v", err)
|
||||
}
|
||||
t.Skipf("symlinks unavailable: %v", err)
|
||||
}
|
||||
if err = WriteExtractedFiles(root, v); err == nil {
|
||||
t.Fatal("symlink destination accepted")
|
||||
}
|
||||
got, err = os.ReadFile(outside)
|
||||
if err != nil || string(got) != "preserve" {
|
||||
t.Fatal("symlink modified outside")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBundleBuilderBoundsAndPortablePaths(t *testing.T) {
|
||||
for _, name := range []string{"/bird.conf", "a/../bird.conf", manifestName, sigFileName, `bird.d\bad`} {
|
||||
if _, err := BuildGzippedTar("r", "s", map[string]string{name: "x"}, testKey()); err == nil {
|
||||
t.Fatalf("invalid builder path accepted: %q", name)
|
||||
}
|
||||
}
|
||||
if _, err := BuildGzippedTarWithLimits("r", "s", map[string]string{"bird.conf": strings.Repeat("x", 1025)}, testKey(), Limits{MaxFileBytes: 1024}); !errors.Is(err, ErrLimit) {
|
||||
t.Fatalf("builder file cap %v", err)
|
||||
}
|
||||
if _, err := BuildGzippedTarWithLimits("r", "s", map[string]string{"bird.conf": "x"}, testKey(), Limits{MaxExpandedBytes: 1024}); !errors.Is(err, ErrLimit) {
|
||||
t.Fatalf("builder tar overhead cap %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// This is a decoded-size regression using repeated representative route lines,
|
||||
// not a unique-prefix cardinality test or a BIRD validity/performance benchmark.
|
||||
func TestDefaultLimitsAllowMillionRepresentativeIPv6RouteLines(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("large representative config")
|
||||
}
|
||||
config := strings.Repeat("route 2001:db8:1234:5678::/64 blackhole;\n", 1_000_000)
|
||||
raw, err := BuildGzippedTar("large", "speaker", map[string]string{"bird.conf": config}, testKey())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
v, err := VerifyGzippedTar(raw, testKey().Public().(ed25519.PublicKey))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(v.Files["bird.conf"]) != len(config) {
|
||||
t.Fatal("large config truncated")
|
||||
}
|
||||
}
|
||||
+35
-8
@@ -9,7 +9,6 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -36,22 +35,47 @@ const manifestName = "manifest.json"
|
||||
|
||||
// BuildGzippedTar builds a .tar.gz with manifest.json, bundle.sig (Ed25519 over manifest JSON), and payload files.
|
||||
func BuildGzippedTar(revisionID, speakerID string, fragments map[string]string, priv ed25519.PrivateKey) ([]byte, error) {
|
||||
return BuildGzippedTarWithLimits(revisionID, speakerID, fragments, priv, Limits{})
|
||||
}
|
||||
|
||||
// BuildGzippedTarWithLimits preserves the manifest/signature wire format while
|
||||
// refusing payloads that exceed the receiving node's configured budgets.
|
||||
func BuildGzippedTarWithLimits(revisionID, speakerID string, fragments map[string]string, priv ed25519.PrivateKey, limits Limits) ([]byte, error) {
|
||||
l, err := limits.Normalized()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(priv) != ed25519.PrivateKeySize {
|
||||
return nil, fmt.Errorf("bundle: invalid ed25519 private key size")
|
||||
}
|
||||
pub := priv.Public().(ed25519.PublicKey)
|
||||
|
||||
norm := make(map[string]string, len(fragments))
|
||||
norm := make(map[string]string)
|
||||
seen := map[string]bool{}
|
||||
var total int64
|
||||
for p, content := range fragments {
|
||||
p = path.Clean(strings.TrimPrefix(p, "/"))
|
||||
if p == "." || strings.HasPrefix(p, "..") {
|
||||
return nil, fmt.Errorf("bundle: invalid path %q", p)
|
||||
}
|
||||
if strings.HasPrefix(p, "_") {
|
||||
continue // UI-only preview aggregate, not shipped to nodes
|
||||
}
|
||||
if len(norm) >= l.MaxEntries-2 {
|
||||
return nil, ErrLimit
|
||||
}
|
||||
if err := validatePath(p, l.MaxPathBytes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if p == manifestName || p == sigFileName || seen[strings.ToLower(p)] {
|
||||
return nil, fmt.Errorf("bundle: duplicate or reserved path %q", p)
|
||||
}
|
||||
seen[strings.ToLower(p)] = true
|
||||
if int64(len(content)) > l.MaxFileBytes || int64(len(content)) > l.MaxExpandedBytes-total {
|
||||
return nil, ErrLimit
|
||||
}
|
||||
total += int64(len(content))
|
||||
norm[p] = content
|
||||
}
|
||||
if len(norm) > l.MaxEntries-2 {
|
||||
return nil, ErrLimit
|
||||
}
|
||||
|
||||
var files []FileEntry
|
||||
var payload [][]byte
|
||||
@@ -75,11 +99,14 @@ func BuildGzippedTar(revisionID, speakerID string, fragments map[string]string,
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if int64(len(manifestJSON)) > l.MaxManifestBytes {
|
||||
return nil, ErrLimit
|
||||
}
|
||||
sig := ed25519.Sign(priv, manifestJSON)
|
||||
|
||||
var buf bytes.Buffer
|
||||
gw := gzip.NewWriter(&buf)
|
||||
tw := tar.NewWriter(gw)
|
||||
gw := gzip.NewWriter(&limitedWriter{w: &buf, remaining: l.MaxCompressedBytes})
|
||||
tw := tar.NewWriter(&limitedWriter{w: gw, remaining: l.MaxExpandedBytes})
|
||||
|
||||
add := func(name string, body []byte) error {
|
||||
hdr := &tar.Header{
|
||||
|
||||
Binary file not shown.
+92
-18
@@ -11,44 +11,95 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// VerifiedContents is the result of verifying a downloaded bundle on evobgp-node.
|
||||
// VerifiedContents contains only payloads covered by a verified signed manifest.
|
||||
type VerifiedContents struct {
|
||||
Manifest Manifest
|
||||
Files map[string][]byte
|
||||
}
|
||||
|
||||
// VerifyGzippedTar checks Ed25519 signature over manifest.json and SHA-256 of each file.
|
||||
func VerifyGzippedTar(bundle []byte, pub ed25519.PublicKey) (*VerifiedContents, error) {
|
||||
gr, err := gzip.NewReader(bytes.NewReader(bundle))
|
||||
// VerifyGzippedTar verifies the unchanged signed manifest format with default limits.
|
||||
func VerifyGzippedTar(raw []byte, pub ed25519.PublicKey) (*VerifiedContents, error) {
|
||||
return VerifyGzippedTarWithLimits(raw, pub, Limits{})
|
||||
}
|
||||
|
||||
// VerifyGzippedTarWithLimits verifies the signature and hashes under explicit
|
||||
// compressed, expanded, per-entry and metadata budgets.
|
||||
func VerifyGzippedTarWithLimits(raw []byte, pub ed25519.PublicKey, limits Limits) (*VerifiedContents, error) {
|
||||
l, err := limits.Normalized()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if int64(len(raw)) > l.MaxCompressedBytes {
|
||||
return nil, ErrLimit
|
||||
}
|
||||
if len(pub) != ed25519.PublicKeySize {
|
||||
return nil, fmt.Errorf("bundle: invalid public key size")
|
||||
}
|
||||
gr, err := gzip.NewReader(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = gr.Close() }()
|
||||
|
||||
var manifestRaw []byte
|
||||
var sig []byte
|
||||
files := make(map[string][]byte)
|
||||
tr := tar.NewReader(gr)
|
||||
decoded := &io.LimitedReader{R: gr, N: l.MaxExpandedBytes + 1}
|
||||
tr := tar.NewReader(decoded)
|
||||
var manifestRaw, sig []byte
|
||||
files := map[string][]byte{}
|
||||
seen := map[string]bool{}
|
||||
count := 0
|
||||
var logicalBytes int64
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if decoded.N == 0 {
|
||||
return nil, ErrLimit
|
||||
}
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
name := path.Clean(hdr.Name)
|
||||
if strings.HasPrefix(name, "..") {
|
||||
return nil, fmt.Errorf("bundle: illegal tar entry %q", hdr.Name)
|
||||
count++
|
||||
if count > l.MaxEntries {
|
||||
return nil, ErrLimit
|
||||
}
|
||||
data, err := io.ReadAll(tr)
|
||||
if err = validatePath(hdr.Name, l.MaxPathBytes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
name := hdr.Name
|
||||
folded := strings.ToLower(name)
|
||||
if seen[folded] {
|
||||
return nil, fmt.Errorf("bundle: duplicate tar entry %q", name)
|
||||
}
|
||||
seen[folded] = true
|
||||
if hdr.Typeflag != tar.TypeReg && hdr.Typeflag != tar.TypeRegA {
|
||||
return nil, fmt.Errorf("bundle: nonregular tar entry %q", name)
|
||||
}
|
||||
for key := range hdr.PAXRecords {
|
||||
if strings.HasPrefix(key, "GNU.sparse.") {
|
||||
return nil, fmt.Errorf("bundle: sparse entry %q is unsupported", name)
|
||||
}
|
||||
}
|
||||
max := l.MaxFileBytes
|
||||
if name == manifestName {
|
||||
max = l.MaxManifestBytes
|
||||
}
|
||||
if name == sigFileName {
|
||||
max = ed25519.SignatureSize
|
||||
}
|
||||
if hdr.Size < 0 || hdr.Size > max || hdr.Size >= decoded.N || hdr.Size > l.MaxExpandedBytes-logicalBytes {
|
||||
return nil, ErrLimit
|
||||
}
|
||||
logicalBytes += hdr.Size
|
||||
data, err := io.ReadAll(io.LimitReader(tr, max+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if int64(len(data)) > max || decoded.N == 0 {
|
||||
return nil, ErrLimit
|
||||
}
|
||||
switch name {
|
||||
case manifestName:
|
||||
manifestRaw = data
|
||||
@@ -58,17 +109,36 @@ func VerifyGzippedTar(bundle []byte, pub ed25519.PublicKey) (*VerifiedContents,
|
||||
files[name] = data
|
||||
}
|
||||
}
|
||||
if len(manifestRaw) == 0 || len(sig) == 0 {
|
||||
// tar EOF precedes the gzip trailer. Drain through the same byte budget so
|
||||
// checksums, extra gzip members and trailing decompression are also bounded.
|
||||
if _, err = io.Copy(io.Discard, decoded); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if decoded.N == 0 {
|
||||
return nil, ErrLimit
|
||||
}
|
||||
if len(manifestRaw) == 0 || len(sig) != ed25519.SignatureSize {
|
||||
return nil, fmt.Errorf("bundle: missing manifest or signature")
|
||||
}
|
||||
if !ed25519.Verify(pub, manifestRaw, sig) {
|
||||
return nil, fmt.Errorf("bundle: ed25519 signature mismatch")
|
||||
}
|
||||
var m Manifest
|
||||
if err := json.Unmarshal(manifestRaw, &m); err != nil {
|
||||
if err = json.Unmarshal(manifestRaw, &m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(m.Files) > l.MaxEntries-2 {
|
||||
return nil, ErrLimit
|
||||
}
|
||||
listed := map[string]bool{}
|
||||
for _, fe := range m.Files {
|
||||
if err = validatePath(fe.Path, l.MaxPathBytes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if fe.Path == manifestName || fe.Path == sigFileName || listed[strings.ToLower(fe.Path)] {
|
||||
return nil, fmt.Errorf("bundle: duplicate or reserved manifest path %q", fe.Path)
|
||||
}
|
||||
listed[strings.ToLower(fe.Path)] = true
|
||||
body, ok := files[fe.Path]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("bundle: missing file %q", fe.Path)
|
||||
@@ -78,10 +148,13 @@ func VerifyGzippedTar(bundle []byte, pub ed25519.PublicKey) (*VerifiedContents,
|
||||
return nil, fmt.Errorf("bundle: checksum mismatch for %q", fe.Path)
|
||||
}
|
||||
}
|
||||
if len(listed) != len(files) {
|
||||
return nil, fmt.Errorf("bundle: payload is not covered by manifest")
|
||||
}
|
||||
return &VerifiedContents{Manifest: m, Files: files}, nil
|
||||
}
|
||||
|
||||
// ParsePublicKeyHex decodes a 64-char hex Ed25519 public key (32 bytes).
|
||||
// ParsePublicKeyHex decodes a 32-byte Ed25519 public key from hexadecimal.
|
||||
func ParsePublicKeyHex(s string) (ed25519.PublicKey, error) {
|
||||
s = strings.TrimSpace(strings.TrimPrefix(strings.TrimPrefix(s, "0x"), "0X"))
|
||||
raw, err := hex.DecodeString(s)
|
||||
@@ -94,7 +167,8 @@ func ParsePublicKeyHex(s string) (ed25519.PublicKey, error) {
|
||||
return ed25519.PublicKey(raw), nil
|
||||
}
|
||||
|
||||
// PublicKeyFromManifestBase64 uses the key embedded in the signed manifest (cross-check with expected pubkey optional).
|
||||
// PublicKeyFromManifestBase64 decodes the manifest key. Callers must still trust
|
||||
// an independently provisioned key when authenticating the bundle.
|
||||
func PublicKeyFromManifestBase64(m *Manifest) (ed25519.PublicKey, error) {
|
||||
if m.PublicKeyB64 == "" {
|
||||
return nil, fmt.Errorf("bundle: manifest missing public_key_base64")
|
||||
|
||||
+267
-23
@@ -8,48 +8,292 @@ import (
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// ApplyPostgresMigrations runs ordered *.up.sql from an embedded subtree (idempotent via schema_migrations).
|
||||
func ApplyPostgresMigrations(ctx context.Context, pool *pgxpool.Pool, fsys fs.FS, subdir string) error {
|
||||
if _, err := pool.Exec(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations (version TEXT NOT NULL PRIMARY KEY)`); err != nil {
|
||||
return fmt.Errorf("db: schema_migrations: %w", err)
|
||||
}
|
||||
const (
|
||||
postgresMigrationLockKey int64 = 0x45766f4247504d47 // "EvoBGPMG", scoped to the database.
|
||||
migrationLockTimeout = 30 * time.Second
|
||||
migrationCleanupTimeout = 5 * time.Second
|
||||
)
|
||||
|
||||
// PostgreSQL migrations are transaction-only. Non-transactional statements
|
||||
// need an explicit, separately reviewed execution path.
|
||||
|
||||
// ApplyPostgresMigrations runs ordered *.up.sql from an embedded subtree.
|
||||
// Each migration and its version row commit atomically under a session advisory lock.
|
||||
func ApplyPostgresMigrations(ctx context.Context, pool *pgxpool.Pool, fsys fs.FS, subdir string) (retErr error) {
|
||||
entries, err := fs.ReadDir(fsys, subdir)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("db: read migrations: %w", err)
|
||||
}
|
||||
var ups []string
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".up.sql") {
|
||||
continue
|
||||
if !e.IsDir() && strings.HasSuffix(e.Name(), ".up.sql") {
|
||||
ups = append(ups, e.Name())
|
||||
}
|
||||
ups = append(ups, e.Name())
|
||||
}
|
||||
sort.Strings(ups)
|
||||
// Include pool acquisition in the lock timeout. The lock precedes even
|
||||
// schema_migrations creation, because another process may start from an empty DB.
|
||||
lockCtx, cancelLock := context.WithTimeout(ctx, migrationLockTimeout)
|
||||
conn, err := pool.Acquire(lockCtx)
|
||||
if err != nil {
|
||||
cancelLock()
|
||||
return fmt.Errorf("db: acquire migration connection: %w", err)
|
||||
}
|
||||
defer conn.Release()
|
||||
if _, err := conn.Exec(lockCtx, `SELECT pg_advisory_lock($1)`, postgresMigrationLockKey); err != nil {
|
||||
cancelLock()
|
||||
// Cancellation may race with a successful server-side lock acquisition.
|
||||
closeCtx, closeCancel := context.WithTimeout(context.Background(), migrationCleanupTimeout)
|
||||
defer closeCancel()
|
||||
if closeErr := conn.Conn().Close(closeCtx); closeErr != nil {
|
||||
return errors.Join(fmt.Errorf("db: acquire migration lock: %w", err), fmt.Errorf("db: close migration connection: %w", closeErr))
|
||||
}
|
||||
return fmt.Errorf("db: acquire migration lock: %w", err)
|
||||
}
|
||||
cancelLock()
|
||||
defer func() {
|
||||
cleanupCtx, cancel := context.WithTimeout(context.Background(), migrationCleanupTimeout)
|
||||
defer cancel()
|
||||
var unlocked bool
|
||||
if err := conn.QueryRow(cleanupCtx, `SELECT pg_advisory_unlock($1)`, postgresMigrationLockKey).Scan(&unlocked); err != nil || !unlocked {
|
||||
if err == nil {
|
||||
err = errors.New("lock was not held")
|
||||
}
|
||||
retErr = errors.Join(retErr, fmt.Errorf("db: release migration lock: %w", err))
|
||||
// Never return a possibly locked session to the pool.
|
||||
closeCtx, closeCancel := context.WithTimeout(context.Background(), migrationCleanupTimeout)
|
||||
defer closeCancel()
|
||||
if closeErr := conn.Conn().Close(closeCtx); closeErr != nil {
|
||||
retErr = errors.Join(retErr, fmt.Errorf("db: close migration connection: %w", closeErr))
|
||||
}
|
||||
}
|
||||
}()
|
||||
if _, err := conn.Exec(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations (version TEXT NOT NULL PRIMARY KEY)`); err != nil {
|
||||
return fmt.Errorf("db: schema_migrations: %w", err)
|
||||
}
|
||||
for _, name := range ups {
|
||||
ver := strings.TrimSuffix(name, ".up.sql")
|
||||
var dummy int
|
||||
err := pool.QueryRow(ctx, `SELECT 1 FROM schema_migrations WHERE version = $1`, ver).Scan(&dummy)
|
||||
if err == nil {
|
||||
continue
|
||||
}
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return err
|
||||
}
|
||||
body, err := fs.ReadFile(fsys, path.Join(subdir, name))
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("db: read migration %s: %w", name, err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, string(body)); err != nil {
|
||||
if bad := unsupportedMigrationStatement(string(body)); bad != "" {
|
||||
return fmt.Errorf("db: migration %s uses unsupported non-transactional SQL %q", name, bad)
|
||||
}
|
||||
if err := applyPostgresMigration(ctx, conn, strings.TrimSuffix(name, ".up.sql"), body); err != nil {
|
||||
return fmt.Errorf("db: migrate %s: %w", name, err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `INSERT INTO schema_migrations (version) VALUES ($1)`, ver); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// unsupportedMigrationStatement inspects statement starts after masking SQL
|
||||
// comments and quoted strings. In particular, a comment after a semicolon
|
||||
// must not conceal COMMIT or another command that would escape our transaction.
|
||||
func unsupportedMigrationStatement(sql string) string {
|
||||
const (
|
||||
normal = iota
|
||||
singleQuote
|
||||
doubleQuote
|
||||
lineComment
|
||||
blockComment
|
||||
dollarQuote
|
||||
)
|
||||
state := normal
|
||||
blockDepth := 0
|
||||
dollarTag := ""
|
||||
escapeString := false
|
||||
var clean strings.Builder
|
||||
for i := 0; i < len(sql); {
|
||||
switch state {
|
||||
case normal:
|
||||
switch {
|
||||
case sql[i] == '\'':
|
||||
escapeString = i > 0 && (sql[i-1] == 'E' || sql[i-1] == 'e') && (i == 1 || !isSQLIdentifierByte(sql[i-2]))
|
||||
state = singleQuote
|
||||
clean.WriteByte(' ')
|
||||
i++
|
||||
case sql[i] == '"':
|
||||
state = doubleQuote
|
||||
clean.WriteByte(' ')
|
||||
i++
|
||||
case strings.HasPrefix(sql[i:], "--"):
|
||||
state = lineComment
|
||||
clean.WriteByte(' ')
|
||||
i += 2
|
||||
case strings.HasPrefix(sql[i:], "/*"):
|
||||
state = blockComment
|
||||
blockDepth = 1
|
||||
clean.WriteByte(' ')
|
||||
i += 2
|
||||
case sql[i] == '$' && (i == 0 || !isSQLIdentifierByte(sql[i-1])):
|
||||
end := i + 1
|
||||
if end < len(sql) && isDollarTagStartByte(sql[end]) {
|
||||
end++
|
||||
for end < len(sql) && isDollarTagByte(sql[end]) {
|
||||
end++
|
||||
}
|
||||
}
|
||||
if end < len(sql) && sql[end] == '$' {
|
||||
dollarTag = sql[i : end+1]
|
||||
state = dollarQuote
|
||||
clean.WriteByte(' ')
|
||||
i = end + 1
|
||||
} else {
|
||||
clean.WriteByte(sql[i])
|
||||
i++
|
||||
}
|
||||
default:
|
||||
clean.WriteByte(sql[i])
|
||||
i++
|
||||
}
|
||||
case singleQuote:
|
||||
switch {
|
||||
case escapeString && sql[i] == '\\' && i+1 < len(sql):
|
||||
i += 2
|
||||
case sql[i] == '\'' && i+1 < len(sql) && sql[i+1] == '\'':
|
||||
i += 2
|
||||
case sql[i] == '\'':
|
||||
state = normal
|
||||
i++
|
||||
default:
|
||||
i++
|
||||
}
|
||||
case doubleQuote:
|
||||
if sql[i] == '"' {
|
||||
if i+1 < len(sql) && sql[i+1] == '"' {
|
||||
i += 2
|
||||
} else {
|
||||
state = normal
|
||||
i++
|
||||
}
|
||||
} else {
|
||||
i++
|
||||
}
|
||||
case lineComment:
|
||||
if sql[i] == '\n' || sql[i] == '\r' {
|
||||
clean.WriteByte('\n')
|
||||
state = normal
|
||||
}
|
||||
i++
|
||||
case blockComment:
|
||||
switch {
|
||||
case strings.HasPrefix(sql[i:], "/*"):
|
||||
blockDepth++
|
||||
i += 2
|
||||
case strings.HasPrefix(sql[i:], "*/"):
|
||||
blockDepth--
|
||||
i += 2
|
||||
if blockDepth == 0 {
|
||||
state = normal
|
||||
}
|
||||
default:
|
||||
i++
|
||||
}
|
||||
case dollarQuote:
|
||||
if strings.HasPrefix(sql[i:], dollarTag) {
|
||||
i += len(dollarTag)
|
||||
state = normal
|
||||
} else {
|
||||
i++
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, statement := range strings.Split(clean.String(), ";") {
|
||||
words := strings.Fields(strings.ToUpper(statement))
|
||||
if isUnsupportedMigrationCommand(words) {
|
||||
return strings.Join(words[:min(len(words), 4)], " ")
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func isSQLIdentifierByte(b byte) bool {
|
||||
return isDollarTagByte(b) || b == '$'
|
||||
}
|
||||
|
||||
func isDollarTagStartByte(b byte) bool {
|
||||
return b >= 'a' && b <= 'z' || b >= 'A' && b <= 'Z' || b == '_' || b >= 0x80
|
||||
}
|
||||
|
||||
func isDollarTagByte(b byte) bool {
|
||||
return isDollarTagStartByte(b) || b >= '0' && b <= '9'
|
||||
}
|
||||
|
||||
func isUnsupportedMigrationCommand(words []string) bool {
|
||||
if len(words) == 0 {
|
||||
return false
|
||||
}
|
||||
second := ""
|
||||
if len(words) > 1 {
|
||||
second = words[1]
|
||||
}
|
||||
switch words[0] {
|
||||
case "BEGIN", "COMMIT", "END", "ROLLBACK", "ABORT", "SAVEPOINT", "REINDEX", "VACUUM", "CLUSTER":
|
||||
return true
|
||||
case "START", "PREPARE":
|
||||
return second == "TRANSACTION"
|
||||
case "RELEASE":
|
||||
return second == "SAVEPOINT" || second != ""
|
||||
case "ALTER":
|
||||
return second == "SYSTEM"
|
||||
case "CREATE":
|
||||
if second == "DATABASE" || second == "TABLESPACE" {
|
||||
return true
|
||||
}
|
||||
idx := 2
|
||||
if second == "UNIQUE" {
|
||||
idx = 3
|
||||
}
|
||||
return len(words) > idx && words[idx-1] == "INDEX" && words[idx] == "CONCURRENTLY"
|
||||
case "DROP":
|
||||
return second == "DATABASE" || second == "TABLESPACE" || second == "INDEX" && len(words) > 2 && words[2] == "CONCURRENTLY"
|
||||
case "REFRESH":
|
||||
return len(words) > 3 && second == "MATERIALIZED" && words[2] == "VIEW" && words[3] == "CONCURRENTLY"
|
||||
case "DISCARD":
|
||||
return second == "ALL"
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func applyPostgresMigration(ctx context.Context, conn *pgxpool.Conn, version string, body []byte) (retErr error) {
|
||||
tx, err := conn.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
committed := false
|
||||
defer func() {
|
||||
if committed {
|
||||
return
|
||||
}
|
||||
cleanupCtx, cancel := context.WithTimeout(context.Background(), migrationCleanupTimeout)
|
||||
defer cancel()
|
||||
if err := tx.Rollback(cleanupCtx); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
||||
retErr = errors.Join(retErr, fmt.Errorf("db: rollback migration %s: %w", version, err))
|
||||
}
|
||||
}()
|
||||
var present int
|
||||
err = tx.QueryRow(ctx, `SELECT 1 FROM schema_migrations WHERE version = $1`, version).Scan(&present)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, string(body)); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO schema_migrations (version) VALUES ($1)`, version); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
committed = true
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"path"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"evobgp/migrations"
|
||||
)
|
||||
|
||||
func TestPostgresEmbeddedMigrationsTransactional(t *testing.T) {
|
||||
entries, err := fs.ReadDir(migrations.Postgres, "postgres")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if !strings.HasSuffix(entry.Name(), ".up.sql") {
|
||||
continue
|
||||
}
|
||||
body, err := fs.ReadFile(migrations.Postgres, path.Join("postgres", entry.Name()))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bad := unsupportedMigrationStatement(string(body)); bad != "" {
|
||||
t.Errorf("%s contains unsupported transaction control or DDL: %q", entry.Name(), bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresMigrationRejectsTransactionControl(t *testing.T) {
|
||||
for _, statement := range []string{
|
||||
"COMMIT;", "END;", "ROLLBACK;", "ABORT;", "BEGIN;",
|
||||
"CREATE INDEX CONCURRENTLY idx ON t (id);", "VACUUM t;",
|
||||
"CREATE TABLE t(id int); /* boundary */ COMMIT; SELECT 1/0;",
|
||||
"/* comment */COMMIT;",
|
||||
"-- comment\nCOMMIT;",
|
||||
"/* nested /* comment */ still comment */ END;",
|
||||
"CREATE TABLE foo$tag$ (id int); COMMIT; -- $tag$",
|
||||
"-- comment\rCOMMIT;",
|
||||
`foo$E'abc\'; COMMIT;`,
|
||||
`éE'abc\'; COMMIT;`,
|
||||
} {
|
||||
if unsupportedMigrationStatement(statement) == "" {
|
||||
t.Errorf("did not reject %q", statement)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresMigrationIgnoresQuotedControlWords(t *testing.T) {
|
||||
for _, statement := range []string{
|
||||
"SELECT 'COMMIT;'::text;",
|
||||
"SELECT $$COMMIT;$$;",
|
||||
"SELECT $tag$END;$tag$;",
|
||||
"SELECT E'ABORT;\\'';",
|
||||
"SELECT \"ROLLBACK\" FROM t;",
|
||||
"-- COMMIT;\nSELECT 1;",
|
||||
} {
|
||||
if bad := unsupportedMigrationStatement(statement); bad != "" {
|
||||
t.Errorf("rejected quoted or commented SQL %q: %s", statement, bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
+24
-11
@@ -25,17 +25,7 @@ func OpenPostgresPool(ctx context.Context, dsn string) (*pgxpool.Pool, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if max := os.Getenv("EVOBGP_DB_MAX_CONNS"); max != "" {
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(max)); err == nil && n > 0 {
|
||||
cfg.MaxConns = int32(n)
|
||||
}
|
||||
}
|
||||
if min := os.Getenv("EVOBGP_DB_MIN_CONNS"); min != "" {
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(min)); err == nil && n >= 0 {
|
||||
cfg.MinConns = int32(n)
|
||||
}
|
||||
}
|
||||
cfg.MaxConnLifetime = 30 * time.Minute
|
||||
applyPostgresPoolConfig(cfg)
|
||||
pool, err := pgxpool.NewWithConfig(ctx, cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -47,6 +37,29 @@ func OpenPostgresPool(ctx context.Context, dsn string) (*pgxpool.Pool, error) {
|
||||
return pool, nil
|
||||
}
|
||||
|
||||
// applyPostgresPoolConfig sets pool limits. An explicit EVOBGP_DB_MAX_CONNS is kept.
|
||||
// When it is unset, the pool defaults to 8 connections so it can cover the default
|
||||
// worker count instead of pgx's default of 4.
|
||||
func applyPostgresPoolConfig(cfg *pgxpool.Config) {
|
||||
if cfg == nil {
|
||||
return
|
||||
}
|
||||
if max := strings.TrimSpace(os.Getenv("EVOBGP_DB_MAX_CONNS")); max != "" {
|
||||
if n, err := strconv.Atoi(max); err == nil && n > 0 {
|
||||
cfg.MaxConns = int32(n)
|
||||
}
|
||||
} else {
|
||||
cfg.MaxConns = 8
|
||||
}
|
||||
if min := strings.TrimSpace(os.Getenv("EVOBGP_DB_MIN_CONNS")); min != "" {
|
||||
if n, err := strconv.Atoi(min); err == nil && n >= 0 {
|
||||
cfg.MinConns = int32(n)
|
||||
}
|
||||
}
|
||||
cfg.MaxConnLifetime = 30 * time.Minute
|
||||
cfg.MaxConnIdleTime = 5 * time.Minute
|
||||
}
|
||||
|
||||
// OpenSQLite applies sqlite/*.up.sql then opens the DB (foreign keys on).
|
||||
func OpenSQLite(ctx context.Context, filePath string) (*sql.DB, error) {
|
||||
dsn := fmt.Sprintf("file:%s?_pragma=foreign_keys(1)", strings.TrimPrefix(filePath, "file:"))
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func TestApplyPostgresPoolConfigDefaultAndExplicit(t *testing.T) {
|
||||
t.Setenv("EVOBGP_DB_MAX_CONNS", "")
|
||||
t.Setenv("EVOBGP_DB_MIN_CONNS", "")
|
||||
cfg, err := pgxpool.ParseConfig("postgres://localhost/evobgp?sslmode=disable")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
applyPostgresPoolConfig(cfg)
|
||||
if cfg.MaxConns != 8 {
|
||||
t.Fatalf("default max conns: %d", cfg.MaxConns)
|
||||
}
|
||||
if cfg.MaxConnIdleTime != 5*time.Minute {
|
||||
t.Fatalf("idle time: %s", cfg.MaxConnIdleTime)
|
||||
}
|
||||
|
||||
t.Setenv("EVOBGP_DB_MAX_CONNS", "25")
|
||||
cfg, err = pgxpool.ParseConfig("postgres://localhost/evobgp?sslmode=disable")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
applyPostgresPoolConfig(cfg)
|
||||
if cfg.MaxConns != 25 {
|
||||
t.Fatalf("explicit max conns overridden: %d", cfg.MaxConns)
|
||||
}
|
||||
}
|
||||
Loaded 100 of 269 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user