Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
132559cb8e | ||
|
|
aa4e3d0180 | ||
|
|
1ccffc85da | ||
|
|
d38ee68c4e | ||
|
|
aaef47c7a7 | ||
|
|
cbf345b25f | ||
|
|
f548d0671f | ||
|
|
6510a9ca22 | ||
|
|
07c3de4939 | ||
|
|
948dac34fd | ||
|
|
480756d832 | ||
|
|
135fb34e00 | ||
|
|
9efa3bbc8a | ||
|
|
fad2bd3353 | ||
|
|
930e42b0b0 | ||
|
|
16b4923bd7 | ||
|
|
1cfd062835 | ||
|
|
21233bd578 | ||
|
|
990cc739df | ||
|
|
3500bd4624 | ||
|
|
374575ec01 | ||
|
|
f57b430052 | ||
|
|
8a9d60a5a7 | ||
|
|
b963311b43 | ||
|
|
50bdb8232b | ||
|
|
ee8e24ffc6 | ||
|
|
44b94caacf | ||
|
|
cbb4b467ad | ||
|
|
e65cf0d958 | ||
|
|
4a57c91e29 | ||
|
|
2289107911 | ||
|
|
782097420d | ||
|
|
82382d90f2 | ||
|
|
5a16a45922 | ||
|
|
6a6f6cedbc | ||
|
|
9639a03bfe | ||
|
|
48c10b7436 | ||
|
|
4db6438245 | ||
|
|
fb108ec5ab | ||
|
|
a1ada06a76 | ||
|
|
b5ed47902c | ||
|
|
8a19c2a3f4 | ||
|
|
1c0d78b552 | ||
|
|
e0a912a693 | ||
|
|
9740a34fdc | ||
|
|
6fa265a246 | ||
|
|
b8170c4204 | ||
|
|
c6e13bb86b | ||
|
|
2aecbf96fd | ||
|
|
ec65249bf1 | ||
|
|
6329a4df27 | ||
|
|
880d77810a | ||
|
|
8ebce28e34 | ||
|
|
dd7d43c2c2 | ||
|
|
a8c5e9701f | ||
|
|
be3d73f374 | ||
|
|
5fca165c69 | ||
|
|
293115e0e1 | ||
|
|
6d2051f813 | ||
|
|
b51a9ae3b3 | ||
|
|
4d4cd2301f | ||
|
|
d687881eaa | ||
|
|
87e756f34f | ||
|
|
27864fad58 | ||
|
|
dc5e777d07 | ||
|
|
23910c3393 | ||
|
|
0e90bbee4e | ||
|
|
55bc87cbc4 | ||
|
|
e68f034966 | ||
|
|
e06ee880b2 | ||
|
|
33fe8fdd18 | ||
|
|
6fa693156d | ||
|
|
5858d0889e | ||
|
|
bfe20c1fe0 |
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"mcp__codegraph__codegraph_explore",
|
||||
"mcp__codegraph__codegraph_search",
|
||||
"mcp__codegraph__codegraph_node",
|
||||
"mcp__codegraph__codegraph_callers",
|
||||
"mcp__codegraph__codegraph_callees",
|
||||
"mcp__codegraph__codegraph_impact",
|
||||
"mcp__codegraph__codegraph_files",
|
||||
"mcp__codegraph__codegraph_status"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
# CodeGraph data files
|
||||
# These are local to each machine and should not be committed
|
||||
|
||||
# Database
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
|
||||
# Cache
|
||||
cache/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Hook markers
|
||||
.dirty
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"pid": 44608,
|
||||
"version": "0.9.9",
|
||||
"socketPath": "\\\\.\\pipe\\codegraph-97b92efdcc5351da",
|
||||
"startedAt": 1781240018712
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"extends": ["@commitlint/config-conventional"]
|
||||
}
|
||||
@@ -32,6 +32,6 @@ powershell -NoProfile -File scripts/commit/staged-context.ps1
|
||||
- Новая пользовательская возможность → `feat` (minor)
|
||||
- Починка ожидаемого поведения / баг → `fix` (patch)
|
||||
- Follow-up баги после недавнего `feat` в том же scope → **`fix`**, не `feat`
|
||||
- Только перестройка без нового поведения → `refactor` (none)
|
||||
- Только перестройка без нового поведения → `refactor` (patch, без новых функций)
|
||||
|
||||
Заголовок — EN, императив, ≤72 символов. Тело — RU.
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"codegraph": {
|
||||
"type": "stdio",
|
||||
"command": "codegraph",
|
||||
"args": [
|
||||
"serve",
|
||||
"--mcp",
|
||||
"--path",
|
||||
"C:\\Users\\shats\\Dev\\EvoBGP"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
---
|
||||
name: Технический аудит EvoBGP
|
||||
overview: "Полный технический аудит EvoBGP для production-сценария (10+ клиентов, нестабильная сеть). Архитектура — hybrid control plane; сильные стороны: stale fallback, CDN/RIPEstat resilience, Ed25519 bundles. Критичные риски: in-process jobs, DoH без retry, misconfiguration demo-seed, отсутствие HA API."
|
||||
todos:
|
||||
- id: quick-ops-checklist
|
||||
content: Применить production-checklist (SEED_DEMO=0, BUNDLE_SEED_HEX, DB/JOB/CONCURRENCY tuning, TLS)
|
||||
status: pending
|
||||
- id: fix-doh-retry
|
||||
content: Добавить DoWithRetry для DoH в internal/pipeline/refresh.go
|
||||
status: pending
|
||||
- id: fix-job-meta-race
|
||||
content: Исправить чтение j.Meta в worker.go через Snapshot() или locked accessor
|
||||
status: pending
|
||||
- id: cdn-preview-resilience
|
||||
content: Перевести CDN preview на upstreamHTTPDo в routes_crud.go
|
||||
status: pending
|
||||
- id: partial-cdn-failure
|
||||
content: "Partial CDN source failure: skip/degrade вместо fail всего модуля"
|
||||
status: pending
|
||||
- id: graceful-shutdown
|
||||
content: Cancel/drain jobs при SIGTERM в cmd/evobgp-api и evobgp-all
|
||||
status: pending
|
||||
- id: ha-job-queue
|
||||
content: "Roadmap: распределённая очередь jobs (PG claim или NATS) для HA API"
|
||||
status: pending
|
||||
isProject: false
|
||||
---
|
||||
|
||||
# Технический аудит EvoBGP
|
||||
|
||||
## Executive summary
|
||||
|
||||
EvoBGP — **hybrid control plane**: один процесс [`evobgp-all`](cmd/evobgp-all/main.go) (monolith) или **reference Compose** с разделёнными воркерами ([`docs/architecture.md`](docs/architecture.md)). Data plane (BIRD + agent) отделён от control plane (API + PostgreSQL + jobs).
|
||||
|
||||
**Сильные стороны для нестабильной сети:**
|
||||
- Stale snapshot fallback по умолчанию (`EVOBGP_STALE_ON_UPSTREAM_ERROR=1`) — [`internal/pipeline/collect_stale.go`](internal/pipeline/collect_stale.go)
|
||||
- CDN/RIPEstat: retry (3×) + per-host circuit breaker — [`internal/httpclient/httpclient.go`](internal/httpclient/httpclient.go), [`circuit.go`](internal/httpclient/circuit.go)
|
||||
- ETag conditional GET, ASN TTL-кэш, parallel collect с cap
|
||||
- Подписанные бандлы Ed25519, verify перед apply
|
||||
|
||||
**Главные риски для 10+ клиентов:**
|
||||
1. `jobs.Registry` — **in-memory, только в процессе API** (ARCH-04)
|
||||
2. DoH — **без retry/breaker** (критично при блокировках провайдеров)
|
||||
3. Один failed CDN source **без stale cache валит весь модуль**
|
||||
4. Production misconfiguration: `Bearer dev`, HTTP API, ephemeral bundle key
|
||||
5. Data race на `Job.Meta` и alias pointers в `store.Memory`
|
||||
|
||||
---
|
||||
|
||||
## 1. Архитектура
|
||||
|
||||
### Стиль
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph hybrid [Hybrid deployment]
|
||||
All[evobgp_all monolith]
|
||||
Split[evobgp_api + workers]
|
||||
end
|
||||
subgraph cp [Control plane]
|
||||
API[HTTP API]
|
||||
Jobs[jobs.Registry in-process]
|
||||
PG[(PostgreSQL)]
|
||||
end
|
||||
subgraph dp [Data plane per speaker]
|
||||
Agent[evobgp_agent]
|
||||
BIRD[BIRD2]
|
||||
NodeCLI[evobgp_node]
|
||||
end
|
||||
All --> API
|
||||
Split --> API
|
||||
API --> Jobs
|
||||
API --> PG
|
||||
NodeCLI --> API
|
||||
Agent --> API
|
||||
Agent --> BIRD
|
||||
```
|
||||
|
||||
| Профиль | Стиль | Когда |
|
||||
|---------|-------|-------|
|
||||
| `microvps` / `evobgp-all` | Monolith | 1 VPS, shared Registry |
|
||||
| reference Compose | Microservices-lite | API + scheduler/ingest/render/deploy |
|
||||
| Remote speakers | Edge agents | Panel→Node dispatch |
|
||||
|
||||
### Узкие места (bottlenecks)
|
||||
|
||||
| # | Bottleneck | Где | Impact |
|
||||
|---|------------|-----|--------|
|
||||
| B1 | **In-process job queue** | [`internal/jobs/job.go:175-177`](internal/jobs/job.go) | HA API невозможен без потери/дублирования jobs; scheduler без `EVOBGP_CONTROL_PLANE_URL` создаёт **отдельный Registry** — [`cmd/evobgp-scheduler/main.go:58-60`](cmd/evobgp-scheduler/main.go) |
|
||||
| B2 | **Module refresh = sync upstream fan-out** | [`internal/pipeline/collect_parallel.go`](internal/pipeline/collect_parallel.go) | До `EVOBGP_COLLECT_CONCURRENCY` (8 default, max 32) параллельных HTTP; worst case ~45s × retries на источник |
|
||||
| B3 | **Default job concurrency = 8** | [`internal/jobs/job.go:264-268`](internal/jobs/job.go) | При burst refresh 10+ tenants — очередь растёт, goroutine блокируются на sem |
|
||||
| B4 | **PostgreSQL pool default** | [`internal/db/open.go:28-38`](internal/db/open.go) | pgx default ~4 conns; при `JOB_MAX=16` + HTTP — contention без `EVOBGP_DB_MAX_CONNS=25` |
|
||||
| B5 | **Live endpoints fan-out** | [`internal/httpapi/peers_live.go`](internal/httpapi/peers_live.go) | N goroutines × N speakers, 12s timeout каждый |
|
||||
| B6 | **Broker — заглушка** | [`internal/broker`](internal/broker) | NATS URL логируется, очередь не распределена |
|
||||
|
||||
### Масштабируемость
|
||||
|
||||
- **Вертикальная:** хорошо до ~10–20 tenants при `evobgp-all` + tuning ([`docs/production-checklist.md`](docs/production-checklist.md))
|
||||
- **Горизонтальная API:** **не поддерживается** — два `evobgp-api` = два независимых Registry; `job_audit` в PG — audit only, не очередь исполнения
|
||||
- **Workers (ingest/render/deploy):** координируются через **общую БД**, не через jobs — OK для prefetch/drift
|
||||
|
||||
### Отказоустойчивость
|
||||
|
||||
| Сценарий | Поведение | Оценка |
|
||||
|----------|-----------|--------|
|
||||
| CDN/RIPEstat недоступен | Stale snapshot + circuit breaker | **Хорошо** (если был prior snapshot) |
|
||||
| DoH недоступен | Fail модуля или stale domain snapshot | **Средне** (нет HTTP retry) |
|
||||
| API restart mid-job | Job теряется из Registry; audit может быть inconsistent | **Плохо** |
|
||||
| PG недоступен | API `/v1/ready` → 503 | **OK** |
|
||||
| Agent unreachable | Deploy job succeed, drift в `evobgp-deploy` | **Частичный fail** (by design) |
|
||||
|
||||
**Рекомендация:** для 10+ клиентов — **`evobgp-all` на каждом CP** или один CP + tuning; HA API требует **распределённой очереди** (NATS/Redis + worker pool) — задокументировано как future work.
|
||||
|
||||
---
|
||||
|
||||
## 2. Анализ кода
|
||||
|
||||
### Антипаттерны
|
||||
|
||||
| ID | Проблема | Файл | Критичность |
|
||||
|----|----------|------|-------------|
|
||||
| A1 | **Concurrent map read/write** — worker читает `j.Meta` без lock, handler пишет через `mergeMeta`/`Snapshot` | [`worker.go:108,263,379`](internal/jobs/worker.go), [`job.go:103-111`](internal/jobs/job.go) | **high** |
|
||||
| A2 | **Escape internal pointers** из Memory store | [`store/memory.go:416-475`](internal/store/memory.go) | **high** (tests/dev); **low** (prod PG) |
|
||||
| A3 | **Fire-and-forget goroutine** на каждый auth | [`auth.go:79-81`](internal/httpapi/auth.go) | **medium** |
|
||||
| A4 | **Silent error swallow** в prefetch | [`internal/ingest/run.go`](internal/ingest/run.go), `prefetch.go` | **medium** |
|
||||
| A5 | **Bypass resilience layer** — CDN preview прямой `Do` | [`routes_crud.go:267`](internal/httpapi/routes_crud.go) | **medium** |
|
||||
| A6 | **`EVOBGP_DEV_INSECURE` — dead code** | compose + [`server.go`](internal/httpapi/server.go) | **low** (misleading ops) |
|
||||
| A7 | **Unused Registry** в ingest/render/deploy binaries | [`cmd/evobgp-ingest/main.go`](cmd/evobgp-ingest/main.go) | **low** (resource waste) |
|
||||
|
||||
### Maintainability
|
||||
|
||||
**Плюсы:** чёткое разделение слоёв (ARCH-01..10), `store.Backend`, OpenAPI как контракт, engineering rules, table-driven tests в birdfmt/pipeline.
|
||||
|
||||
**Минусы:**
|
||||
- Дублирование retry-логики (httpclient vs nodedispatch inline loop)
|
||||
- Env-tuning разбросан (`EVOBGP_*` в 15+ местах без central config struct для pipeline)
|
||||
- `Job` comment «персистенция в БД пока не подключена» устарел — hooks есть в [`bootstrap.go:67-92`](internal/httpapi/bootstrap.go)
|
||||
|
||||
### Потенциальные баги и race conditions
|
||||
|
||||
1. **`j.Meta` data race** — `-race` на `TestParallelModuleRefresh_*` + concurrent `GET /v1/jobs/{id}` polling
|
||||
2. **Memory store alias** — `deploy.Run` читает `LastAppliedRevisionID` пока worker пишет
|
||||
3. **peerLiveCache** возвращает slice без копии — [`peers_live.go:82-84`](internal/httpapi/peers_live.go)
|
||||
4. **TOCTOU idempotency** — terminal job удаляется из `byIdempo`, повторный POST создаст новый job (by design, но клиент должен знать)
|
||||
|
||||
### Error handling
|
||||
|
||||
**Хорошо:**
|
||||
- Префиксы ошибок (`httpclient:`, `birdfmt:`)
|
||||
- HTTP 5xx через `writeProblem`, без raw `err.Error()` (ERR-01)
|
||||
- `context.Context` в pipeline workers
|
||||
|
||||
**Пробелы:**
|
||||
- `runRollback` без `workContext` — не отменяется — [`worker.go:500+`](internal/jobs/worker.go)
|
||||
- Prefetch/ingest: ошибки не логируются
|
||||
- `mergeBirdPostApplyMeta` — `context.Background()` 8s, игнорирует job cancel
|
||||
|
||||
---
|
||||
|
||||
## 3. Производительность
|
||||
|
||||
### Блокирующие операции
|
||||
|
||||
| Участок | Блокировка | Риск |
|
||||
|---------|------------|------|
|
||||
| `POST .../cdn-sources/preview` | Sync CDN fetch до 45s в HTTP handler | UI timeout, worker starvation |
|
||||
| `GET /v1/peers/live` | N × agent HTTP, wg.Wait | Slow при многих speakers |
|
||||
| Module refresh job | Sequential: ingest → render revision → optional deploy | Long job chain |
|
||||
| `bird -p` / `birdc configure` | Subprocess в deploy | Disk I/O на ноде |
|
||||
|
||||
### Неэффективные алгоритмы / лишние запросы
|
||||
|
||||
- **Tenant refresh:** `aggregateTenantPrefixRowsAll` — parallel по модулям, но каждый модуль может refetch все CDN/ASN/DoH — [`aggregate.go:28+`](internal/pipeline/aggregate.go). Snapshot skip есть через `module_hash` — проверять hit rate в meta.
|
||||
- **ASN resolve:** `PolitePause()` 150ms между AS — [`asnresolve/ripestat.go`](internal/asnresolve/ripestat.go) — при 50 AS = +7.5s minimum.
|
||||
- **GetModulePrefixSnapshot** вызывается многократно в одном refresh (cdn_snapshot, collect_parallel) — potential duplicate DB reads.
|
||||
- **Auth TouchAPIKeyLastUsed:** UPDATE на каждый request (async) — load на PG при high RPS.
|
||||
|
||||
### Кэширование
|
||||
|
||||
| Кэш | TTL | Gap |
|
||||
|-----|-----|-----|
|
||||
| ASN prefix cache | 1800s (`EVOBGP_ASN_CACHE_TTL_SEC`) | OK |
|
||||
| CDN ETag in DB | Until 304/change | OK |
|
||||
| Module prefix snapshot | Content-hash based skip | OK |
|
||||
| peerLiveCache | In-memory, per-process | Не shared между API replicas; нет defensive copy |
|
||||
| Circuit breaker state | Per-process | Не shared |
|
||||
|
||||
### Конкретные улучшения
|
||||
|
||||
```go
|
||||
// 1. CDN preview — использовать upstreamHTTPDo вместо прямого Do
|
||||
resp, err := pipeline.UpstreamHTTPDo(r.Context(), s.cdnHTTP, req) // extract upstreamHTTPDo
|
||||
|
||||
// 2. Job.Meta — читать под lock или через Snapshot()
|
||||
st := j.Snapshot()
|
||||
mid, _ := st["meta"].(map[string]any)["module_id"].(string)
|
||||
|
||||
// 3. Memory store — возвращать копии (как Postgres)
|
||||
modCopy := *mod
|
||||
return &modCopy, nil
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Сетевое взаимодействие (критично)
|
||||
|
||||
### Текущее состояние
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph resilient [Resilient path]
|
||||
CDN[CDN fetch]
|
||||
RIPE[RIPEstat]
|
||||
CDN --> Breaker[Circuit breaker]
|
||||
RIPE --> Breaker
|
||||
Breaker --> Retry[DoWithRetry 3x linear 2s]
|
||||
end
|
||||
subgraph fragile [Fragile path]
|
||||
DoH[DoH resolve]
|
||||
Preview[CDN preview API]
|
||||
AgentHealth[Agent health/bird]
|
||||
DoH --> SingleDo[Single hc.Do]
|
||||
Preview --> SingleDo
|
||||
AgentHealth --> SingleDo
|
||||
end
|
||||
subgraph fallback [App-level fallback]
|
||||
Stale[Stale snapshot]
|
||||
SysDNS[System DNS]
|
||||
DoH --> SysDNS
|
||||
CDN --> Stale
|
||||
RIPE --> Stale
|
||||
end
|
||||
```
|
||||
|
||||
| Upstream | Timeout | Retry | Breaker | Stale fallback |
|
||||
|----------|---------|-------|---------|----------------|
|
||||
| CDN ingest | 45s | 3× linear | per-host | yes |
|
||||
| RIPEstat | 45s | 3× | per-host | yes + cache |
|
||||
| DoH | 10s/profile | **no** | **no** | domain snapshot |
|
||||
| CDN preview | 45s | **no** | **no** | N/A |
|
||||
| Scheduler→API | 45s | 3× | no | N/A |
|
||||
| Node dispatch | 30s | inline 3× | no | N/A |
|
||||
|
||||
### Пробелы для блокировок провайдеров
|
||||
|
||||
1. **DoH без retry** — transient timeout = fail; failover между profiles есть, но каждый profile — single shot
|
||||
2. **429/408 не ретраятся** — только `>= 500`
|
||||
3. **Нет jitter** — thundering herd при mass tenant refresh
|
||||
4. **DNS rebinding TOCTOU** — SSRF check до fetch, HTTP dial без pinned IP — [`cdn_url.go:75-115`](internal/pipeline/cdn_url.go)
|
||||
5. **Circuit breaker без half-open** — после 30s cooldown сразу full traffic — [`circuit.go:29-33`](internal/httpclient/circuit.go)
|
||||
6. **Breaker per-process** — ingest container ≠ API container
|
||||
|
||||
### Рекомендации для нестабильной сети
|
||||
|
||||
| # | Изменение | Effort | Effect |
|
||||
|---|-----------|--------|--------|
|
||||
| N1 | DoH через `DoWithRetry` + optional breaker | Low | **High** для DOMAINS modules |
|
||||
| N2 | Retry 429/503 с `Retry-After` + exponential backoff + jitter | Medium | **High** при rate limits |
|
||||
| N3 | **Partial CDN failure** — continue с stale per-source, не fail whole module | Medium | **High** |
|
||||
| N4 | Multiple DoH profiles + `failover` policy (already exists) — **документировать ops playbook** | Low | **High** (config, not code) |
|
||||
| N5 | Pinned dialer / custom `Transport.DialContext` после SSRF resolve | Medium | **Medium** (SSRF hardening) |
|
||||
| N6 | Proxy support (`HTTP_PROXY` / `EVOBGP_HTTP_PROXY`) для CDN/DoH | Medium | **High** в censored networks |
|
||||
| N7 | Unify CDN preview на `upstreamHTTPDo` | Low | **Medium** |
|
||||
|
||||
---
|
||||
|
||||
## 5. Устойчивость и надёжность
|
||||
|
||||
### Graceful degradation
|
||||
|
||||
**Работает:**
|
||||
- `EVOBGP_STALE_ON_UPSTREAM_ERROR=1` — ASN/CDN/domain stale — [`collect_stale.go`](internal/pipeline/collect_stale.go)
|
||||
- CDN 304 без local cache → forced full GET — [`cdn_snapshot.go:141-159`](internal/pipeline/cdn_snapshot.go)
|
||||
- DoH → system DNS fallback — [`doh_resolve.go:75-93`](internal/pipeline/doh_resolve.go)
|
||||
- Deploy: job succeed even if agent wake fails (drift detection)
|
||||
|
||||
**Не работает / частично:**
|
||||
- Один CDN source fail без cache → **весь module_refresh failed** — [`collect_parallel.go:221-223`](internal/pipeline/collect_parallel.go)
|
||||
- Circuit open → immediate error, stale only if prior data exists
|
||||
- API shutdown: HTTP drain 15s, **jobs не cancel/drain** — [`cmd/evobgp-api/main.go:67-72`](cmd/evobgp-api/main.go)
|
||||
|
||||
### Сценарии отказов
|
||||
|
||||
| Событие | Что произойдёт |
|
||||
|---------|----------------|
|
||||
| **Потеря CP↔PG** | Ready=false; running jobs fail; no new jobs persist audit reliably |
|
||||
| **Потеря CP↔CDN** | Stale prefixes если были; иначе job fail; breaker opens 30s |
|
||||
| **Потеря CP↔agent** | Deploy meta `dispatch_failed`; BIRD на старой ревизии; drift logs |
|
||||
| **RIPEstat rate limit** | 429 → no retry → stale or fail |
|
||||
| **Рост нагрузки** | Job queue; goroutine pile-up; PG pool exhaustion; `/metrics` shows queue depth |
|
||||
| **API restart** | In-flight jobs lost; clients poll 404 or stale terminal state |
|
||||
|
||||
---
|
||||
|
||||
## 6. Безопасность
|
||||
|
||||
| ID | Finding | Severity | Fix |
|
||||
|----|---------|----------|-----|
|
||||
| S1 | `Bearer dev` → operator при demo-seed | **high** (misconfig) | `EVOBGP_SEED_DEMO=0` — [`auth.go:66-92`](internal/httpapi/auth.go) |
|
||||
| S2 | API plain HTTP | **high** (ops) | TLS на edge (Traefik/nginx) |
|
||||
| S3 | Ephemeral bundle key без `EVOBGP_BUNDLE_SEED_HEX` | **high** (ops) | Stable seed + pubkey на нодах |
|
||||
| S4 | Compose defaults: weak PG password, `sslmode=disable` | **high** (ops) | Secrets manager, `sslmode=require` |
|
||||
| S5 | `/metrics` без auth | **medium** | Network policy / mTLS |
|
||||
| S6 | No rate limiting on auth | **medium** | Middleware limiter (e.g. per-IP) |
|
||||
| S7 | CDN SSRF DNS rebinding | **medium** | Pinned dialer after resolve |
|
||||
| S8 | `EVOBGP_CDN_ALLOW_PRIVATE=1` | **medium** | Never in prod |
|
||||
| S9 | `EVOBGP_NODE_DISPATCH_INSECURE_TLS=1` | **medium** | Valid TLS to agent |
|
||||
| S10 | Plaintext `EVOBGP_API_KEYS` in env | **medium** | DB keys via API |
|
||||
| S11 | `editor` can cancel jobs | **low** | Restrict to operator |
|
||||
| S12 | agent_secret `==` compare | **low** | `subtle.ConstantTimeCompare` |
|
||||
|
||||
**SQL injection:** не обнаружено — параметризованные запросы в [`repository/`](internal/repository/).
|
||||
|
||||
**Bundle crypto:** Ed25519 корректно; path traversal blocked в tar extract.
|
||||
|
||||
---
|
||||
|
||||
## 7. Конкретные рекомендации (prioritized backlog)
|
||||
|
||||
### High
|
||||
|
||||
| # | Описание | Как исправить |
|
||||
|---|----------|---------------|
|
||||
| H1 | DoH без retry | Обернуть `hc.Do` в `DoWithRetry(ctx, hc, req, 3)` в [`refresh.go:288,360`](internal/pipeline/refresh.go) |
|
||||
| H2 | Data race `Job.Meta` | Читать через `Snapshot()` или добавить `MetaLocked()` accessor |
|
||||
| H3 | CDN source partial failure | В `collectCDNPrefixRows`: при err без stale — log warning + skip source вместо `return nil, r.err` (config flag `EVOBGP_CDN_PARTIAL_OK=1`) |
|
||||
| H4 | Production checklist enforcement | CI/deploy validation: reject `SEED_DEMO=1`, require `BUNDLE_SEED_HEX` |
|
||||
| H5 | Job queue HA roadmap | Persist queued jobs in PG + worker claim (`SELECT FOR UPDATE SKIP LOCKED`) или NATS — ARCH-04 |
|
||||
|
||||
### Medium
|
||||
|
||||
| # | Описание | Как исправить |
|
||||
|---|----------|---------------|
|
||||
| M1 | CDN preview bypass | [`routes_crud.go:267`](internal/httpapi/routes_crud.go) → `upstreamHTTPDo` |
|
||||
| M2 | Retry 429/503 | Extend `DoWithRetry` status check + parse `Retry-After` |
|
||||
| M3 | Graceful shutdown | On SIGTERM: `Registry.RequestCancelAll()` + wait workers with timeout |
|
||||
| M4 | Auth goroutine storm | Worker pool или sync touch with debounce |
|
||||
| M5 | HTTP proxy support | Custom Transport reading `EVOBGP_HTTP_PROXY` |
|
||||
| M6 | Memory store copies | Defensive copy in Get/List (dev/test safety) |
|
||||
| M7 | Rate limiting | `golang.org/x/time/rate` on auth middleware |
|
||||
|
||||
### Low
|
||||
|
||||
| # | Описание | Как исправить |
|
||||
|---|----------|---------------|
|
||||
| L1 | Jitter in backoff | `wait + rand.Intn(wait/2)` in DoWithRetry |
|
||||
| L2 | Half-open breaker | Single probe request after cooldown |
|
||||
| L3 | Remove dead `EVOBGP_DEV_INSECURE` from compose | Docs + compose cleanup |
|
||||
| L4 | Prefetch error logging | `log.Printf` or structured log in prefetch |
|
||||
| L5 | peerLiveCache defensive copy | `append([]T(nil), views...)` on store |
|
||||
|
||||
---
|
||||
|
||||
## 8. Quick wins (максимальный эффект / минимум усилий)
|
||||
|
||||
1. **Ops (0 code):** [`docs/production-checklist.md`](docs/production-checklist.md) — `SEED_DEMO=0`, `BUNDLE_SEED_HEX`, `DB_MAX_CONNS=25`, `JOB_MAX=16`, `COLLECT_CONCURRENCY=16`, TLS edge, restrict metrics
|
||||
2. **DoH retry** — 5–10 строк в `refresh.go`, reuse existing `DoWithRetry`
|
||||
3. **CDN preview → upstreamHTTPDo** — 1 line change in handler
|
||||
4. **Job.Meta read fix** — replace 4 reads in `worker.go` with `Snapshot()` parsing
|
||||
5. **Log prefetch failures** — visibility без изменения behavior
|
||||
6. **Document DoH failover playbook** — multiple profiles (Cloudflare, Google, Quad9) + `failover` policy for censored regions
|
||||
7. **Run `go test -race ./internal/jobs/...`** in CI — catch Meta race
|
||||
8. **Prefer `evobgp-all`** over split reference for <20 tenants — eliminates Registry split bug
|
||||
|
||||
---
|
||||
|
||||
## Диаграмма: refresh под сетевым stress
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Op as Operator
|
||||
participant API as evobgp_api
|
||||
participant Job as module_refresh
|
||||
participant CDN as CDN_upstream
|
||||
participant PG as PostgreSQL
|
||||
|
||||
Op->>API: POST /modules/id/refresh
|
||||
API->>Job: Enqueue
|
||||
Job->>CDN: GET with ETag
|
||||
alt CDN timeout or 5xx
|
||||
CDN-->>Job: error after 3 retries
|
||||
Job->>PG: load prior snapshot
|
||||
alt stale exists
|
||||
Job->>PG: CreateRenderRevision stale
|
||||
Job-->>API: succeeded degraded
|
||||
else no stale
|
||||
Job-->>API: failed
|
||||
end
|
||||
else CDN 200
|
||||
CDN-->>Job: new prefixes
|
||||
Job->>PG: CreateRenderRevision
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Итоговая оценка зрелости
|
||||
|
||||
| Область | Оценка | Комментарий |
|
||||
|---------|--------|-------------|
|
||||
| Архитектура | 7/10 | Чистые слои; HA/API scaling — слабое место |
|
||||
| Сеть/resilience | 6/10 | CDN/ASN хорошо; DoH/preview — пробелы |
|
||||
| Concurrency | 6/10 | Registry продуман; Meta race, shutdown |
|
||||
| Performance | 7/10 | Parallel collect, caching; tuning needed at scale |
|
||||
| Security | 6/10 | Crypto OK; ops/config risks dominate |
|
||||
| Maintainability | 8/10 | Docs, rules, OpenAPI, tests |
|
||||
|
||||
**Вердикт:** проект **готов для 10+ клиентов в single-CP deployment** (`evobgp-all` + PostgreSQL + production checklist) при условии ops discipline. Для **multi-CP HA** и **агрессивных сетевых блокировок** — приоритет: DoH retry, partial CDN failure, distributed job queue, HTTP proxy.
|
||||
@@ -0,0 +1,93 @@
|
||||
---
|
||||
description: Context7 — закреплённые ID библиотек и документации стека EvoBGP
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Context7 — стек EvoBGP
|
||||
|
||||
При вопросах об API, синтаксисе, конфигурации и миграциях библиотек **сначала** `query-docs` с ID из таблицы ниже. Шаг `resolve-library-id` **пропускать**, если библиотека уже перечислена (кроме неоднозначного случая).
|
||||
|
||||
Локальные версии: `go.mod`, `web/package.json`. При расхождении с ID — предпочитать версию из репозитория.
|
||||
|
||||
---
|
||||
|
||||
## Backend (Go)
|
||||
|
||||
| Библиотека | Context7 ID | Версия в проекте | Когда |
|
||||
|------------|-------------|------------------|-------|
|
||||
| Go stdlib | `/golang/go/go1_24_6` | Go 1.24 | `net/http`, `context`, тесты, concurrency |
|
||||
| pgx | `/websites/pkg_go_dev_github_com_jackc_pgx_v5` | v5.7.2 | PostgreSQL, pool, транзакции, типы |
|
||||
| Prometheus Go client | `/prometheus/client_golang` | v1.20.5 | метрики, `/metrics`, middleware |
|
||||
| modernc SQLite | `/websites/pkg_go_dev_modernc_org_sqlite` | v1.34.5 | SQLite-бэкенд, миграции sqlite |
|
||||
| miekg/dns | `/miekg/dns` | v1.1.72 | DNS-запросы, DoH, pipeline |
|
||||
|
||||
---
|
||||
|
||||
## HTTP-контракт и спецификации
|
||||
|
||||
| Библиотека | Context7 ID | Версия в проекте | Когда |
|
||||
|------------|-------------|------------------|-------|
|
||||
| OpenAPI | `/oai/openapi-specification` | 3.x в `docs/openapi.yaml` | схемы, operationId, problem+json |
|
||||
| Redocly CLI | `/redocly/redocly-cli` | CI `@redocly/cli` | lint OpenAPI, `npx @redocly/cli lint` |
|
||||
|
||||
---
|
||||
|
||||
## Web UI (`web/`)
|
||||
|
||||
| Библиотека | Context7 ID | Версия в проекте | Когда |
|
||||
|------------|-------------|------------------|-------|
|
||||
| Svelte | `/websites/svelte_dev` | ^5.54 | runes, компоненты, реактивность |
|
||||
| SvelteKit | `/sveltejs/kit` | ^2.50 | routing, `load`, adapters, SSR |
|
||||
| Vite | `/vitejs/vite/v7.3.1` | ^7.3.1 | dev server, build, plugins |
|
||||
| TypeScript | `/microsoft/typescript/v5.9.3` | ^5.9.3 | типы, strict, tsconfig |
|
||||
| Tailwind CSS | `/tailwindlabs/tailwindcss.com` | ^4.1 | v4, `@tailwindcss/vite`, утилиты |
|
||||
| shadcn-svelte | `/websites/shadcn-svelte` | CLI | примитивы `ui/core`, theming |
|
||||
| Bits UI | `/llmstxt/bits-ui_llms_txt` | ^2.17 | headless-примитивы под shadcn |
|
||||
| sveltekit-superforms | `/ciscoheat/sveltekit-superforms` | ^2.30 | формы, server actions |
|
||||
| Formsnap | `/svecosystem/formsnap` | ^2.0 | доступные поля форм |
|
||||
| Zod | `/websites/zod_dev_v4` | ^4.4 | схемы валидации |
|
||||
| TanStack Table | `/websites/tanstack_table` | table-core ^8.21 | `AppDataTable`, колонки, сортировка |
|
||||
|
||||
UI-правила репозитория: `.cursor/rules/web-shadcn.mdc` (shadcn-svelte docs — первичный источник для компонентов).
|
||||
|
||||
---
|
||||
|
||||
## Data plane / BGP
|
||||
|
||||
| Библиотека | Context7 ID | Версия в проекте | Когда |
|
||||
|------------|-------------|------------------|-------|
|
||||
| BIRD 2 | `/llmstxt/bird_xmsl_dev_llms_txt` | BIRD2 в compose | `birdfmt`, фильтры, протоколы |
|
||||
| BIRD (исходники) | `/cz-nic/bird` | — | низкоуровневый синтаксис daemon |
|
||||
|
||||
Сетевые правила: `.cursor/rules/networking-bird.mdc`.
|
||||
|
||||
---
|
||||
|
||||
## DevOps
|
||||
|
||||
| Библиотека | Context7 ID | Версия в проекте | Когда |
|
||||
|------------|-------------|------------------|-------|
|
||||
| Docker Compose | `/docker/compose` | `deploy/compose/` | сервисы, profiles, volumes |
|
||||
| Docker | `/docker/docs` | — | образы, bake, networking |
|
||||
|
||||
---
|
||||
|
||||
## Приоритет источников
|
||||
|
||||
1. **Контракт HTTP** — `docs/openapi.yaml` (не Context7).
|
||||
2. **Context7** — синтаксис и API библиотек из таблицы.
|
||||
3. **Локальные docs** — `docs/`, `web/README.md`, `AGENTS.md`.
|
||||
4. **Официальный сайт** — BIRD: https://bird.network.cz/?get_doc (если Context7 не покрыл кейс).
|
||||
|
||||
## Примеры запросов
|
||||
|
||||
```
|
||||
/docs /websites/svelte_dev runes $state $derived
|
||||
/docs /golang/go/go1_24_6 net/http ServeMux pattern matching
|
||||
/docs /websites/pkg_go_dev_github_com_jackc_pgx_v5 pool acquire rows
|
||||
/docs /llmstxt/bird_xmsl_dev_llms_txt filter bgp import
|
||||
```
|
||||
|
||||
## Не через Context7
|
||||
|
||||
Рефакторинг `internal/*`, бизнес-логика EvoBGP, code review — код репозитория и `codegraph`. Context7 — только внешние библиотеки и инструменты.
|
||||
@@ -50,10 +50,10 @@ powershell -NoProfile -File scripts/commit/staged-context.ps1
|
||||
| `feat` | **новая** пользовательская возможность (раньше нельзя было) | minor |
|
||||
| `fix` | восстановление **ожидаемого** поведения; баг, регрессия, падение UI | patch |
|
||||
| `perf` | ускорение без смены API | patch |
|
||||
| `refactor` | реструктуризация **без** новой возможности и **без** исправления бага | — |
|
||||
| `refactor` | реструктуризация **без** новой возможности и **без** исправления бага | patch |
|
||||
| `docs` | только документация | — |
|
||||
| `test` | тесты | — |
|
||||
| `ci` | CI/CD (`.gitea/`, workflows) | — |
|
||||
| `ci` | CI/CD (`.gitea/`, workflows); правки, из‑за которых нужны новые образы | patch |
|
||||
| `chore` | обслуживание, deps, `.cursor/` | — |
|
||||
|
||||
### Выбор type: semver, а не «красивые слова»
|
||||
@@ -62,7 +62,7 @@ powershell -NoProfile -File scripts/commit/staged-context.ps1
|
||||
|
||||
1. Появилось **новое** действие / экран / API / настройка, которых не было → `feat`
|
||||
2. То, что **должно было работать**, не работало (кнопки, диалоги, сохранение, 500) → `fix`
|
||||
3. Только перестройка кода или UI на другой паттерн, поведение для пользователя то же → `refactor`
|
||||
3. Только перестройка кода или UI на другой паттерн, поведение для пользователя то же → `refactor` (patch, без новых функций)
|
||||
4. Ускорение без изменения контракта → `perf`
|
||||
|
||||
**Не путать с формулировкой diff:**
|
||||
@@ -105,7 +105,7 @@ feat(web): migrate modules list to AppDataTable
|
||||
# Хорошо — если не было нового user-facing
|
||||
refactor(web): migrate modules list to AppDataTable
|
||||
|
||||
Единый паттерн таблиц; поведение списка модулей без изменений.
|
||||
Единый паттерн таблиц; поведение списка модулей без изменений. Semver: patch.
|
||||
```
|
||||
|
||||
```
|
||||
@@ -142,6 +142,8 @@ feat(web): add module create dialog on /modules
|
||||
| `.cursor/` | `chore` |
|
||||
| прочее в корне | `chore` |
|
||||
|
||||
**Запрещено:** несколько scope через запятую (`refactor(web, httpapi): …`) — semantic-release не распознает `type`, релиз не будет (см. [docs/releasing.md](../../docs/releasing.md)).
|
||||
|
||||
`type` определять по **содержимому diff**, не только по пути.
|
||||
|
||||
## Multi-change
|
||||
|
||||
@@ -15,7 +15,7 @@ alwaysApply: true
|
||||
|
||||
**ARCH-01** | MUST | Новая persistence-логика — метод `store.Backend` + реализации в `repository` и `store.Memory`; SQL не в `httpapi`.
|
||||
*Rationale:* единая абстракция данных.
|
||||
*Проверка:* grep SQL в `internal/httpapi` — отсутствие; review.
|
||||
*Проверка:* CI `scripts/lint-httpapi.sh`; grep SQL в `internal/httpapi` — отсутствие.
|
||||
|
||||
**ARCH-02** | MUST | HTTP-маршруты только в `internal/httpapi`; регистрация через `http.ServeMux` с паттернами `METHOD /v1/...`.
|
||||
*Rationale:* один слой REST.
|
||||
@@ -57,9 +57,9 @@ alwaysApply: true
|
||||
|
||||
## Code Style
|
||||
|
||||
**STYLE-01** | MUST | Go-код после `gofmt`; перед PR — `go vet ./...`.
|
||||
*Rationale:* единый стиль.
|
||||
*Проверка:* CI job `go`.
|
||||
**STYLE-01** | MUST | Go-код после `gofmt`; перед PR — `go vet ./...`. Агент после правок Go: `gofmt -w` на изменённых файлах + `golangci-lint run` (или `scripts/lint-go.*`) до exit 0.
|
||||
*Rationale:* CI job `go` включает golangci-lint (gofmt).
|
||||
*Проверка:* CI job `go`; `.cursor/rules/engineering.mdc` STYLE-01.
|
||||
|
||||
**STYLE-02** | MUST | Экспортируемые типы/функции публичных пакетов — godoc-комментарий.
|
||||
*Rationale:* навигация по API пакетов.
|
||||
@@ -75,7 +75,7 @@ alwaysApply: true
|
||||
|
||||
**STYLE-05** | MUST | HTTP-ошибки — `writeProblem` / `writeJSON` (`application/problem+json` для 4xx/5xx).
|
||||
*Rationale:* RFC 9457, OpenAPI.
|
||||
*Проверка:* `problem.go`.
|
||||
*Проверка:* `problem.go`; CI `scripts/lint-httpapi.sh` (5xx и 4xx store/cdn/csv).
|
||||
|
||||
**STYLE-06** | MUST | JSON полей HTTP DTO согласованы с `docs/openapi.yaml`.
|
||||
*Rationale:* контракт API.
|
||||
@@ -123,8 +123,8 @@ alwaysApply: true
|
||||
**TEST-03** | MUST | Новые BIRD-сценарии в `internal/birdfmt/testdata/scenarios/*/bird.conf` + `bird -p`.
|
||||
*Проверка:* CI job `bird2`.
|
||||
|
||||
**TEST-04** | MUST | Изменения `web/` — локально `npm run check` и `npm run lint` (CI web пока не в scope).
|
||||
*Проверка:* локальные команды.
|
||||
**TEST-04** | MUST | Изменения `web/` — локально **`npm run check` и `npm run lint`** (обе команды, exit 0); CI job `web` в `.gitea/workflows/ci.yaml`. Агент: при fail lint — `npx prettier --write` затем повтор. Только `check` не заменяет `lint`.
|
||||
*Проверка:* CI job `web`; `.cursor/rules/web-shadcn.mdc` WEB-19.
|
||||
|
||||
**TEST-05** | MUST | Изменения OpenAPI — `npx @redocly/cli lint docs/openapi.yaml`.
|
||||
*Проверка:* CI job `openapi`.
|
||||
@@ -230,11 +230,14 @@ alwaysApply: true
|
||||
go vet ./...
|
||||
go test ./... -race -count=1
|
||||
npx @redocly/cli lint docs/openapi.yaml
|
||||
# web: cd web; npm run check; npm run lint
|
||||
# web: cd web; npm run check; npm run lint (или scripts/lint-web.ps1)
|
||||
# go fmt/lint: gofmt -w <files>; scripts/lint-go.ps1 (gofmt + vet + golangci-lint)
|
||||
# birdfmt: go test ./internal/birdfmt/... -count=1
|
||||
```
|
||||
|
||||
**Рекомендуется (частично внедрено):** CI job `web` (Gitea); `scripts/lint-httpapi.sh` в job `go`; `.golangci.yml` (локально); pre-commit gofmt/prettier.
|
||||
**CI (Gitea):** job `web` (check + lint); job `go`: `go vet`, `scripts/lint-httpapi.sh` (ARCH-01, ERR-01), `scripts/check-migrations-pair.sh` (DEP-03), `golangci-lint`, `go test -race`, build `cmd/*`.
|
||||
|
||||
**Рекомендуется локально:** `.golangci.yml`; `.pre-commit-config.yaml` (gofmt + prettier web).
|
||||
|
||||
**Только code review:** слои SQL; роли; idempotency; OpenAPI bodies; secrets в compose.
|
||||
|
||||
|
||||
@@ -73,6 +73,15 @@ alwaysApply: false
|
||||
**WEB-15** | MUST | Сомнения — https://shadcn-svelte.com/llms.txt , Svelte MCP, `npm run check`.
|
||||
*Проверка:* локально.
|
||||
|
||||
**WEB-19** | MUST | **После любого изменения `web/**`** — перед завершением задачи агент **обязан** выполнить в `web/`:
|
||||
```powershell
|
||||
npm run check
|
||||
npm run lint
|
||||
```
|
||||
Если `npm run lint` падает (Prettier) — **сначала** `npx prettier --write <изменённые файлы>` или `npx prettier --write .`, затем снова `npm run check` и `npm run lint`. Не сдавать PR/ответ, пока обе команды не exit 0.
|
||||
*Rationale:* CI job `web` = `check` + `prettier --check`; `svelte-check` не ловит форматирование.
|
||||
*Проверка:* CI job `web`; pre-commit hook `prettier-web`.
|
||||
|
||||
**WEB-16** | MUST | Подтверждение удаления — `ConfirmDialog` из patterns, не `window.confirm`.
|
||||
*Проверка:* review.
|
||||
|
||||
@@ -95,15 +104,23 @@ Tailwind v4: https://shadcn-svelte.com/docs/migration/tailwind-v4
|
||||
|
||||
## Enforcement
|
||||
|
||||
**Обязательный финальный шаг агента при правках `web/**`:** `npm run check` **и** `npm run lint` (см. **WEB-19**). Только `check` недостаточно.
|
||||
|
||||
```powershell
|
||||
cd web
|
||||
npm run check
|
||||
npm run lint
|
||||
# при warn/fail lint:
|
||||
npx prettier --write .
|
||||
npm run check
|
||||
npm run lint
|
||||
```
|
||||
|
||||
**PR checklist `web/**`:**
|
||||
- [ ] `npm run check` — exit 0
|
||||
- [ ] `npm run lint` (prettier --check) — exit 0
|
||||
- [ ] `ui/core` / `ui/patterns`, не дубли примитивов
|
||||
- [ ] Новые примитивы через shadcn CLI
|
||||
- [ ] Ссылка на docs компонента (если новый паттерн)
|
||||
|
||||
**CI:** job `web` рекомендован; пока обязательно локально.
|
||||
**CI:** job `web` — `npm run check` + `npm run lint`.
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
"plugins": {
|
||||
"svelte": {
|
||||
"enabled": true
|
||||
},
|
||||
"claude-plugins-official/gopls-lsp": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,7 +101,7 @@ git commit -m "$( @'
|
||||
| Пользователь получает **новую** возможность? | `feat` (minor) |
|
||||
| Восстанавливается **ожидаемое** поведение / устранён баг? | `fix` (patch) |
|
||||
| Только скорость, контракт тот же? | `perf` (patch) |
|
||||
| Только структура кода/UI, поведение то же? | `refactor` (none) |
|
||||
| Только структура кода/UI, поведение то же? | `refactor` (patch) |
|
||||
|
||||
**Follow-up:** правки сразу после `feat` в том же scope без новой возможности → **`fix`**, не `feat` (слова *enhance/improve/refactor* в задаче не делают commit `feat`).
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
---
|
||||
name: context7-evobgp
|
||||
description: Context7 lookup для стека EvoBGP — использовать закреплённые library ID из .cursor/rules/context7-stack.mdc вместо resolve-library-id.
|
||||
---
|
||||
|
||||
# Context7 — EvoBGP stack
|
||||
|
||||
Перед `query-docs` открой `.cursor/rules/context7-stack.mdc` и выбери ID из таблицы по области задачи.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Определи область: `internal/` (Go), `web/` (Svelte), `docs/openapi.yaml`, `birdfmt`/`pipeline` (BIRD), `deploy/compose` (Docker).
|
||||
2. Найди строку в таблице `context7-stack.mdc`.
|
||||
3. Вызови `query-docs` с `libraryId` из таблицы и полным вопросом пользователя.
|
||||
4. `resolve-library-id` — только если библиотеки нет в таблице или нужна другая major-версия.
|
||||
|
||||
## Быстрые ID (частые)
|
||||
|
||||
| Задача | libraryId |
|
||||
|--------|-----------|
|
||||
| Svelte 5 runes | `/websites/svelte_dev` |
|
||||
| SvelteKit load/forms | `/sveltejs/kit` |
|
||||
| shadcn-svelte компонент | `/websites/shadcn-svelte` |
|
||||
| pgx pool/query | `/websites/pkg_go_dev_github_com_jackc_pgx_v5` |
|
||||
| Go net/http | `/golang/go/go1_24_6` |
|
||||
| OpenAPI lint | `/redocly/redocly-cli` |
|
||||
| BIRD config | `/llmstxt/bird_xmsl_dev_llms_txt` |
|
||||
| Tailwind v4 | `/tailwindlabs/tailwindcss.com` |
|
||||
| Zod 4 schema | `/websites/zod_dev_v4` |
|
||||
|
||||
Полный список и версии — в `context7-stack.mdc`.
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"codegraph": {
|
||||
"type": "stdio",
|
||||
"command": "codegraph",
|
||||
"args": [
|
||||
"serve",
|
||||
"--mcp"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -12,7 +12,7 @@ Workflow: [workflows/ci.yaml](workflows/ci.yaml).
|
||||
|
||||
## CI (quality gates)
|
||||
|
||||
Job **changes** вычисляет флаги по путям в diff. Изменение `.gitea/workflows/*` поднимает полный прогон.
|
||||
Job **changes** вычисляет флаги по путям в diff. Полный прогон (все узлы openapi / web / go / bird2 в графе): `.gitea/workflows/*`, `scripts/*`, `.golangci.yml`, `.pre-commit-config.yaml`, корневой `package.json` / `.releaserc.json`. Отдельно: `migrations/*`, `docs/openapi.yaml` → `go` / `openapi` и т.д. (см. `ci.yaml`).
|
||||
|
||||
На **pull request** — **commitlint** (Conventional Commits).
|
||||
|
||||
@@ -44,6 +44,8 @@ git.shts.su/<owner>/<имя>:sha-<full-sha>
|
||||
|
||||
Имена образов: `evobgp-api`, `evobgp-all`, `evobgp-scheduler`, `evobgp-ingest`, `evobgp-render`, `evobgp-deploy`, `evobgp-node`, `evobgp-web`, `evobgp-web-all`, `evobgp-agent`, `evobgp-bird2`.
|
||||
|
||||
**Удалённый спикер** (compose `deploy/compose/docker-compose.remote-speaker.yaml`): `evobgp-bird2`, `evobgp-agent`, `evobgp-node` (fallback profile); Traefik — внешний `traefik:latest`. CI: `scripts/validate-remote-speaker-compose.sh`.
|
||||
|
||||
Пример:
|
||||
|
||||
```bash
|
||||
|
||||
+88
-37
@@ -8,10 +8,9 @@ on:
|
||||
|
||||
jobs:
|
||||
# ---------------------------------------------------------------------------
|
||||
# Гранулярная детекция изменений по модулям.
|
||||
# Каждый флаг соответствует группе файлов; downstream-джобы запускаются
|
||||
# только когда их группа затронута. Изменение CI-конфигурации (.gitea/workflows/*)
|
||||
# поднимает все флаги, чтобы гарантировать полный прогон.
|
||||
# Детекция изменений по модулям (флаги → downstream-джобы в графе CI).
|
||||
# Полный прогон (все флаги true): .gitea/workflows/*, scripts/*, .golangci.yml,
|
||||
# .pre-commit-config.yaml — чтобы при правках CI/CD пересобирались все узлы.
|
||||
# ---------------------------------------------------------------------------
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -40,6 +39,23 @@ jobs:
|
||||
docker_web=false
|
||||
docker_bird=false
|
||||
|
||||
# Все флаги true → openapi, web, go, bird2 (и release на main) в графе CI.
|
||||
set_all_flags_true() {
|
||||
openapi=true
|
||||
go=true
|
||||
web=true
|
||||
bird_conf=true
|
||||
docker_go=true
|
||||
docker_web=true
|
||||
docker_bird=true
|
||||
}
|
||||
|
||||
write_outputs() {
|
||||
for v in openapi go web bird_conf docker_go docker_web docker_bird; do
|
||||
eval "echo \"\$v=\$$v\"" >> "$GITHUB_OUTPUT"
|
||||
done
|
||||
}
|
||||
|
||||
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
||||
base="${{ github.event.pull_request.base.sha }}"
|
||||
head="${{ github.event.pull_request.head.sha }}"
|
||||
@@ -52,61 +68,84 @@ jobs:
|
||||
elif git rev-parse --verify HEAD~1 >/dev/null 2>&1; then
|
||||
FILES="$(git diff --name-only HEAD~1 HEAD)"
|
||||
else
|
||||
openapi=true; go=true; web=true; bird_conf=true
|
||||
docker_go=true; docker_web=true; docker_bird=true
|
||||
for v in openapi go web bird_conf docker_go docker_web docker_bird; do
|
||||
echo "$v=true" >> "$GITHUB_OUTPUT"
|
||||
done
|
||||
echo "No parent commit — full pipeline"
|
||||
set_all_flags_true
|
||||
write_outputs
|
||||
echo "No parent commit — full pipeline (all modules)"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "$(printf '%s' "$FILES" | tr -d '[:space:]')" ]; then
|
||||
go=true; web=true
|
||||
for v in openapi go web bird_conf docker_go docker_web docker_bird; do
|
||||
eval "echo \"\$v=\$$v\"" >> "$GITHUB_OUTPUT"
|
||||
done
|
||||
echo "Empty diff — safe fallback: go=true web=true"
|
||||
set_all_flags_true
|
||||
write_outputs
|
||||
echo "Empty diff — full pipeline fallback"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
ci_changed=false
|
||||
full_pipeline=false
|
||||
|
||||
while IFS= read -r f || [ -n "${f:-}" ]; do
|
||||
[ -z "${f:-}" ] && continue
|
||||
case "$f" in
|
||||
.gitea/workflows/*) ci_changed=true ;;
|
||||
docs/openapi.yaml|redocly.yaml) openapi=true ;;
|
||||
web/README.md) ;; # doc-only
|
||||
web/*) web=true ;;
|
||||
deploy/bird/*) bird_conf=true ;;
|
||||
deploy/docker/bird/*) docker_bird=true; docker_go=true ;;
|
||||
deploy/docker/gobinary/*) docker_go=true ;;
|
||||
deploy/docker/docker-bake.hcl) docker_go=true; docker_web=true ;;
|
||||
deploy/docker/evobgp-agent/*) docker_go=true ;;
|
||||
deploy/docker/evobgp-web/*) docker_web=true ;;
|
||||
deploy/docker/bird2/*) docker_bird=true ;;
|
||||
go.mod|go.sum|go.work) go=true ;;
|
||||
*.go) go=true ;;
|
||||
cmd/*|internal/*) go=true ;;
|
||||
# CI/CD инфраструктура — все узлы quality gates
|
||||
.gitea/workflows/*|.golangci.yml|.pre-commit-config.yaml|scripts/*)
|
||||
full_pipeline=true
|
||||
;;
|
||||
docs/openapi.yaml|redocly.yaml)
|
||||
openapi=true
|
||||
;;
|
||||
docs/api.md|docs/access.md)
|
||||
openapi=true
|
||||
go=true
|
||||
;;
|
||||
web/README.md|web/components.json)
|
||||
;;
|
||||
web/*)
|
||||
web=true
|
||||
;;
|
||||
deploy/bird/*)
|
||||
bird_conf=true
|
||||
go=true
|
||||
;;
|
||||
deploy/compose/*|deploy/docker/*)
|
||||
docker_go=true
|
||||
docker_web=true
|
||||
docker_bird=true
|
||||
go=true
|
||||
;;
|
||||
go.mod|go.sum|go.work)
|
||||
go=true
|
||||
;;
|
||||
migrations/*)
|
||||
go=true
|
||||
;;
|
||||
cmd/*|internal/*|*.go)
|
||||
go=true
|
||||
bird_conf=true
|
||||
;;
|
||||
docs/*)
|
||||
go=true
|
||||
;;
|
||||
package.json|package-lock.json|.releaserc.json)
|
||||
full_pipeline=true
|
||||
;;
|
||||
*)
|
||||
go=true
|
||||
;;
|
||||
esac
|
||||
done <<< "$FILES"
|
||||
|
||||
if $ci_changed; then
|
||||
go=true; web=true; bird_conf=true
|
||||
docker_go=true; docker_web=true; docker_bird=true
|
||||
if $full_pipeline; then
|
||||
set_all_flags_true
|
||||
fi
|
||||
|
||||
for v in openapi go web bird_conf docker_go docker_web docker_bird; do
|
||||
eval "echo \"\$v=\$$v\"" >> "$GITHUB_OUTPUT"
|
||||
done
|
||||
write_outputs
|
||||
|
||||
echo "Changed files (first 30):"
|
||||
printf '%s\n' "$FILES" | head -n 30
|
||||
echo "--- flags ---"
|
||||
echo "openapi=$openapi go=$go web=$web bird_conf=$bird_conf"
|
||||
echo "docker_go=$docker_go docker_web=$docker_web docker_bird=$docker_bird ci=$ci_changed"
|
||||
echo "docker_go=$docker_go docker_web=$docker_web docker_bird=$docker_bird full_pipeline=$full_pipeline"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
openapi:
|
||||
@@ -157,6 +196,16 @@ jobs:
|
||||
run: go vet ./...
|
||||
- name: Lint httpapi (ERR-01 / ARCH-01)
|
||||
run: sh scripts/lint-httpapi.sh
|
||||
- name: Check migration pairs (DEP-03)
|
||||
run: sh scripts/check-migrations-pair.sh
|
||||
- name: Validate remote speaker compose
|
||||
run: sh scripts/validate-remote-speaker-compose.sh
|
||||
# go.mod: go 1.24 — бинарник golangci-lint < v1.64.2 (сборка на Go 1.23) не запускается.
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v6
|
||||
with:
|
||||
version: v1.64.8
|
||||
install-mode: goinstall
|
||||
- name: Test
|
||||
run: go test ./... -race -count=1
|
||||
- name: Build all commands
|
||||
@@ -254,6 +303,8 @@ jobs:
|
||||
cache-dependency-path: package-lock.json
|
||||
- name: Install release tooling
|
||||
run: npm ci
|
||||
- name: Verify releasable commit messages
|
||||
run: node scripts/commit/verify-release-commits.mjs
|
||||
- name: Semantic release
|
||||
run: npx semantic-release
|
||||
env:
|
||||
|
||||
@@ -16,3 +16,4 @@ Thumbs.db
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.*.example
|
||||
|
||||
@@ -2,6 +2,7 @@ run:
|
||||
timeout: 5m
|
||||
|
||||
linters:
|
||||
disable-all: true
|
||||
enable:
|
||||
- gofmt
|
||||
- govet
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"codegraph": {
|
||||
"type": "stdio",
|
||||
"command": "codegraph",
|
||||
"args": [
|
||||
"serve",
|
||||
"--mcp"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
# Local hooks (optional): install with `pre-commit install`
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v5.0.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
- id: end-of-file-fixer
|
||||
- repo: https://github.com/dnephin/pre-commit-golang
|
||||
rev: v0.5.1
|
||||
hooks:
|
||||
- id: go-fmt
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: prettier-web
|
||||
name: prettier (web)
|
||||
entry: bash -c 'cd web && npx prettier --check .'
|
||||
language: system
|
||||
files: ^web/
|
||||
pass_filenames: false
|
||||
@@ -10,6 +10,8 @@
|
||||
{ "type": "feat", "release": "minor" },
|
||||
{ "type": "fix", "release": "patch" },
|
||||
{ "type": "perf", "release": "patch" },
|
||||
{ "type": "ci", "release": "patch" },
|
||||
{ "type": "refactor", "release": "patch" },
|
||||
{ "breaking": true, "release": "major" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
## С чего начать (минимум чтения)
|
||||
|
||||
0. **Инженерные правила** — при изменении кода следовать [.cursor/rules/engineering.mdc](.cursor/rules/engineering.mdc); для `web/` — [.cursor/rules/web-shadcn.mdc](.cursor/rules/web-shadcn.mdc); для `birdfmt` / `pipeline` / BIRD — [.cursor/rules/networking-bird.mdc](.cursor/rules/networking-bird.mdc).
|
||||
0. **Инженерные правила** — при изменении кода следовать [.cursor/rules/engineering.mdc](.cursor/rules/engineering.mdc); для `web/` — [.cursor/rules/web-shadcn.mdc](.cursor/rules/web-shadcn.mdc); для `birdfmt` / `pipeline` / BIRD — [.cursor/rules/networking-bird.mdc](.cursor/rules/networking-bird.mdc). **Context7 (документация библиотек)** — закреплённые ID стека: [.cursor/rules/context7-stack.mdc](.cursor/rules/context7-stack.mdc); скилл [.cursor/skills/context7-evobgp/SKILL.md](.cursor/skills/context7-evobgp/SKILL.md).
|
||||
1. **[docs/README.md](docs/README.md)** — оглавление и роли читателя.
|
||||
2. **[docs/architecture.md](docs/architecture.md)** — компоненты `cmd/`, карта `internal/`, потоки данных (одного этого файла обычно достаточно для ориентации).
|
||||
3. Задача-специфично: [docs/api.md](docs/api.md), [docs/access.md](docs/access.md), [web/README.md](web/README.md) — только если меняете API, доступ или фронт.
|
||||
@@ -51,6 +51,7 @@
|
||||
|
||||
- Консоль пользователя: **PowerShell**; пути в стиле `deploy\compose`.
|
||||
- Быстрый старт и переменные: [docs/quickstart.md](docs/quickstart.md), [README.md](README.md).
|
||||
- **Go:** после правок — `gofmt -w`, `go vet ./...`, `scripts/lint-go.ps1` (как CI golangci-lint).
|
||||
|
||||
## Язык документации проекта
|
||||
|
||||
@@ -58,4 +59,12 @@
|
||||
|
||||
## Svelte / фронтенд
|
||||
|
||||
При правках `web/**/*.svelte` или Svelte-модулей следуйте навыкам/инструментам проекта (официальный Svelte MCP и скиллы Cursor, если подключены).
|
||||
При правках `web/**/*.svelte` или Svelte-модулей следуйте [.cursor/rules/web-shadcn.mdc](.cursor/rules/web-shadcn.mdc) (**WEB-19**): перед завершением задачи **обязательно**:
|
||||
|
||||
```powershell
|
||||
cd web
|
||||
npm run check
|
||||
npm run lint
|
||||
```
|
||||
|
||||
Если `lint` падает — `npx prettier --write .` и повторить обе команды. CI job `web` не пропускает без этого.
|
||||
|
||||
@@ -6,8 +6,10 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"evobgp/internal/agentserver"
|
||||
"evobgp/internal/birdfmt"
|
||||
)
|
||||
|
||||
@@ -17,12 +19,14 @@ func main() {
|
||||
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")
|
||||
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, " serve Panel→Node HTTP API (POST /v1/agent/sync)\n")
|
||||
flag.PrintDefaults()
|
||||
}
|
||||
flag.Parse()
|
||||
@@ -40,9 +44,21 @@ func main() {
|
||||
ctl.Birdc = *birdc
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
|
||||
defer cancel()
|
||||
switch args[0] {
|
||||
case "serve":
|
||||
runServe(*listen, *timeout)
|
||||
case "parse-check", "configure", "watch":
|
||||
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
|
||||
defer cancel()
|
||||
runBirdCommand(ctx, args, ctl, *watchEvery, *timeout)
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown command: %s\n", args[0])
|
||||
flag.Usage()
|
||||
os.Exit(2)
|
||||
}
|
||||
}
|
||||
|
||||
func runBirdCommand(ctx context.Context, args []string, ctl *birdfmt.BirdCtl, watchEvery, timeout time.Duration) {
|
||||
switch args[0] {
|
||||
case "parse-check":
|
||||
if len(args) != 2 {
|
||||
@@ -63,23 +79,51 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
case "watch":
|
||||
if *watchEvery <= 0 {
|
||||
if watchEvery <= 0 {
|
||||
fmt.Fprintln(os.Stderr, "watch-interval must be > 0")
|
||||
os.Exit(2)
|
||||
}
|
||||
log.Printf("evobgp-agent watch: birdc configure every %s (socket=%q)", *watchEvery, *socket)
|
||||
log.Printf("evobgp-agent watch: birdc configure every %s (socket=%q)", watchEvery, ctl.Socket)
|
||||
for {
|
||||
cctx, cancel := context.WithTimeout(context.Background(), *timeout)
|
||||
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)
|
||||
time.Sleep(watchEvery)
|
||||
}
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown command: %s\n", args[0])
|
||||
flag.Usage()
|
||||
}
|
||||
}
|
||||
|
||||
func runServe(listenFlag string, syncTimeout time.Duration) {
|
||||
cfg, err := agentserver.ConfigFromEnv()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(2)
|
||||
}
|
||||
if listenFlag != "" {
|
||||
cfg.Listen = listenFlag
|
||||
}
|
||||
if syncTimeout > 0 {
|
||||
cfg.SyncTimeout = syncTimeout
|
||||
}
|
||||
var mu sync.Mutex
|
||||
var lastRev string
|
||||
var lastAt time.Time
|
||||
cfg.LastSync = func() (string, time.Time) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return lastRev, lastAt
|
||||
}
|
||||
cfg.OnSyncSuccess = func(rev string) {
|
||||
mu.Lock()
|
||||
lastRev = rev
|
||||
lastAt = time.Now().UTC()
|
||||
mu.Unlock()
|
||||
}
|
||||
if err := agentserver.ListenAndServe(cfg); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
+12
-2
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
"evobgp/internal/birdfmt"
|
||||
"evobgp/internal/config"
|
||||
"evobgp/internal/dbcli"
|
||||
"evobgp/internal/deploy"
|
||||
"evobgp/internal/httpapi"
|
||||
"evobgp/internal/ingest"
|
||||
@@ -24,6 +25,9 @@ import (
|
||||
|
||||
// microVPS entrypoint: один процесс — HTTP API и фоновые воркеры scheduler, ingest, render, deploy (общий store и jobs.Registry).
|
||||
func main() {
|
||||
if len(os.Args) > 1 && os.Args[1] == "db" {
|
||||
os.Exit(dbcli.Run(os.Args[2:]))
|
||||
}
|
||||
cfg := config.Load()
|
||||
opts := httpapi.Options{
|
||||
APIKeys: os.Getenv("EVOBGP_API_KEYS"),
|
||||
@@ -52,11 +56,16 @@ func main() {
|
||||
go render.Run(ctx, renderDeps)
|
||||
go deploy.Run(ctx, deployDeps)
|
||||
|
||||
srv.StartBackground(ctx)
|
||||
startBirdMetricsPoller(ctx)
|
||||
|
||||
httpSrv := &http.Server{
|
||||
Addr: cfg.HTTPAddr,
|
||||
Handler: srv.Handler(),
|
||||
Addr: cfg.HTTPAddr,
|
||||
Handler: srv.Handler(),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
ReadTimeout: 60 * time.Second,
|
||||
WriteTimeout: 120 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
}
|
||||
go func() {
|
||||
svc := platform.ServiceName("evobgp-all")
|
||||
@@ -91,6 +100,7 @@ func startBirdMetricsPoller(ctx context.Context) {
|
||||
return birdfmt.ShowProtocols(ctx, socket, birdcBin)
|
||||
},
|
||||
birdfmt.CountEstablishedBGPSessions,
|
||||
birdfmt.ParseBGPProtocolStates,
|
||||
)
|
||||
log.Printf("birdc protocols poller enabled (socket=%s interval=%s)", sock, interval)
|
||||
}
|
||||
|
||||
+13
-3
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
"evobgp/internal/birdfmt"
|
||||
"evobgp/internal/config"
|
||||
"evobgp/internal/dbcli"
|
||||
"evobgp/internal/httpapi"
|
||||
"evobgp/internal/observability"
|
||||
"evobgp/internal/platform"
|
||||
@@ -19,6 +20,9 @@ import (
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) > 1 && os.Args[1] == "db" {
|
||||
os.Exit(dbcli.Run(os.Args[2:]))
|
||||
}
|
||||
cfg := config.Load()
|
||||
seedDemo := os.Getenv("EVOBGP_SEED_DEMO") != "0"
|
||||
opts := httpapi.Options{
|
||||
@@ -39,11 +43,16 @@ func main() {
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
srv.StartBackground(ctx)
|
||||
startBirdMetricsPoller(ctx)
|
||||
|
||||
httpSrv := &http.Server{
|
||||
Addr: cfg.HTTPAddr,
|
||||
Handler: srv.Handler(),
|
||||
Addr: cfg.HTTPAddr,
|
||||
Handler: srv.Handler(),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
ReadTimeout: 60 * time.Second,
|
||||
WriteTimeout: 120 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
}
|
||||
go func() {
|
||||
svc := platform.ServiceName("evobgp-api")
|
||||
@@ -53,7 +62,7 @@ func main() {
|
||||
tid, mCDN, mIP, rev, sp := srv.Store().DemoIDs()
|
||||
log.Printf("demo tenant=%s module_cdn=%s module_ip_ranges=%s revision=%s speaker=%s", tid, mCDN, mIP, rev, sp)
|
||||
log.Printf("example: EVOBGP_API_KEYS=op|%s|operator,node|%s|node", tid, tid)
|
||||
log.Printf("with EVOBGP_DEV_INSECURE=1 use Authorization: Bearer dev (operator, demo tenant only)")
|
||||
log.Printf("demo auth: Authorization: Bearer dev (operator, demo tenant only)")
|
||||
}
|
||||
if err := httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatal(err)
|
||||
@@ -84,6 +93,7 @@ func startBirdMetricsPoller(ctx context.Context) {
|
||||
return birdfmt.ShowProtocols(ctx, socket, birdcBin)
|
||||
},
|
||||
birdfmt.CountEstablishedBGPSessions,
|
||||
birdfmt.ParseBGPProtocolStates,
|
||||
)
|
||||
log.Printf("birdc protocols poller enabled (socket=%s interval=%s)", sock, interval)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/** @type {import('@commitlint/types').UserConfig} */
|
||||
module.exports = {
|
||||
extends: ['@commitlint/config-conventional'],
|
||||
plugins: [
|
||||
{
|
||||
rules: {
|
||||
'scope-no-commas': ({ scope }) => {
|
||||
if (scope && scope.includes(',')) {
|
||||
return [
|
||||
false,
|
||||
'scope must not contain commas (semantic-release will not parse the commit type)'
|
||||
];
|
||||
}
|
||||
return [true];
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
rules: {
|
||||
'scope-no-commas': [2, 'always']
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
# TLS для Traefik (profile production). Скопируйте в .env.remote-speaker-tls
|
||||
|
||||
# FQDN agent API (DNS only в Cloudflare → IP этой VPS)
|
||||
AGENT_DOMAIN=bgp-dc2.example.com
|
||||
|
||||
# Let's Encrypt + Cloudflare DNS challenge (как evobgp-edge на CP)
|
||||
LETSENCRYPT_EMAIL=ops@example.com
|
||||
CF_DNS_API_TOKEN=
|
||||
|
||||
# IP основного сервера (Panel) — единственный источник wake-up / health
|
||||
PANEL_IP_WHITELIST=203.0.113.1/32
|
||||
@@ -0,0 +1,22 @@
|
||||
# Скопируйте в .env.remote-speaker рядом с docker-compose.remote-speaker.yaml
|
||||
# Значения agent_secret и node token — из Web UI после создания спикера.
|
||||
|
||||
EVOBGP_REGISTRY=git.shts.su/denozord
|
||||
EVOBGP_IMAGE_TAG=latest
|
||||
|
||||
# Control plane (HTTPS в prod)
|
||||
EVOBGP_CONTROL_PLANE_URL=https://cp.example.com:8080
|
||||
|
||||
# Из карточки спикера в панели
|
||||
EVOBGP_SPEAKER_ID=00000000-0000-0000-0000-000000000001
|
||||
EVOBGP_AGENT_SECRET=change-me-from-ui-once
|
||||
EVOBGP_NODE_TOKEN=evobgp_node_token_from_access
|
||||
|
||||
# GET /v1/bundle/signing-public-key (operator) или env CP EVOBGP_BUNDLE_SEED_HEX
|
||||
EVOBGP_BUNDLE_PUBKEY_BASE64=
|
||||
|
||||
# Fallback polling (profile fallback)
|
||||
EVOBGP_SYNC_INTERVAL_SEC=300
|
||||
|
||||
# Lab profile plain — порт agent на хосте
|
||||
EVOBGP_AGENT_PORT=8443
|
||||
@@ -0,0 +1,155 @@
|
||||
# Удалённый BGP-спикер (Remnawave-style): bird2 + evobgp-agent + Traefik (LE).
|
||||
# См. docs/remote-speakers.md
|
||||
#
|
||||
# cp .env.remote-speaker.example .env.remote-speaker
|
||||
# cp .env.remote-speaker-tls.example .env.remote-speaker-tls
|
||||
# docker compose -f docker-compose.remote-speaker.yaml \
|
||||
# --env-file .env.remote-speaker --env-file .env.remote-speaker-tls up -d
|
||||
#
|
||||
# Profiles:
|
||||
# production (default) — bird2 host + agent + evobgp-edge
|
||||
# plain — bird2 + agent без Traefik (lab)
|
||||
# fallback — + sync-bundle polling
|
||||
|
||||
name: evobgp-remote-speaker
|
||||
|
||||
x-logging: &default-logging
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
services:
|
||||
bird2:
|
||||
profiles: ["production", "plain", "fallback"]
|
||||
image: ${EVOBGP_REGISTRY:-git.shts.su/denozord}/evobgp-bird2:${EVOBGP_IMAGE_TAG:-latest}
|
||||
restart: unless-stopped
|
||||
network_mode: host
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
# sysctls нельзя с network_mode: host — включите ip_forward на VPS (см. docs/remote-speakers.md)
|
||||
volumes:
|
||||
- bird_etc:/etc/bird
|
||||
- bird_run:/run/bird
|
||||
logging: *default-logging
|
||||
|
||||
evobgp-agent:
|
||||
profiles: ["production"]
|
||||
image: ${EVOBGP_REGISTRY:-git.shts.su/denozord}/evobgp-agent:${EVOBGP_IMAGE_TAG:-latest}
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- bird2
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
environment:
|
||||
EVOBGP_AGENT_LISTEN: ":8443"
|
||||
EVOBGP_AGENT_SECRET: ${EVOBGP_AGENT_SECRET:?set EVOBGP_AGENT_SECRET}
|
||||
EVOBGP_CONTROL_PLANE_URL: ${EVOBGP_CONTROL_PLANE_URL:?set EVOBGP_CONTROL_PLANE_URL}
|
||||
EVOBGP_NODE_TOKEN: ${EVOBGP_NODE_TOKEN:?set EVOBGP_NODE_TOKEN}
|
||||
EVOBGP_SPEAKER_ID: ${EVOBGP_SPEAKER_ID:?set EVOBGP_SPEAKER_ID}
|
||||
EVOBGP_BUNDLE_PUBKEY_BASE64: ${EVOBGP_BUNDLE_PUBKEY_BASE64:?set EVOBGP_BUNDLE_PUBKEY_BASE64}
|
||||
EVOBGP_BIRD_EXTRACT_DIR: /etc/bird
|
||||
EVOBGP_BIRDC_SOCKET: /run/bird/bird.ctl
|
||||
volumes:
|
||||
- bird_etc:/etc/bird
|
||||
- bird_run:/run/bird
|
||||
entrypoint: ["/usr/local/bin/evobgp-agent"]
|
||||
command: ["serve", "-socket=/run/bird/bird.ctl"]
|
||||
networks:
|
||||
- speaker-net
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.evobgp-agent.rule=Host(`${AGENT_DOMAIN}`)
|
||||
- traefik.http.routers.evobgp-agent.entrypoints=websecure
|
||||
- traefik.http.routers.evobgp-agent.tls=true
|
||||
- traefik.http.routers.evobgp-agent.tls.certresolver=letsencrypt
|
||||
- traefik.http.routers.evobgp-agent.middlewares=panel-ipwhitelist@docker
|
||||
- traefik.http.middlewares.panel-ipwhitelist.ipallowlist.sourcerange=${PANEL_IP_WHITELIST}
|
||||
- traefik.http.services.evobgp-agent.loadbalancer.server.port=8443
|
||||
logging: *default-logging
|
||||
|
||||
evobgp-agent-plain:
|
||||
profiles: ["plain", "fallback"]
|
||||
image: ${EVOBGP_REGISTRY:-git.shts.su/denozord}/evobgp-agent:${EVOBGP_IMAGE_TAG:-latest}
|
||||
restart: unless-stopped
|
||||
network_mode: host
|
||||
depends_on:
|
||||
- bird2
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
environment:
|
||||
EVOBGP_AGENT_LISTEN: "${EVOBGP_AGENT_PORT:-8443}"
|
||||
EVOBGP_AGENT_SECRET: ${EVOBGP_AGENT_SECRET:?set EVOBGP_AGENT_SECRET}
|
||||
EVOBGP_CONTROL_PLANE_URL: ${EVOBGP_CONTROL_PLANE_URL:?set EVOBGP_CONTROL_PLANE_URL}
|
||||
EVOBGP_NODE_TOKEN: ${EVOBGP_NODE_TOKEN:?set EVOBGP_NODE_TOKEN}
|
||||
EVOBGP_SPEAKER_ID: ${EVOBGP_SPEAKER_ID:?set EVOBGP_SPEAKER_ID}
|
||||
EVOBGP_BUNDLE_PUBKEY_BASE64: ${EVOBGP_BUNDLE_PUBKEY_BASE64:?set EVOBGP_BUNDLE_PUBKEY_BASE64}
|
||||
EVOBGP_BIRD_EXTRACT_DIR: /etc/bird
|
||||
EVOBGP_BIRDC_SOCKET: /run/bird/bird.ctl
|
||||
volumes:
|
||||
- bird_etc:/etc/bird
|
||||
- bird_run:/run/bird
|
||||
entrypoint: ["/usr/local/bin/evobgp-agent"]
|
||||
command: ["serve", "-listen=:${EVOBGP_AGENT_PORT:-8443}", "-socket=/run/bird/bird.ctl"]
|
||||
logging: *default-logging
|
||||
|
||||
evobgp-edge:
|
||||
profiles: ["production"]
|
||||
image: traefik:latest
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- evobgp-agent
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
environment:
|
||||
DOCKER_API_VERSION: "1.44"
|
||||
CF_DNS_API_TOKEN: ${CF_DNS_API_TOKEN:?set CF_DNS_API_TOKEN}
|
||||
command:
|
||||
- --api.dashboard=false
|
||||
- --providers.docker=true
|
||||
- --providers.docker.exposedbydefault=false
|
||||
- --entrypoints.web.address=:80
|
||||
- --entrypoints.websecure.address=:443
|
||||
- --entrypoints.web.http.redirections.entrypoint.to=websecure
|
||||
- --entrypoints.web.http.redirections.entrypoint.scheme=https
|
||||
- --certificatesresolvers.letsencrypt.acme.email=${LETSENCRYPT_EMAIL:?set LETSENCRYPT_EMAIL}
|
||||
- --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json
|
||||
- --certificatesresolvers.letsencrypt.acme.dnschallenge=true
|
||||
- --certificatesresolvers.letsencrypt.acme.dnschallenge.provider=cloudflare
|
||||
- --certificatesresolvers.letsencrypt.acme.dnschallenge.delaybeforecheck=15
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- traefik_letsencrypt:/letsencrypt
|
||||
networks:
|
||||
- speaker-net
|
||||
logging: *default-logging
|
||||
|
||||
sync-bundle:
|
||||
profiles: ["fallback"]
|
||||
image: ${EVOBGP_REGISTRY:-git.shts.su/denozord}/evobgp-node:${EVOBGP_IMAGE_TAG:-latest}
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- bird2
|
||||
environment:
|
||||
EVOBGP_CONTROL_PLANE_URL: ${EVOBGP_CONTROL_PLANE_URL:?set EVOBGP_CONTROL_PLANE_URL}
|
||||
EVOBGP_NODE_TOKEN: ${EVOBGP_NODE_TOKEN:?set EVOBGP_NODE_TOKEN}
|
||||
EVOBGP_SPEAKER_ID: ${EVOBGP_SPEAKER_ID:?set EVOBGP_SPEAKER_ID}
|
||||
EVOBGP_BUNDLE_PUBKEY_BASE64: ${EVOBGP_BUNDLE_PUBKEY_BASE64:?set EVOBGP_BUNDLE_PUBKEY_BASE64}
|
||||
EVOBGP_SYNC_INTERVAL_SEC: ${EVOBGP_SYNC_INTERVAL_SEC:-300}
|
||||
volumes:
|
||||
- bird_etc:/etc/bird
|
||||
- bird_run:/run/bird
|
||||
- ../../scripts/sync-bundle.sh:/usr/local/bin/sync-bundle.sh:ro
|
||||
entrypoint: ["/bin/sh", "/usr/local/bin/sync-bundle.sh"]
|
||||
network_mode: host
|
||||
logging: *default-logging
|
||||
|
||||
networks:
|
||||
speaker-net:
|
||||
|
||||
volumes:
|
||||
bird_etc:
|
||||
bird_run:
|
||||
traefik_letsencrypt:
|
||||
name: evobgp_speaker_traefik_letsencrypt
|
||||
@@ -96,7 +96,7 @@ services:
|
||||
<<: *env-ref
|
||||
EVOBGP_HTTP_ADDR: ":8080"
|
||||
EVOBGP_SEED_DEMO: "1"
|
||||
# Local reference only: allows Bearer dev for scheduler HTTP client (EVOBGP_SCHEDULER_BEARER).
|
||||
# DEV ONLY — не для production. Bearer dev + слабые demo-секреты (см. docs/access.md).
|
||||
EVOBGP_DEV_INSECURE: "1"
|
||||
EVOBGP_BIRDC_SOCKET: /run/bird/bird.ctl
|
||||
EVOBGP_BIRDC_INTERVAL: 30s
|
||||
|
||||
@@ -135,6 +135,7 @@ services:
|
||||
EVOBGP_BIRDC_INTERVAL: 30s
|
||||
EVOBGP_BIRD_ACTIVE_DIR: /etc/bird
|
||||
EVOBGP_BIRD_STAGING_DIR: /tmp/evobgp-bird-staging
|
||||
# DEV ONLY — не для production (см. docs/access.md).
|
||||
EVOBGP_DEV_INSECURE: "1"
|
||||
volumes:
|
||||
- bird_etc:/etc/bird
|
||||
|
||||
@@ -5,8 +5,14 @@ server {
|
||||
gzip on;
|
||||
gzip_types text/css application/javascript application/json image/svg+xml;
|
||||
|
||||
# Docker embedded DNS: без resolver nginx кэширует IP upstream при старте —
|
||||
# после recreate evobgp-all остаётся 502 (connection refused на старый IP).
|
||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||
set $evobgp_upstream evobgp-api;
|
||||
|
||||
location /v1/ {
|
||||
proxy_pass http://evobgp-api:8080/v1/;
|
||||
# С переменной в proxy_pass нельзя полагаться на замену URI — передаём $request_uri целиком.
|
||||
proxy_pass http://$evobgp_upstream:8080$request_uri;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
@@ -15,7 +21,7 @@ server {
|
||||
}
|
||||
|
||||
location = /metrics {
|
||||
proxy_pass http://evobgp-api:8080/metrics;
|
||||
proxy_pass http://$evobgp_upstream:8080/metrics;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
| [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`) |
|
||||
| [access.md](access.md) | Выдача доступа: API-ключи, роли, нода, CORS |
|
||||
| [remote-speakers.md](remote-speakers.md) | Удалённые BGP-реплики: Traefik, agent sync, compose |
|
||||
| [releasing.md](releasing.md) | Автоматические релизы, Conventional Commits, CI |
|
||||
| [openapi.yaml](openapi.yaml) | Источник правды по контракту API |
|
||||
| [OPENAPI-GITEA.md](OPENAPI-GITEA.md) | Как открыть HTML-документацию API (в т.ч. из Gitea) |
|
||||
|
||||
+48
-4
@@ -22,6 +22,19 @@ opkey|01ARZ3NDEKTSV4RRFFQ69G5FAV|operator,nodekey|01ARZ3NDEKTSV4RRFFQ69G5FAV|nod
|
||||
|
||||
При включённом демо-сиде сервер при старте может вывести в лог готовую подсказку с реальным `tenant_id` из БД — см. лог `evobgp-api` / `evobgp-all`.
|
||||
|
||||
Ключи из `EVOBGP_API_KEYS` загружаются при старте и **дополняют** ключи из таблицы `api_key` в БД (break-glass / bootstrap). После первого operator-ключа можно создавать остальные через API или веб-настройки.
|
||||
|
||||
### Управление через API и UI
|
||||
|
||||
При подключённой БД operator может:
|
||||
|
||||
- `GET|POST /v1/api-keys`, `GET|PATCH|DELETE /v1/api-keys/{id}`, `POST /v1/api-keys/{id}/rotate` — см. OpenAPI, тег **API keys**.
|
||||
- В веб-панели: **Права доступа** (`/access`) → блок «API-ключи» (только для роли `operator`). Токен для браузера — в **Настройки** (`/settings`).
|
||||
|
||||
Полный токен возвращается **один раз** в ответе `201` (создание) и `200` (ротация). В списках — только `prefix` (первые 8 символов). В БД хранится SHA-256 токена, не plaintext.
|
||||
|
||||
`GET /v1/auth/session` — текущие `tenant_id` и `role` (для UI).
|
||||
|
||||
### Роли
|
||||
|
||||
| Роль | Уровень | Назначение |
|
||||
@@ -33,11 +46,28 @@ opkey|01ARZ3NDEKTSV4RRFFQ69G5FAV|operator,nodekey|01ARZ3NDEKTSV4RRFFQ69G5FAV|nod
|
||||
|
||||
Обратное ограничение: для эндпоинтов ноды требуется именно роль **`node`**; остальные роли получают отказ.
|
||||
|
||||
### Режим разработки `EVOBGP_DEV_INSECURE`
|
||||
### Токен `dev` (локальная разработка)
|
||||
|
||||
Если установлено `EVOBGP_DEV_INSECURE=1` и в store доступен демо-tenant (`DemoIDs`), то запрос с заголовком **`Authorization: Bearer dev`** получает контекст **`operator`** для этого tenant.
|
||||
Если в store доступен демо-tenant (`DemoIDs`, обычно `EVOBGP_SEED_DEMO` не равен `0`), заголовок **`Authorization: Bearer dev`** даёт роль **`operator`** для этого tenant. **Не зависит** от `EVOBGP_DEV_INSECURE`.
|
||||
|
||||
**Запрещено** в продакшене: любой, кто знает заголовок, получает полные права оператора на демо-данные.
|
||||
**Запрещено** в продакшене: не оставляйте demo-seed с известным токеном `dev` на боевых данных. Переменная `EVOBGP_DEV_INSECURE` в текущей версии **не влияет** на аутентификацию (оставлена в compose для совместимости; не включайте в production — см. SEC-02 в инженерных правилах).
|
||||
|
||||
### PostgreSQL monitoring и maintenance (control plane)
|
||||
|
||||
При `EVOBGP_DATABASE_URL` (не memory backend):
|
||||
|
||||
| Операция | Минимальная роль |
|
||||
|----------|------------------|
|
||||
| `GET /v1/monitoring/postgres/*`, `GET /v1/monitoring/correlation` | viewer |
|
||||
| `POST /v1/postgres/vacuum`, `vacuum-analyze`, `analyze`, `reindex`, `cleanup` | **operator** (async job, rate limit 60s на kind) |
|
||||
| `GET /v1/postgres/maintenance/logs` | viewer |
|
||||
|
||||
Метрики **instance-level** (не per-tenant). CLI: `evobgp-api db …` / `evobgp-all db …`.
|
||||
|
||||
### Синхронные «тяжёлые» GET (control plane)
|
||||
|
||||
- `POST /v1/modules/{module_id}/cdn-sources/preview` — загрузка CDN в том же HTTP-запросе (лимит тела ~8 MiB, см. OpenAPI).
|
||||
- `GET /v1/bird/status` (если маршрут включён в деплое) — опрос локального `birdc`, таймаут сервера ~12 с.
|
||||
|
||||
### Детерминированный ключ подписи бандлов (тесты)
|
||||
|
||||
@@ -45,7 +75,9 @@ opkey|01ARZ3NDEKTSV4RRFFQ69G5FAV|operator,nodekey|01ARZ3NDEKTSV4RRFFQ69G5FAV|nod
|
||||
|
||||
## Публичный ключ бандла для нод
|
||||
|
||||
При старте API в лог печатается строка **bundle signing public key (base64)**. Её нужно передать администратору реплики и использовать в `evobgp-node`:
|
||||
При старте API в лог печатается строка **bundle signing public key (base64)**. Альтернатива для operator: **`GET /v1/bundle/signing-public-key`** → поле `public_key_base64` для `EVOBGP_BUNDLE_PUBKEY_BASE64` на реплике.
|
||||
|
||||
Использование в `evobgp-node` / agent:
|
||||
|
||||
```text
|
||||
evobgp-node verify-bundle -f bundle.tar.gz -pubkey-base64 "<из_лога_API>"
|
||||
@@ -58,6 +90,17 @@ evobgp-node apply-bundle -f bundle.tar.gz -extract-dir /path/to/dir -pubkey-base
|
||||
evobgp-node pull-bundle -base-url http://control.example:8080 -token "<node_token>" -speaker-id "<uuid>"
|
||||
```
|
||||
|
||||
## Panel→Node dispatch (удалённые спикеры)
|
||||
|
||||
На control plane (prod):
|
||||
|
||||
```text
|
||||
EVOBGP_NODE_DISPATCH_ENABLED=1
|
||||
EVOBGP_BUNDLE_SEED_HEX=<32 bytes hex, стабильный>
|
||||
```
|
||||
|
||||
После `deploy_apply` CP шлёт `POST https://AGENT_DOMAIN/v1/agent/sync` с `Authorization: Bearer <agent_secret>`. На реплике — `EVOBGP_AGENT_SECRET`, Traefik `PANEL_IP_WHITELIST`. Подробнее: [remote-speakers.md](remote-speakers.md).
|
||||
|
||||
## CORS для веб-интерфейса
|
||||
|
||||
Браузерные запросы с другого origin требуют заголовков CORS на API. Задайте список разрешённых origin через **`EVOBGP_CORS_ORIGINS`** (через запятую), например:
|
||||
@@ -88,6 +131,7 @@ http://localhost:5173,http://127.0.0.1:5173,https://ui.example.com
|
||||
| GET модули, ревизии, peers, speakers | да | да | да | нет |
|
||||
| POST/PATCH/DELETE CRUD сущностей | нет | да | да | нет |
|
||||
| apply, rollback, PATCH settings | нет | нет | да | нет |
|
||||
| Управление API-ключами (`/v1/api-keys`) | нет | нет | да | нет |
|
||||
| bundle, latest revision, enroll | нет | нет | нет | да |
|
||||
|
||||
Точные проверки по каждому маршруту — в коде `internal/httpapi` и в схеме безопасности операций в OpenAPI.
|
||||
|
||||
@@ -44,6 +44,12 @@
|
||||
- `GET|POST /v1/communities`
|
||||
- `GET|PATCH|DELETE /v1/communities/{id}`
|
||||
|
||||
### API keys
|
||||
|
||||
- `GET /v1/auth/session` — tenant и роль текущего ключа
|
||||
- `GET|POST /v1/api-keys` — список и создание (operator)
|
||||
- `GET|PATCH|DELETE /v1/api-keys/{id}`, `POST /v1/api-keys/{id}/rotate`
|
||||
|
||||
### Peers
|
||||
|
||||
- `GET /v1/peers`, `POST /v1/peers`
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
| `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` по сокету). |
|
||||
| `evobgp-agent` | Локальный агент рядом с BIRD: `watch`, **`serve`** (Panel→Node sync API на реплике). |
|
||||
|
||||
В Docker Compose профиль **reference** запускает отдельные контейнеры под `evobgp-api` и четыре воркера; профиль **microvps** использует один контейнер `evobgp-all`.
|
||||
|
||||
@@ -40,8 +40,12 @@
|
||||
| `observability` | Метрики Prometheus, HTTP middleware. |
|
||||
| `broker` | Опциональный `EVOBGP_BROKER_URL` для будущей шины; сейчас задачи только in-process (`jobs.Registry`), пакет лишь логирует факт настройки URL. |
|
||||
| `pipeline` | Ingest+render в одном шаге для `module_refresh`: выборка префиксов (CDN/AS/IP/пустые DOMAINS), `CreateRenderRevision`, превью BIRD через `birdfmt`. |
|
||||
| `nodedispatch` | Panel→Node HTTP wake-up (`POST /v1/agent/sync`) после `deploy_apply`. |
|
||||
| `agentserver` | HTTP API на реплике (`serve`): sync + health для Traefik. |
|
||||
|
||||
## Диаграмма: эталонный Compose (reference)
|
||||
## Удалённые спикеры
|
||||
|
||||
Реплики на отдельных VPS: [remote-speakers.md](remote-speakers.md). CP публикует ревизию и при `EVOBGP_NODE_DISPATCH_ENABLED=1` будит agent; agent тянет signed bundle и применяет BIRD. Compose: `deploy/compose/docker-compose.remote-speaker.yaml`.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
# Диагностика схемы PostgreSQL (EvoBGP)
|
||||
|
||||
Runbook для оценки объёма БД и узких мест **перед** и **после** миграций оптимизации схемы. Выполнять на staging или production read-only сессией.
|
||||
|
||||
## Подключение
|
||||
|
||||
```bash
|
||||
psql "$EVOBGP_DATABASE_URL"
|
||||
```
|
||||
|
||||
## HTTP API (панель / мониторинг)
|
||||
|
||||
При подключённом PostgreSQL control plane отдаёт instance-level метрики (роль **viewer+**):
|
||||
|
||||
- `GET /v1/monitoring/postgres/overview` — подключения, TPS, cache hit, размер БД
|
||||
- `GET /v1/monitoring/postgres/queries` — top queries (`pg_stat_statements`, если extension включён)
|
||||
- `GET /v1/monitoring/postgres/locks`, `/tables`, `/recommendations`
|
||||
- `GET /v1/monitoring/correlation?window=60` — корреляция refresh jobs и cache hit
|
||||
|
||||
Обслуживание (**operator**, async `202` + `job_id`): `POST /v1/postgres/vacuum`, `vacuum-analyze`, `analyze`, `reindex`, `cleanup`; журнал `GET /v1/postgres/maintenance/logs`.
|
||||
|
||||
CLI на CP: `evobgp-api db report|vacuum|analyze|cleanup` (см. `internal/dbcli`).
|
||||
|
||||
Миграция `000023` создаёт `pg_stat_statements`; для сбора статистики **обязательно** preload и перезапуск Postgres:
|
||||
|
||||
```text
|
||||
# postgresql.conf или command в compose
|
||||
shared_preload_libraries = 'pg_stat_statements'
|
||||
```
|
||||
|
||||
После изменения — restart контейнера/сервиса Postgres. Без этого API `/v1/monitoring/postgres/queries` вернёт пустой список (`statements_available: false`), без 5xx.
|
||||
|
||||
## 1. Размеры таблиц и индексов
|
||||
|
||||
```sql
|
||||
SELECT relname,
|
||||
pg_size_pretty(pg_total_relation_size(relid)) AS total,
|
||||
pg_size_pretty(pg_relation_size(relid)) AS heap,
|
||||
pg_size_pretty(pg_indexes_size(relid)) AS indexes
|
||||
FROM pg_catalog.pg_statio_user_tables
|
||||
ORDER BY pg_total_relation_size(relid) DESC;
|
||||
```
|
||||
|
||||
**Ожидание:** лидеры — `revision_materialized_prefix`, `config_revision` (TOAST от preview), JSONB-кэши.
|
||||
|
||||
## 2. Seq scan (горячие таблицы)
|
||||
|
||||
```sql
|
||||
SELECT schemaname, relname, seq_scan, seq_tup_read, idx_scan
|
||||
FROM pg_stat_user_tables
|
||||
WHERE schemaname = 'public'
|
||||
ORDER BY seq_tup_read DESC;
|
||||
```
|
||||
|
||||
Сброс статистики после деплоя: `SELECT pg_stat_reset();` (только осознанно, теряется baseline).
|
||||
|
||||
## 3. Неиспользуемые индексы
|
||||
|
||||
```sql
|
||||
SELECT indexrelname, idx_scan, pg_size_pretty(pg_relation_size(indexrelid)) AS size
|
||||
FROM pg_stat_user_indexes
|
||||
WHERE schemaname = 'public' AND idx_scan = 0
|
||||
ORDER BY pg_relation_size(indexrelid) DESC;
|
||||
```
|
||||
|
||||
## 4. Дубликаты в materialized prefixes
|
||||
|
||||
Перед UNIQUE `(revision_id, prefix, community_id, source)`:
|
||||
|
||||
```sql
|
||||
SELECT revision_id, prefix, community_id, source, COUNT(*) AS n
|
||||
FROM revision_materialized_prefix
|
||||
GROUP BY 1, 2, 3, 4
|
||||
HAVING COUNT(*) > 1
|
||||
LIMIT 20;
|
||||
```
|
||||
|
||||
## 5. Шаблон отчёта staging
|
||||
|
||||
| Метрика | До | После | Дата |
|
||||
|---------|-----|-------|------|
|
||||
| `revision_materialized_prefix` total | | | |
|
||||
| `config_revision` total | | | |
|
||||
| `module_prefix_snapshot` total | | | |
|
||||
| `asn_prefix_cache` total | | | |
|
||||
| Top seq_scan table | | | |
|
||||
| Unused indexes (count) | | | |
|
||||
|
||||
## 6. EXPLAIN для типовых запросов
|
||||
|
||||
```sql
|
||||
-- Список префиксов ревизии (keyset)
|
||||
EXPLAIN (ANALYZE, BUFFERS)
|
||||
SELECT prefix::text, community_id::text, source
|
||||
FROM revision_materialized_prefix
|
||||
WHERE revision_id = '<revision-uuid>'::uuid
|
||||
ORDER BY id
|
||||
LIMIT 51;
|
||||
|
||||
-- Diff added (anti-join)
|
||||
EXPLAIN (ANALYZE, BUFFERS)
|
||||
SELECT b.prefix::text
|
||||
FROM revision_materialized_prefix b
|
||||
LEFT JOIN revision_materialized_prefix a
|
||||
ON a.revision_id = '<rev-a>'::uuid AND a.prefix = b.prefix
|
||||
WHERE b.revision_id = '<rev-b>'::uuid
|
||||
AND a.prefix IS NULL
|
||||
ORDER BY b.prefix
|
||||
LIMIT 5001;
|
||||
```
|
||||
|
||||
Цель: Index Scan / Bitmap Index Scan по `(revision_id, …)`, без Seq Scan на больших таблицах.
|
||||
@@ -104,6 +104,7 @@ EvoBGP управляет генерацией и применением BGP-к
|
||||
### Настройки (`/v1/settings`)
|
||||
- KV c ключами BIRD и дополнительными feature flags.
|
||||
- Ключевые параметры BIRD: `bird_router_id`, `bird_local_ipv4`, `bird_local_ipv6`, `bird_local_asn`, `bird_bgp_source_ipv4`, `bird_bgp_source_ipv6`.
|
||||
- **Tenant settings** — глобальный default. **Per-speaker** override: `meta_json.bird_bgp_source_ipv4` / `node_ipv4` в карточке спикера (Web UI → Сеть → Спикеры); pipeline накладывает overlay при сборке бандла для реплики. См. [remote-speakers.md](remote-speakers.md).
|
||||
|
||||
## 7. Эксплуатация и runbook
|
||||
|
||||
|
||||
+1124
-2
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
||||
# Production checklist
|
||||
|
||||
Краткий чеклист перед выводом EvoBGP в production (10+ клиентов, нестабильная сеть).
|
||||
|
||||
## Обязательно
|
||||
|
||||
- `EVOBGP_SEED_DEMO=0` — отключить demo-tenant и токен `Bearer dev`.
|
||||
- `EVOBGP_DEV_INSECURE` не задавать или `0` — не использовать lab-флаги в prod.
|
||||
- `EVOBGP_BUNDLE_SEED_HEX` — задать стабильный hex-ключ подписи бандлов; сохранить pubkey для нод.
|
||||
- PostgreSQL с TLS (`sslmode` не `disable`) при доступе вне private network.
|
||||
- `EVOBGP_CORS_ORIGINS` — явный whitelist origin веб-панели.
|
||||
- `EVOBGP_STALE_ON_UPSTREAM_ERROR=1` (по умолчанию) — stale snapshot при сбоях CDN/ASN/DoH.
|
||||
|
||||
## Рекомендуется
|
||||
|
||||
- `EVOBGP_JOB_MAX_CONCURRENT=16`, `EVOBGP_DB_MAX_CONNS=25`, `EVOBGP_COLLECT_CONCURRENCY=16` при росте tenants.
|
||||
- `EVOBGP_NODE_DISPATCH_INSECURE_TLS=0` — только валидный TLS к agent.
|
||||
- Ограничить `/metrics` сетевой политикой или reverse proxy.
|
||||
- Профиль `evobgp-all` или HA API + персистентная `job_audit` (PostgreSQL).
|
||||
- Мониторинг drift: `evobgp-deploy`, `last_applied_revision_id` vs published.
|
||||
|
||||
## Не использовать в prod
|
||||
|
||||
- `EVOBGP_CDN_ALLOW_PRIVATE=1` — только тесты/lab.
|
||||
- Plaintext `EVOBGP_API_KEYS` без ротации (break-glass — временно).
|
||||
- Ручное редактирование `evobgp_*.conf` на нодах без ревизии.
|
||||
@@ -203,6 +203,10 @@ docker compose --profile reference up -d
|
||||
|
||||
В **evobgp-all** (microvps) те же пакеты крутятся в одном процессе и используют общий `jobs.Registry` без HTTP.
|
||||
|
||||
## Удалённые BGP-спикеры
|
||||
|
||||
Реплики на отдельных VPS (bird2 + agent + Traefik): см. **[remote-speakers.md](remote-speakers.md)**. На CP включите `EVOBGP_NODE_DISPATCH_ENABLED=1` и зафиксируйте `EVOBGP_BUNDLE_SEED_HEX`. Compose: `deploy/compose/docker-compose.remote-speaker.yaml`.
|
||||
|
||||
## Вариант 3: Локально без Docker (только API)
|
||||
|
||||
1. Поднимите PostgreSQL и создайте БД (или используйте существующую).
|
||||
|
||||
+23
-2
@@ -7,9 +7,15 @@ EvoBGP использует [Conventional Commits](https://www.conventionalcommi
|
||||
| Тип коммита | Bump |
|
||||
|-------------|------|
|
||||
| `feat` | minor (1.0.0 → 1.1.0) |
|
||||
| `fix`, `perf` | patch (1.0.0 → 1.0.1) |
|
||||
| `fix`, `perf`, `ci`, `refactor` | patch (1.5.1 → 1.5.2) |
|
||||
| `feat!`, `fix!` или `BREAKING CHANGE:` в теле | major (1.0.0 → 2.0.0) |
|
||||
| `docs`, `chore`, `ci`, `test`, `refactor` | без релиза |
|
||||
| `docs`, `chore`, `test` | без релиза |
|
||||
|
||||
**Scope:** один идентификатор **без запятых** (`web`, `httpapi`, `api`). Заголовок `refactor(a, b): …` **не парсится** semantic-release → релиз не создаётся (commitlint на PR это тоже отклонит). Подробнее — раздел «Scope и semantic-release» ниже.
|
||||
|
||||
`refactor` — patch без новых функций: перестройка кода/UI при том же поведении для пользователя. По semver на одном уровне с `fix`, но семантически «мельче» `feat` (не minor).
|
||||
|
||||
Отдельного суффикса `1.x.y.fix` в semver нет: «fix» в Conventional Commits означает **patch** (третья цифра). Для починки пайплайна без смены продукта — `fix(ci):` или `ci:` (оба дают patch после настройки `.releaserc.json`).
|
||||
|
||||
Первый релиз при отсутствии git-тегов — **1.0.0**, если есть releasable-коммиты.
|
||||
|
||||
@@ -58,6 +64,21 @@ API: `GET /version`, `GET /v1/version` — поля `version`, `git_sha`, `build
|
||||
|
||||
Web UI показывает версию из API (footer sidebar, страница «Мониторинг»).
|
||||
|
||||
## Scope и semantic-release
|
||||
|
||||
Парсер [conventional-commits-parser](https://github.com/conventional-changelog/conventional-changelog/tree/master/packages/conventional-commits-parser) (его использует semantic-release) **не понимает запятые в scope**:
|
||||
|
||||
| Заголовок | Парсится | Релиз |
|
||||
|-----------|----------|-------|
|
||||
| `refactor(web): fix layout` | да, `refactor` | patch |
|
||||
| `refactor(NetworkOverviewTab, NetworkSpeakersCard): fix layout` | **нет**, `type: null` | **нет** |
|
||||
|
||||
Правило: **один scope** из таблицы в [.cursor/rules/conventional-commits.mdc](../.cursor/rules/conventional-commits.mdc) (`web`, `httpapi`, `api`, …).
|
||||
|
||||
На push в `main` job **release** запускает `scripts/commit/verify-release-commits.mjs` — в логе будут предупреждения о непарсящихся коммитах.
|
||||
|
||||
Если релиз «не создался», а CI зелёный: смотрите лог release — часто `No releasable commits`. Исправление: новый коммит с корректным заголовком (например `refactor(web): …`).
|
||||
|
||||
## CHANGELOG
|
||||
|
||||
Release notes — в Gitea Release; файл `CHANGELOG.md` генерируется в CI и прикрепляется как asset, **не** попадает в git history.
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
# Удалённые BGP-спикеры (Remnawave-style)
|
||||
|
||||
Runbook для реплик **bird2 + evobgp-agent** на отдельных VPS. Control plane (`evobgp-all`) инициирует доставку после `module_refresh` → `deploy_apply`; реплика **не** собирает префиксы сама.
|
||||
|
||||
## Модель
|
||||
|
||||
| Remnawave | EvoBGP |
|
||||
|-----------|--------|
|
||||
| Panel → Node:PORT | CP POST `https://AGENT_DOMAIN/v1/agent/sync` |
|
||||
| SECRET_KEY | `agent_secret` (Bearer) |
|
||||
| Copy compose | Web UI → карточка спикера |
|
||||
| Push Xray JSON | Wake-up → pull signed bundle → verify Ed25519 → apply |
|
||||
|
||||
Подробнее: [architecture.md](architecture.md).
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
1. **CP (microvps-full):** зафиксируйте `EVOBGP_BUNDLE_SEED_HEX` (32 байта hex) — стабильный ключ подписи бандлов.
|
||||
2. **Web UI → Сеть → Спикеры:** создайте спикер `role=replica`, укажите **Agent domain**, **IP ноды**, **BGP source** (по умолчанию = IP ноды).
|
||||
3. Сохраните **`agent_secret`** (показывается один раз) и скопируйте **docker-compose** из UI.
|
||||
4. Выдайте **node API-ключ** ([access.md](access.md)) для `EVOBGP_NODE_TOKEN`.
|
||||
5. `GET /v1/bundle/signing-public-key` → `EVOBGP_BUNDLE_PUBKEY_BASE64` на реплике.
|
||||
6. На VPS реплики:
|
||||
```bash
|
||||
cd deploy/compose
|
||||
cp .env.remote-speaker.example .env.remote-speaker
|
||||
cp .env.remote-speaker-tls.example .env.remote-speaker-tls
|
||||
# заполните переменные из UI
|
||||
docker compose -f docker-compose.remote-speaker.yaml \
|
||||
--env-file .env.remote-speaker --env-file .env.remote-speaker-tls \
|
||||
--profile production up -d
|
||||
```
|
||||
7. **CP:** `EVOBGP_NODE_DISPATCH_ENABLED=1` — Panel шлёт wake-up после publish.
|
||||
8. Cloudflare: `AGENT_DOMAIN` → IP VPS, **DNS only** (как Web UI в [quickstart.md](quickstart.md)).
|
||||
|
||||
## Compose-профили
|
||||
|
||||
| Profile | Состав |
|
||||
|---------|--------|
|
||||
| `production` | bird2 (host) + agent + Traefik LE |
|
||||
| `plain` | bird2 + agent на хосте без Traefik (только lab) |
|
||||
| `fallback` | + `sync-bundle` polling (`scripts/sync-bundle.sh`) |
|
||||
|
||||
Файлы: [docker-compose.remote-speaker.yaml](../deploy/compose/docker-compose.remote-speaker.yaml).
|
||||
|
||||
## Firewall
|
||||
|
||||
| Порт | Кто | Зачем |
|
||||
|------|-----|-------|
|
||||
| **443** | IP CP (`PANEL_IP_WHITELIST`) | HTTPS dispatch, health, **`GET /v1/agent/bird/protocols`** (live peer sessions) |
|
||||
| **179** | BGP peers | Data plane |
|
||||
| **80** | ACME | Traefik → 443 |
|
||||
|
||||
## Подготовка VPS (перед `docker compose up`)
|
||||
|
||||
`bird2` — **`network_mode: host`**. Docker **не может** задать `net.ipv4.ip_forward` в таком контейнере; включите на **хосте**:
|
||||
|
||||
```bash
|
||||
sysctl -w net.ipv4.ip_forward=1
|
||||
sysctl -w net.ipv6.conf.all.forwarding=1
|
||||
echo 'net.ipv4.ip_forward=1' | tee /etc/sysctl.d/99-evobgp-bird.conf
|
||||
echo 'net.ipv6.conf.all.forwarding=1' >> /etc/sysctl.d/99-evobgp-bird.conf
|
||||
sysctl --system
|
||||
```
|
||||
|
||||
## Безопасность (три участка)
|
||||
|
||||
1. **CP → реплика:** HTTPS (LE) + Traefik ipallowlist + `agent_secret`.
|
||||
2. **Реплика → CP:** HTTPS + роль `node` (только bundle/latest/enroll).
|
||||
3. **Конфиг:** Ed25519 `bundle.sig`, SHA-256 manifest, `bird -p`, LKG на ноде.
|
||||
|
||||
Prod checklist:
|
||||
|
||||
- [ ] `EVOBGP_CONTROL_PLANE_URL=https://...`
|
||||
- [ ] `EVOBGP_NODE_DISPATCH_ENABLED=1` на CP
|
||||
- [ ] `EVOBGP_BUNDLE_SEED_HEX` на CP (не менять после выдачи pubkey репликам)
|
||||
- [ ] Уникальные `agent_secret` и node token на спикер
|
||||
- [ ] Не использовать profile `plain` в prod
|
||||
- [ ] Не отключать verify-bundle в agent
|
||||
|
||||
## Per-speaker BGP source
|
||||
|
||||
В UI: **IP ноды** (`meta_json.node_ipv4`) и **BGP source IPv4** (`bird_bgp_source_ipv4`, default = IP ноды). Pipeline накладывает overlay при `GET .../bundle/{revision_id}` — меняются `router id` и peer `local`.
|
||||
|
||||
Tenant `/v1/settings` (`bird_bgp_source_ipv4`) — fallback для master / если у спикера не задано.
|
||||
|
||||
## Drift и dispatch
|
||||
|
||||
- `published_revision_id` vs `last_applied_revision_id` — в UI и `evobgp-deploy`.
|
||||
- Job `deploy_apply` meta: `node_dispatch.results[]` — статус wake-up per speaker.
|
||||
- Canary: `POST /v1/speakers/{id}/apply` с `revision_id`.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Симптом | Проверка |
|
||||
|---------|----------|
|
||||
| `sysctl net.ipv4.ip_forward not allowed in host network` | Уберите sysctls из compose (уже так в main); включите ip_forward на VPS (см. выше) |
|
||||
| `no service selected` | `--profile production` или `COMPOSE_PROFILES=production` |
|
||||
| Offline в UI | `GET https://AGENT_DOMAIN/v1/agent/health` с CP; LE cert; whitelist |
|
||||
| dispatch error | CP logs job meta; firewall 443; `agent_secret` |
|
||||
| verify-bundle fail | pubkey совпадает с CP seed; пересоберите pubkey после смены seed |
|
||||
| BGP не поднимается | bird2 `network_mode: host`; peers; MD5 BGP отдельно от HTTP sync |
|
||||
|
||||
## Ограничения (scale-review)
|
||||
|
||||
- Peers **не** фильтруются по `speaker_id` — один tenant-wide peers fragment на все реплики.
|
||||
- Разные peer-наборы per site — отдельная итерация pipeline.
|
||||
- Если Panel не достучится до agent — включите profile `fallback` (polling).
|
||||
|
||||
## Связанные env
|
||||
|
||||
| Переменная | Где |
|
||||
|------------|-----|
|
||||
| `EVOBGP_NODE_DISPATCH_ENABLED=1` | CP |
|
||||
| `EVOBGP_AGENT_SECRET` | реплика |
|
||||
| `EVOBGP_NODE_TOKEN` | реплика |
|
||||
| `EVOBGP_BUNDLE_PUBKEY_BASE64` | реплика |
|
||||
| `PANEL_IP_WHITELIST` | Traefik на реплике |
|
||||
@@ -25,6 +25,7 @@ require (
|
||||
github.com/prometheus/common v0.55.0 // indirect
|
||||
github.com/prometheus/procfs v0.15.1 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/robfig/cron/v3 v3.0.1 // indirect
|
||||
golang.org/x/crypto v0.46.0 // indirect
|
||||
golang.org/x/mod v0.31.0 // indirect
|
||||
golang.org/x/net v0.48.0 // indirect
|
||||
|
||||
@@ -46,6 +46,8 @@ github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0leargg
|
||||
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
package agentserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"evobgp/internal/birdfmt"
|
||||
"evobgp/internal/nodecli"
|
||||
)
|
||||
|
||||
const upstreamErrorDetail = "upstream request failed"
|
||||
|
||||
// Config holds evobgp-agent serve settings.
|
||||
type Config struct {
|
||||
Listen string
|
||||
Secret string
|
||||
ControlPlaneURL string
|
||||
NodeToken string
|
||||
SpeakerID string
|
||||
PubKeyB64 string
|
||||
PubKeyHex string
|
||||
ExtractDir string
|
||||
BirdBin string
|
||||
BirdcBin string
|
||||
Socket string
|
||||
SyncTimeout time.Duration
|
||||
LastSync func() (revisionID string, at time.Time)
|
||||
OnSyncSuccess func(revisionID string)
|
||||
}
|
||||
|
||||
// Server serves Panel→Node internal API (Remnawave-style wake-up).
|
||||
type Server struct {
|
||||
cfg Config
|
||||
mux *http.ServeMux
|
||||
}
|
||||
|
||||
// New builds an agent HTTP server.
|
||||
func New(cfg Config) *Server {
|
||||
s := &Server{cfg: cfg, mux: http.NewServeMux()}
|
||||
s.mux.HandleFunc("GET /v1/agent/health", s.handleHealth)
|
||||
s.mux.HandleFunc("GET /v1/agent/bird/protocols", s.handleBirdProtocols)
|
||||
s.mux.HandleFunc("POST /v1/agent/sync", s.handleSync)
|
||||
return s
|
||||
}
|
||||
|
||||
// Handler returns the root HTTP handler.
|
||||
func (s *Server) Handler() http.Handler {
|
||||
return s.mux
|
||||
}
|
||||
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.authorize(r) {
|
||||
writeProblem(w, http.StatusUnauthorized, "missing or invalid Authorization")
|
||||
return
|
||||
}
|
||||
body := map[string]any{
|
||||
"ok": true,
|
||||
"speaker_id": strings.TrimSpace(s.cfg.SpeakerID),
|
||||
}
|
||||
if s.cfg.LastSync != nil {
|
||||
if rev, at := s.cfg.LastSync(); rev != "" {
|
||||
body["last_applied_revision_id"] = rev
|
||||
body["last_sync_at"] = at.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, body)
|
||||
}
|
||||
|
||||
func (s *Server) handleBirdProtocols(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.authorize(r) {
|
||||
writeProblem(w, http.StatusUnauthorized, "missing or invalid Authorization")
|
||||
return
|
||||
}
|
||||
sock := strings.TrimSpace(s.cfg.Socket)
|
||||
if sock == "" {
|
||||
writeProblem(w, http.StatusServiceUnavailable, "EVOBGP_BIRDC_SOCKET not configured")
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
out, err := birdfmt.ShowProtocols(ctx, sock, strings.TrimSpace(s.cfg.BirdcBin))
|
||||
if err != nil {
|
||||
log.Printf("agentserver: bird protocols: %v", err)
|
||||
writeProblem(w, http.StatusBadGateway, upstreamErrorDetail)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"ok": true,
|
||||
"sessions": birdfmt.ParseBGPSessions(out),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleSync(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.authorize(r) {
|
||||
writeProblem(w, http.StatusUnauthorized, "missing or invalid Authorization")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
RevisionID string `json:"revision_id"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
|
||||
timeout := s.cfg.SyncTimeout
|
||||
if timeout <= 0 {
|
||||
timeout = 45 * time.Second
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), timeout)
|
||||
defer cancel()
|
||||
|
||||
res, err := nodecli.SyncBundle(ctx, nodecli.SyncConfig{
|
||||
BaseURL: s.cfg.ControlPlaneURL,
|
||||
Token: s.cfg.NodeToken,
|
||||
SpeakerID: s.cfg.SpeakerID,
|
||||
RevisionID: strings.TrimSpace(req.RevisionID),
|
||||
PubKeyB64: s.cfg.PubKeyB64,
|
||||
PubKeyHex: s.cfg.PubKeyHex,
|
||||
ExtractDir: s.cfg.ExtractDir,
|
||||
BirdBin: s.cfg.BirdBin,
|
||||
BirdcBin: s.cfg.BirdcBin,
|
||||
Socket: s.cfg.Socket,
|
||||
Timeout: timeout,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("agentserver: sync: %v", err)
|
||||
writeProblem(w, http.StatusBadGateway, upstreamErrorDetail)
|
||||
return
|
||||
}
|
||||
if s.cfg.OnSyncSuccess != nil {
|
||||
s.cfg.OnSyncSuccess(res.RevisionID)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"ok": true,
|
||||
"applied_revision_id": res.RevisionID,
|
||||
"main_config": res.MainConfig,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) authorize(r *http.Request) bool {
|
||||
secret := strings.TrimSpace(s.cfg.Secret)
|
||||
if secret == "" {
|
||||
return false
|
||||
}
|
||||
h := r.Header.Get("Authorization")
|
||||
const prefix = "Bearer "
|
||||
if !strings.HasPrefix(h, prefix) {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(h[len(prefix):]) == secret
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func writeProblem(w http.ResponseWriter, status int, detail string) {
|
||||
w.Header().Set("Content-Type", "application/problem+json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"title": http.StatusText(status),
|
||||
"status": status,
|
||||
"detail": detail,
|
||||
})
|
||||
}
|
||||
|
||||
// ListenAndServe starts the agent HTTP server on cfg.Listen.
|
||||
func ListenAndServe(cfg Config) error {
|
||||
if strings.TrimSpace(cfg.Listen) == "" {
|
||||
cfg.Listen = ":8443"
|
||||
}
|
||||
srv := &http.Server{
|
||||
Addr: cfg.Listen,
|
||||
Handler: New(cfg).Handler(),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
log.Printf("evobgp-agent serve: listening on %s speaker=%s", cfg.Listen, cfg.SpeakerID)
|
||||
return srv.ListenAndServe()
|
||||
}
|
||||
|
||||
// ConfigFromEnv builds Config from EVOBGP_* environment variables.
|
||||
func ConfigFromEnv() (Config, error) {
|
||||
cfg := Config{
|
||||
Listen: envOr("EVOBGP_AGENT_LISTEN", ":8443"),
|
||||
Secret: strings.TrimSpace(os.Getenv("EVOBGP_AGENT_SECRET")),
|
||||
ControlPlaneURL: strings.TrimSpace(os.Getenv("EVOBGP_CONTROL_PLANE_URL")),
|
||||
NodeToken: strings.TrimSpace(os.Getenv("EVOBGP_NODE_TOKEN")),
|
||||
SpeakerID: strings.TrimSpace(os.Getenv("EVOBGP_SPEAKER_ID")),
|
||||
PubKeyB64: strings.TrimSpace(os.Getenv("EVOBGP_BUNDLE_PUBKEY_BASE64")),
|
||||
PubKeyHex: strings.TrimSpace(os.Getenv("EVOBGP_BUNDLE_PUBKEY_HEX")),
|
||||
ExtractDir: envOr("EVOBGP_BIRD_EXTRACT_DIR", "/etc/bird"),
|
||||
BirdBin: strings.TrimSpace(os.Getenv("EVOBGP_BIRD_BIN")),
|
||||
BirdcBin: strings.TrimSpace(os.Getenv("EVOBGP_BIRDC_BIN")),
|
||||
Socket: strings.TrimSpace(os.Getenv("EVOBGP_BIRDC_SOCKET")),
|
||||
SyncTimeout: 45 * time.Second,
|
||||
}
|
||||
if cfg.Secret == "" {
|
||||
return cfg, fmt.Errorf("agentserver: EVOBGP_AGENT_SECRET required")
|
||||
}
|
||||
if cfg.ControlPlaneURL == "" || cfg.NodeToken == "" || cfg.SpeakerID == "" {
|
||||
return cfg, fmt.Errorf("agentserver: EVOBGP_CONTROL_PLANE_URL, EVOBGP_NODE_TOKEN, EVOBGP_SPEAKER_ID required")
|
||||
}
|
||||
if cfg.PubKeyB64 == "" && cfg.PubKeyHex == "" {
|
||||
return cfg, fmt.Errorf("agentserver: EVOBGP_BUNDLE_PUBKEY_BASE64 or EVOBGP_BUNDLE_PUBKEY_HEX required")
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func envOr(key, def string) string {
|
||||
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package agentserver_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"evobgp/internal/agentserver"
|
||||
)
|
||||
|
||||
func TestAgentHealth_requiresAuth(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := httptest.NewServer(agentserver.New(agentserver.Config{
|
||||
Secret: "test-secret",
|
||||
SpeakerID: "sp-1",
|
||||
}).Handler())
|
||||
defer srv.Close()
|
||||
|
||||
resp, err := http.Get(srv.URL + "/v1/agent/health")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("want 401, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/v1/agent/health", nil)
|
||||
req.Header.Set("Authorization", "Bearer test-secret")
|
||||
resp2, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = resp2.Body.Close() }()
|
||||
if resp2.StatusCode != http.StatusOK {
|
||||
t.Fatalf("want 200, got %d", resp2.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentSync_badAuth(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := httptest.NewServer(agentserver.New(agentserver.Config{
|
||||
Secret: "right",
|
||||
SpeakerID: "sp-1",
|
||||
ControlPlaneURL: "http://127.0.0.1:1",
|
||||
NodeToken: "tok",
|
||||
PubKeyB64: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
|
||||
ExtractDir: t.TempDir(),
|
||||
}).Handler())
|
||||
defer srv.Close()
|
||||
|
||||
req, _ := http.NewRequest(http.MethodPost, srv.URL+"/v1/agent/sync", strings.NewReader("{}"))
|
||||
req.Header.Set("Authorization", "Bearer wrong")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("want 401, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"evobgp/internal/httpclient"
|
||||
)
|
||||
|
||||
// DefaultRIPEStatURL is the RIPEstat announced-prefixes data call (no API key).
|
||||
@@ -24,7 +26,7 @@ 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) {
|
||||
if hc == nil {
|
||||
hc = http.DefaultClient
|
||||
hc = httpclient.New(httpclient.DefaultTimeout)
|
||||
}
|
||||
base := strings.TrimSpace(os.Getenv("EVOBGP_RIPESTAT_ANNOUNCED_PREFIXES_URL"))
|
||||
if base == "" {
|
||||
@@ -38,11 +40,11 @@ func AnnouncedPrefixes(ctx context.Context, hc *http.Client, asn int64) ([]netip
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", "evobgp-asnresolve/1.0")
|
||||
|
||||
resp, err := hc.Do(req)
|
||||
resp, err := httpclient.DoWithBreaker(ctx, hc, req, 3)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ripestat fetch AS%d: %w", asn, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -52,8 +54,8 @@ func AnnouncedPrefixes(ctx context.Context, hc *http.Client, asn int64) ([]netip
|
||||
}
|
||||
|
||||
var wrap struct {
|
||||
Status string `json:"status"`
|
||||
Data struct {
|
||||
Status string `json:"status"`
|
||||
Data struct {
|
||||
Prefixes []struct {
|
||||
Prefix string `json:"prefix"`
|
||||
} `json:"prefixes"`
|
||||
@@ -86,7 +88,7 @@ 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) {
|
||||
if hc == nil {
|
||||
hc = http.DefaultClient
|
||||
hc = httpclient.New(httpclient.DefaultTimeout)
|
||||
}
|
||||
base := strings.TrimSpace(os.Getenv("EVOBGP_RIPESTAT_AS_OVERVIEW_URL"))
|
||||
if base == "" {
|
||||
@@ -100,11 +102,11 @@ func ASHolderName(ctx context.Context, hc *http.Client, asn int64) (string, erro
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", "evobgp-asnresolve/1.0")
|
||||
|
||||
resp, err := hc.Do(req)
|
||||
resp, err := httpclient.DoWithBreaker(ctx, hc, req, 3)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("ripestat as-overview AS%d: %w", asn, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// Package authkey generates API tokens and derives lookup hashes (no persistence).
|
||||
package authkey
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
const tokenPrefix = "evobgp_"
|
||||
|
||||
// GenerateToken returns a new bearer token (evobgp_ + 32 random bytes, base64url).
|
||||
func GenerateToken() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", fmt.Errorf("authkey: generate token: %w", err)
|
||||
}
|
||||
return tokenPrefix + base64.RawURLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// HashToken returns SHA-256 of the full token (32 bytes).
|
||||
func HashToken(token string) []byte {
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
return sum[:]
|
||||
}
|
||||
|
||||
// Prefix returns the first 8 characters of the token for display.
|
||||
func Prefix(token string) string {
|
||||
if len(token) <= 8 {
|
||||
return token
|
||||
}
|
||||
return token[:8]
|
||||
}
|
||||
@@ -60,10 +60,10 @@ func RenderBGPTemplates(opts BGPTemplatesOptions) (string, error) {
|
||||
|
||||
// BGPPeerFromTemplateOptions describes protocol bgp NAME from TEMPLATE { … }.
|
||||
type BGPPeerFromTemplateOptions struct {
|
||||
ProtocolName string
|
||||
TemplateName string
|
||||
NeighborIP string
|
||||
NeighborASN uint32
|
||||
ProtocolName string
|
||||
TemplateName string
|
||||
NeighborIP string
|
||||
NeighborASN uint32
|
||||
// If set, emits "local … as …" before neighbor (overrides template local/ASN for this peer).
|
||||
OverrideLocalIP string
|
||||
OverrideLocalASN uint32
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package birdfmt
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// BGPSession is one BGP protocol block from `birdc show protocols all`.
|
||||
type BGPSession struct {
|
||||
Name string `json:"name"`
|
||||
Neighbor string `json:"neighbor,omitempty"`
|
||||
State string `json:"state"`
|
||||
}
|
||||
|
||||
// ParseBGPSessions extracts BGP protocol name, state, and neighbor (if present) from birdc output.
|
||||
func ParseBGPSessions(output string) []BGPSession {
|
||||
var out []BGPSession
|
||||
var cur *BGPSession
|
||||
for _, raw := range strings.Split(output, "\n") {
|
||||
line := strings.TrimRight(raw, "\r")
|
||||
trim := strings.TrimSpace(line)
|
||||
if trim == "" {
|
||||
cur = nil
|
||||
continue
|
||||
}
|
||||
low := strings.ToLower(trim)
|
||||
if strings.HasPrefix(low, "bird ") || strings.HasPrefix(low, "name ") || strings.HasPrefix(low, "table ") {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(line, " ") && !strings.HasPrefix(line, "\t") {
|
||||
if isBGPProtocolSummaryRow(trim) {
|
||||
fields := strings.Fields(trim)
|
||||
state := extractBGPSessionStateLine(trim)
|
||||
if state == "" && len(fields) >= 4 {
|
||||
state = fields[3]
|
||||
}
|
||||
out = append(out, BGPSession{Name: fields[0], State: state})
|
||||
cur = &out[len(out)-1]
|
||||
} else {
|
||||
cur = nil
|
||||
}
|
||||
continue
|
||||
}
|
||||
if cur == nil {
|
||||
continue
|
||||
}
|
||||
for _, prefix := range []string{"Neighbor address:", "Neighbor Address:", "Neighbor:"} {
|
||||
if idx := strings.Index(trim, prefix); idx >= 0 {
|
||||
cur.Neighbor = strings.TrimSpace(trim[idx+len(prefix):])
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package birdfmt
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseBGPSessions_neighborAndState(t *testing.T) {
|
||||
sample := `
|
||||
BIRD 2.14 ready.
|
||||
Name Proto Table State Since Info
|
||||
device1 Device --- up 10:00:00
|
||||
evobgp_p_abc123 BGP master4 up 10:00:05 Established
|
||||
Neighbor address: 198.51.100.2
|
||||
Neighbor AS: 65001
|
||||
evobgp_p_def456 BGP master4 up 10:00:06 Active
|
||||
Neighbor address: 2001:db8::2
|
||||
`
|
||||
sessions := ParseBGPSessions(sample)
|
||||
if len(sessions) != 2 {
|
||||
t.Fatalf("got %d sessions want 2", len(sessions))
|
||||
}
|
||||
if sessions[0].Name != "evobgp_p_abc123" || sessions[0].State != "Established" || sessions[0].Neighbor != "198.51.100.2" {
|
||||
t.Fatalf("session0: %+v", sessions[0])
|
||||
}
|
||||
if sessions[1].Neighbor != "2001:db8::2" || sessions[1].State != "Active" {
|
||||
t.Fatalf("session1: %+v", sessions[1])
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,21 @@ import (
|
||||
|
||||
const maxBGPASN = 4294967295
|
||||
|
||||
const filterPrefixChunkSize = 500
|
||||
|
||||
func writePrefixSetAcceptBlocks(b *strings.Builder, keys []string) {
|
||||
for i := 0; i < len(keys); i += filterPrefixChunkSize {
|
||||
end := i + filterPrefixChunkSize
|
||||
if end > len(keys) {
|
||||
end = len(keys)
|
||||
}
|
||||
chunk := keys[i:end]
|
||||
b.WriteString(" if net ~ [ ")
|
||||
b.WriteString(strings.Join(chunk, ", "))
|
||||
b.WriteString(" ] then accept;\n")
|
||||
}
|
||||
}
|
||||
|
||||
func filterUniqueASNs(pathASNs []int64) []int64 {
|
||||
seen := make(map[int64]struct{})
|
||||
for _, a := range pathASNs {
|
||||
@@ -52,9 +67,7 @@ func RenderExportFilterIPv4(filterName string, prefixes []netip.Prefix, pathASNs
|
||||
b.WriteString(strings.TrimSpace(filterName))
|
||||
b.WriteString(" {\n")
|
||||
if len(keys) > 0 {
|
||||
b.WriteString(" if net ~ [ ")
|
||||
b.WriteString(strings.Join(keys, ", "))
|
||||
b.WriteString(" ] then accept;\n")
|
||||
writePrefixSetAcceptBlocks(&b, keys)
|
||||
}
|
||||
for _, asn := range asns {
|
||||
fmt.Fprintf(&b, " if bgp_path ~ [= * %d =] then accept;\n", asn)
|
||||
@@ -95,9 +108,7 @@ func RenderExportFilterIPv6(filterName string, prefixes []netip.Prefix, pathASNs
|
||||
b.WriteString(strings.TrimSpace(filterName))
|
||||
b.WriteString(" {\n")
|
||||
if len(keys) > 0 {
|
||||
b.WriteString(" if net ~ [ ")
|
||||
b.WriteString(strings.Join(keys, ", "))
|
||||
b.WriteString(" ] then accept;\n")
|
||||
writePrefixSetAcceptBlocks(&b, keys)
|
||||
}
|
||||
for _, asn := range asns {
|
||||
fmt.Fprintf(&b, " if bgp_path ~ [= * %d =] then accept;\n", asn)
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package birdfmt
|
||||
|
||||
import "strings"
|
||||
|
||||
// PeerProtocolName returns the BIRD protocol name for a control-plane peer UUID.
|
||||
// Must stay in sync with pipeline peer rendering.
|
||||
func PeerProtocolName(peerID string) string {
|
||||
s := strings.ReplaceAll(strings.TrimSpace(peerID), "-", "")
|
||||
if len(s) > 16 {
|
||||
s = s[:16]
|
||||
}
|
||||
if s == "" {
|
||||
s = "x"
|
||||
}
|
||||
return "evobgp_p_" + s
|
||||
}
|
||||
@@ -50,3 +50,48 @@ func CountEstablishedBGPSessions(showProtocolsOutput string) int {
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// ParseBGPProtocolStates parses `birdc show protocols all` summary rows into protocol_name -> state.
|
||||
func ParseBGPProtocolStates(output string) map[string]string {
|
||||
out := make(map[string]string)
|
||||
for _, raw := range strings.Split(output, "\n") {
|
||||
line := strings.TrimSpace(raw)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
low := strings.ToLower(line)
|
||||
if strings.HasPrefix(low, "bird ") || strings.HasPrefix(low, "name ") || strings.HasPrefix(low, "table ") {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 4 {
|
||||
continue
|
||||
}
|
||||
if !strings.EqualFold(fields[1], "BGP") {
|
||||
continue
|
||||
}
|
||||
state := extractBGPSessionStateLine(line)
|
||||
if state == "" {
|
||||
state = fields[3]
|
||||
}
|
||||
out[fields[0]] = state
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func extractBGPSessionStateLine(line string) string {
|
||||
known := []string{
|
||||
"Established",
|
||||
"Idle",
|
||||
"Connect",
|
||||
"Active",
|
||||
"OpenSent",
|
||||
"OpenConfirm",
|
||||
}
|
||||
for _, st := range known {
|
||||
if strings.Contains(line, st) {
|
||||
return st
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -17,12 +17,12 @@ import (
|
||||
|
||||
// Manifest describes bundle contents for evobgp-node verification.
|
||||
type Manifest struct {
|
||||
RevisionID string `json:"revision_id"`
|
||||
SpeakerID string `json:"speaker_id,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Files []FileEntry `json:"files"`
|
||||
Algorithm string `json:"signature_algorithm"`
|
||||
PublicKeyB64 string `json:"public_key_base64"`
|
||||
RevisionID string `json:"revision_id"`
|
||||
SpeakerID string `json:"speaker_id,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Files []FileEntry `json:"files"`
|
||||
Algorithm string `json:"signature_algorithm"`
|
||||
PublicKeyB64 string `json:"public_key_base64"`
|
||||
}
|
||||
|
||||
// FileEntry is one file inside the bundle archive.
|
||||
|
||||
@@ -27,7 +27,7 @@ func VerifyGzippedTar(bundle []byte, pub ed25519.PublicKey) (*VerifiedContents,
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer gr.Close()
|
||||
defer func() { _ = gr.Close() }()
|
||||
|
||||
var manifestRaw []byte
|
||||
var sig []byte
|
||||
|
||||
@@ -6,9 +6,12 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"evobgp/migrations"
|
||||
|
||||
@@ -22,6 +25,17 @@ 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
|
||||
pool, err := pgxpool.NewWithConfig(ctx, cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
// Package dbcli implements control-plane PostgreSQL maintenance CLI (HTTP or local DSN).
|
||||
package dbcli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"evobgp/internal/db"
|
||||
"evobgp/internal/httpclient"
|
||||
"evobgp/internal/pgmonitor"
|
||||
)
|
||||
|
||||
// Run executes db subcommands; args exclude program name and "db".
|
||||
func Run(args []string) int {
|
||||
if len(args) == 0 {
|
||||
printUsage()
|
||||
return 2
|
||||
}
|
||||
switch args[0] {
|
||||
case "report":
|
||||
return cmdReport(args[1:])
|
||||
case "vacuum":
|
||||
return cmdMaint(args[1:], "vacuum", "/v1/postgres/vacuum")
|
||||
case "analyze":
|
||||
return cmdMaint(args[1:], "analyze", "/v1/postgres/analyze")
|
||||
case "cleanup":
|
||||
return cmdCleanup(args[1:])
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "dbcli: unknown command %q\n", args[0])
|
||||
printUsage()
|
||||
return 2
|
||||
}
|
||||
}
|
||||
|
||||
func printUsage() {
|
||||
fmt.Fprintln(os.Stderr, `usage:
|
||||
evobgp-api db report [--api-url URL] [--token TOKEN] [--format json]
|
||||
evobgp-api db vacuum [--table NAME] [--dry-run] [--api-url URL] [--token TOKEN]
|
||||
evobgp-api db analyze [--table NAME] [--dry-run] [--api-url URL] [--token TOKEN]
|
||||
evobgp-api db cleanup --policy NAME [--dry-run] [--limit N] [--api-url URL] [--token TOKEN]
|
||||
Local break-glass: set EVOBGP_DATABASE_URL (report only uses direct SQL).`)
|
||||
}
|
||||
|
||||
func cmdReport(args []string) int {
|
||||
fs := flag.NewFlagSet("report", flag.ExitOnError)
|
||||
apiURL := fs.String("api-url", "", "control plane base URL")
|
||||
token := fs.String("token", "", "Bearer token (operator)")
|
||||
format := fs.String("format", "json", "output format (json)")
|
||||
_ = fs.Parse(args)
|
||||
|
||||
if dsn := strings.TrimSpace(os.Getenv("EVOBGP_DATABASE_URL")); dsn != "" && *apiURL == "" {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
pool, err := db.OpenPostgresPool(ctx, dsn)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return 1
|
||||
}
|
||||
defer pool.Close()
|
||||
svc := pgmonitor.NewService(pool)
|
||||
ov, err := svc.Overview(ctx)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return 1
|
||||
}
|
||||
return writeJSONStdout(ov, *format)
|
||||
}
|
||||
if *apiURL == "" || *token == "" {
|
||||
fmt.Fprintln(os.Stderr, "report: --api-url and --token required without EVOBGP_DATABASE_URL")
|
||||
return 2
|
||||
}
|
||||
body, err := apiGET(*apiURL, *token, "/v1/monitoring/postgres/overview")
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return 1
|
||||
}
|
||||
var pretty any
|
||||
if err := json.Unmarshal(body, &pretty); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return 1
|
||||
}
|
||||
return writeJSONStdout(pretty, *format)
|
||||
}
|
||||
|
||||
func cmdMaint(args []string, _ string, path string) int {
|
||||
fs := flag.NewFlagSet("maint", flag.ExitOnError)
|
||||
table := fs.String("table", "", "table name")
|
||||
dryRun := fs.Bool("dry-run", false, "dry run only")
|
||||
apiURL := fs.String("api-url", "", "control plane base URL")
|
||||
token := fs.String("token", "", "Bearer token (operator)")
|
||||
_ = fs.Parse(args)
|
||||
if *apiURL == "" || *token == "" {
|
||||
fmt.Fprintln(os.Stderr, "maintenance: --api-url and --token are required")
|
||||
return 2
|
||||
}
|
||||
payload := map[string]any{"dry_run": *dryRun}
|
||||
if *table != "" {
|
||||
payload["table"] = *table
|
||||
}
|
||||
body, err := apiPOST(*apiURL, *token, path, payload)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return 1
|
||||
}
|
||||
return writeRawJSON(body)
|
||||
}
|
||||
|
||||
func cmdCleanup(args []string) int {
|
||||
fs := flag.NewFlagSet("cleanup", flag.ExitOnError)
|
||||
policyID := fs.String("policy-id", "", "maintenance policy UUID")
|
||||
dryRun := fs.Bool("dry-run", true, "dry run")
|
||||
apiURL := fs.String("api-url", "", "control plane base URL")
|
||||
token := fs.String("token", "", "Bearer token (operator)")
|
||||
_ = fs.Parse(args)
|
||||
if *policyID == "" {
|
||||
fmt.Fprintln(os.Stderr, "cleanup: --policy-id is required")
|
||||
return 2
|
||||
}
|
||||
if *apiURL == "" || *token == "" {
|
||||
fmt.Fprintln(os.Stderr, "cleanup: --api-url and --token are required")
|
||||
return 2
|
||||
}
|
||||
path := "/v1/maintenance/run"
|
||||
if *dryRun {
|
||||
path = "/v1/maintenance/dry-run"
|
||||
}
|
||||
payload := map[string]any{"policy_id": *policyID}
|
||||
body, err := apiPOST(*apiURL, *token, path, payload)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return 1
|
||||
}
|
||||
return writeRawJSON(body)
|
||||
}
|
||||
|
||||
func apiGET(base, token, path string) ([]byte, error) {
|
||||
u := strings.TrimRight(base, "/") + path
|
||||
req, err := http.NewRequest(http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
resp, err := httpclient.DoWithRetry(ctx, httpclient.New(60*time.Second), req, 3)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("dbcli: GET %s: %s: %s", path, resp.Status, strings.TrimSpace(string(b)))
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func apiPOST(base, token, path string, payload map[string]any) ([]byte, error) {
|
||||
b, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u := strings.TrimRight(base, "/") + path
|
||||
req, err := http.NewRequest(http.MethodPost, u, bytes.NewReader(b))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
resp, err := httpclient.DoWithRetry(ctx, httpclient.New(60*time.Second), req, 3)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
out, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusAccepted && resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("dbcli: POST %s: %s: %s", path, resp.Status, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func writeJSONStdout(v any, format string) int {
|
||||
if format != "json" {
|
||||
fmt.Fprintln(os.Stderr, "only json format supported")
|
||||
return 2
|
||||
}
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
if err := enc.Encode(v); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func writeRawJSON(b []byte) int {
|
||||
var v any
|
||||
if err := json.Unmarshal(b, &v); err != nil {
|
||||
_, _ = os.Stdout.Write(b)
|
||||
return 0
|
||||
}
|
||||
return writeJSONStdout(v, "json")
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"sync"
|
||||
|
||||
"evobgp/internal/store"
|
||||
)
|
||||
|
||||
type apiKeyResolver struct {
|
||||
mu sync.RWMutex
|
||||
|
||||
envByToken map[string]apiKeyRecord
|
||||
byHash map[string]apiKeyRecord
|
||||
}
|
||||
|
||||
func newAPIKeyResolver(envSpec string, st store.Backend) (*apiKeyResolver, error) {
|
||||
r := &apiKeyResolver{
|
||||
envByToken: make(map[string]apiKeyRecord),
|
||||
byHash: make(map[string]apiKeyRecord),
|
||||
}
|
||||
for _, rec := range parseAPIKeysSpec(envSpec) {
|
||||
r.envByToken[rec.token] = rec
|
||||
}
|
||||
return r, r.reloadFromStore(st)
|
||||
}
|
||||
|
||||
func (r *apiKeyResolver) reloadFromStore(st store.Backend) error {
|
||||
rows, err := st.ListActiveAPIKeyHashes()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
byHash := make(map[string]apiKeyRecord, len(rows))
|
||||
for _, row := range rows {
|
||||
if len(row.TokenHash) != 32 {
|
||||
continue
|
||||
}
|
||||
byHash[hex.EncodeToString(row.TokenHash)] = apiKeyRecord{
|
||||
token: "",
|
||||
tenantID: row.TenantID,
|
||||
role: row.Role,
|
||||
keyID: row.ID,
|
||||
}
|
||||
}
|
||||
r.mu.Lock()
|
||||
r.byHash = byHash
|
||||
r.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *apiKeyResolver) Reload(st store.Backend) error {
|
||||
return r.reloadFromStore(st)
|
||||
}
|
||||
|
||||
func (r *apiKeyResolver) Lookup(raw string) (apiKeyRecord, bool) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
if rec, ok := r.envByToken[raw]; ok {
|
||||
return rec, true
|
||||
}
|
||||
sum := sha256.Sum256([]byte(raw))
|
||||
key := hex.EncodeToString(sum[:])
|
||||
rec, ok := r.byHash[key]
|
||||
return rec, ok
|
||||
}
|
||||
+13
-21
@@ -15,6 +15,7 @@ type Auth struct {
|
||||
TenantID string
|
||||
Role string // viewer, editor, operator, node
|
||||
Token string
|
||||
APIKeyID string // non-empty for DB-managed keys
|
||||
}
|
||||
|
||||
func authFromContext(ctx context.Context) (Auth, bool) {
|
||||
@@ -26,6 +27,7 @@ type apiKeyRecord struct {
|
||||
token string
|
||||
tenantID string
|
||||
role string
|
||||
keyID string // set for DB-managed keys (last_used_at)
|
||||
}
|
||||
|
||||
func parseAPIKeysSpec(spec string) []apiKeyRecord {
|
||||
@@ -54,20 +56,6 @@ func parseAPIKeysSpec(spec string) []apiKeyRecord {
|
||||
|
||||
func (s *Server) authMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if s.insecureDev {
|
||||
h := r.Header.Get("Authorization")
|
||||
const p = "Bearer "
|
||||
if strings.HasPrefix(h, p) {
|
||||
tok := strings.TrimSpace(strings.TrimPrefix(h, p))
|
||||
if tok == "dev" {
|
||||
if a, ok := s.devAuth(); ok {
|
||||
r = r.WithContext(context.WithValue(r.Context(), authCtxKey, a))
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
h := r.Header.Get("Authorization")
|
||||
const p = "Bearer "
|
||||
if !strings.HasPrefix(h, p) {
|
||||
@@ -75,18 +63,22 @@ func (s *Server) authMiddleware(next http.Handler) http.Handler {
|
||||
return
|
||||
}
|
||||
raw := strings.TrimSpace(strings.TrimPrefix(h, p))
|
||||
var matched *apiKeyRecord
|
||||
for i := range s.apiKeys {
|
||||
if s.apiKeys[i].token == raw {
|
||||
matched = &s.apiKeys[i]
|
||||
break
|
||||
if raw == "dev" {
|
||||
if a, ok := s.devAuth(); ok {
|
||||
r = r.WithContext(context.WithValue(r.Context(), authCtxKey, a))
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
if matched == nil {
|
||||
matched, ok := s.keyResolver.Lookup(raw)
|
||||
if !ok {
|
||||
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "unknown api key")
|
||||
return
|
||||
}
|
||||
a := Auth{TenantID: matched.tenantID, Role: matched.role, Token: raw}
|
||||
a := Auth{TenantID: matched.tenantID, Role: matched.role, Token: raw, APIKeyID: matched.keyID}
|
||||
if matched.keyID != "" {
|
||||
go func(id string) { _ = s.store.TouchAPIKeyLastUsed(id) }(matched.keyID)
|
||||
}
|
||||
r = r.WithContext(context.WithValue(r.Context(), authCtxKey, a))
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"time"
|
||||
|
||||
"evobgp/internal/db"
|
||||
"evobgp/internal/httpclient"
|
||||
"evobgp/internal/jobs"
|
||||
"evobgp/internal/observability"
|
||||
"evobgp/internal/repository"
|
||||
@@ -17,7 +18,7 @@ import (
|
||||
|
||||
// NewCDNHTTPClient returns the shared HTTP client for CDN and preview fetches (PERF-02 / ERR-03).
|
||||
func NewCDNHTTPClient() *http.Client {
|
||||
return &http.Client{Timeout: 45 * time.Second}
|
||||
return httpclient.New(httpclient.DefaultTimeout)
|
||||
}
|
||||
|
||||
// BootstrapWorkers opens the same store.Backend and jobs.Registry as New (without HTTP or bundle keys).
|
||||
@@ -50,9 +51,46 @@ func BootstrapWorkers(ctx context.Context, opts Options) (store.Backend, *jobs.R
|
||||
}
|
||||
|
||||
cdnHTTP := NewCDNHTTPClient()
|
||||
wk := &jobs.Worker{Store: backend, HTTPClient: cdnHTTP}
|
||||
wk := &jobs.Worker{Store: backend, PgPool: pool, HTTPClient: cdnHTTP}
|
||||
reg := jobs.NewRegistry(wk.Process)
|
||||
wk.Registry = reg
|
||||
if pool != nil {
|
||||
audit := repository.NewJobAuditWriter(pool)
|
||||
jobMeta := func(j *jobs.Job) map[string]any {
|
||||
if j == nil {
|
||||
return nil
|
||||
}
|
||||
st := j.Snapshot()
|
||||
meta, _ := st["meta"].(map[string]any)
|
||||
return meta
|
||||
}
|
||||
reg.SetPersistHooks(
|
||||
func(j *jobs.Job) {
|
||||
if j == nil {
|
||||
return
|
||||
}
|
||||
audit.UpsertQueued(context.Background(), j.TenantID, j.ID, j.Kind, j.IdempotencyKey, j.ModuleID, jobMeta(j))
|
||||
},
|
||||
func(j *jobs.Job) {
|
||||
if j == nil {
|
||||
return
|
||||
}
|
||||
audit.UpsertRunning(context.Background(), j.TenantID, j.ID, j.Kind, j.IdempotencyKey, jobMeta(j))
|
||||
},
|
||||
func(j *jobs.Job) {
|
||||
if j == nil {
|
||||
return
|
||||
}
|
||||
st := j.Snapshot()
|
||||
status, _ := st["status"].(string)
|
||||
var errMsg *string
|
||||
if e, ok := st["error"].(string); ok && e != "" {
|
||||
errMsg = &e
|
||||
}
|
||||
audit.MarkTerminal(context.Background(), j.TenantID, j.ID, status, errMsg, time.Now().UTC())
|
||||
},
|
||||
)
|
||||
}
|
||||
observability.RegisterStoreBackend(backend)
|
||||
return backend, reg, pool, nil
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ package httpapi
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"evobgp/internal/store"
|
||||
)
|
||||
|
||||
func parseListLimit(r *http.Request) int {
|
||||
@@ -22,3 +24,15 @@ func strPtrOrNull(s string) any {
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// writePaginatedListJSON returns a cursor/limit page as OpenAPI list envelopes (items, next_cursor, has_more).
|
||||
func writePaginatedListJSON[T any](w http.ResponseWriter, r *http.Request, all []T, toItem func(T) map[string]any) {
|
||||
page, next, more := store.PaginateOffset(all, r.URL.Query().Get("cursor"), parseListLimit(r))
|
||||
items := make([]map[string]any, 0, len(page))
|
||||
for _, x := range page {
|
||||
items = append(items, toItem(x))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"items": items, "next_cursor": strPtrOrNull(next), "has_more": more,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/netip"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"evobgp/internal/birdfmt"
|
||||
"evobgp/internal/nodedispatch"
|
||||
"evobgp/internal/store"
|
||||
)
|
||||
|
||||
const peerLiveCacheTTL = 15 * time.Second
|
||||
|
||||
type speakerBGPLive struct {
|
||||
SpeakerID string
|
||||
Label string
|
||||
Sessions []birdfmt.BGPSession
|
||||
Error string
|
||||
}
|
||||
|
||||
type peerLiveCacheEntry struct {
|
||||
at time.Time
|
||||
views []speakerBGPLive
|
||||
}
|
||||
|
||||
var peerLiveCache sync.Map // tenantID -> peerLiveCacheEntry
|
||||
|
||||
type peerSessionOnSpeaker struct {
|
||||
SpeakerID string `json:"speaker_id"`
|
||||
Label string `json:"label"`
|
||||
State string `json:"state"`
|
||||
PollError string `json:"poll_error,omitempty"`
|
||||
}
|
||||
|
||||
type liveSpeakerPoll struct {
|
||||
SpeakerID string `json:"speaker_id"`
|
||||
Label string `json:"label"`
|
||||
OK bool `json:"ok"`
|
||||
SessionCount int `json:"session_count"`
|
||||
PollError string `json:"poll_error,omitempty"`
|
||||
}
|
||||
|
||||
func speakerDisplayLabel(sp *store.Speaker) string {
|
||||
if sp == nil {
|
||||
return ""
|
||||
}
|
||||
meta := store.ParseSpeakerMeta(sp.MetaJSON)
|
||||
host := strings.TrimSpace(meta.AgentDomain)
|
||||
if host == "" {
|
||||
host = strings.TrimSpace(sp.Endpoint)
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(sp.Role), "master") {
|
||||
if host != "" {
|
||||
return "CP · " + host
|
||||
}
|
||||
return "CP (master)"
|
||||
}
|
||||
if host != "" {
|
||||
return host
|
||||
}
|
||||
return sp.ID
|
||||
}
|
||||
|
||||
func masterSpeakerID(speakers []*store.Speaker) string {
|
||||
for _, sp := range speakers {
|
||||
if sp != nil && strings.EqualFold(strings.TrimSpace(sp.Role), "master") {
|
||||
return sp.ID
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *Server) collectSpeakerBGPLive(ctx context.Context, tenantID string, fresh bool) []speakerBGPLive {
|
||||
if !fresh {
|
||||
if v, ok := peerLiveCache.Load(tenantID); ok {
|
||||
ent := v.(peerLiveCacheEntry)
|
||||
if time.Since(ent.at) < peerLiveCacheTTL {
|
||||
return ent.views
|
||||
}
|
||||
}
|
||||
}
|
||||
speakers := s.store.ListSpeakersForTenant(tenantID)
|
||||
views := make([]speakerBGPLive, 0, len(speakers)+1)
|
||||
|
||||
if sock := strings.TrimSpace(os.Getenv("EVOBGP_BIRDC_SOCKET")); sock != "" {
|
||||
v := speakerBGPLive{Label: "CP (local BIRD)"}
|
||||
if mid := masterSpeakerID(speakers); mid != "" {
|
||||
v.SpeakerID = mid
|
||||
for _, sp := range speakers {
|
||||
if sp != nil && sp.ID == mid {
|
||||
v.Label = speakerDisplayLabel(sp)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
out, err := birdfmt.ShowProtocols(ctx, sock, strings.TrimSpace(os.Getenv("EVOBGP_BIRDC_BIN")))
|
||||
if err != nil {
|
||||
v.Error = err.Error()
|
||||
} else {
|
||||
v.Sessions = birdfmt.ParseBGPSessions(out)
|
||||
}
|
||||
views = append(views, v)
|
||||
}
|
||||
|
||||
opts := nodedispatch.Options{Timeout: 8 * time.Second}
|
||||
type resWrap struct {
|
||||
sp *store.Speaker
|
||||
res nodedispatch.BirdProtocolsResult
|
||||
}
|
||||
ch := make(chan resWrap, len(speakers))
|
||||
var wg sync.WaitGroup
|
||||
for _, sp := range speakers {
|
||||
if sp == nil {
|
||||
continue
|
||||
}
|
||||
meta := store.ParseSpeakerMeta(sp.MetaJSON)
|
||||
if !store.SpeakerNeedsRemoteDispatch(sp.Role, meta) {
|
||||
continue
|
||||
}
|
||||
wg.Add(1)
|
||||
go func(speaker *store.Speaker) {
|
||||
defer wg.Done()
|
||||
ch <- resWrap{
|
||||
sp: speaker,
|
||||
res: nodedispatch.FetchBirdProtocols(ctx, speaker, opts),
|
||||
}
|
||||
}(sp)
|
||||
}
|
||||
wg.Wait()
|
||||
close(ch)
|
||||
for rw := range ch {
|
||||
views = append(views, speakerBGPLive{
|
||||
SpeakerID: rw.sp.ID,
|
||||
Label: speakerDisplayLabel(rw.sp),
|
||||
Sessions: rw.res.Sessions,
|
||||
Error: rw.res.Error,
|
||||
})
|
||||
}
|
||||
|
||||
peerLiveCache.Store(tenantID, peerLiveCacheEntry{at: time.Now(), views: views})
|
||||
return views
|
||||
}
|
||||
|
||||
func liveSpeakerPollJSON(views []speakerBGPLive) []liveSpeakerPoll {
|
||||
out := make([]liveSpeakerPoll, 0, len(views))
|
||||
for _, v := range views {
|
||||
out = append(out, liveSpeakerPoll{
|
||||
SpeakerID: v.SpeakerID,
|
||||
Label: v.Label,
|
||||
OK: v.Error == "",
|
||||
SessionCount: len(v.Sessions),
|
||||
PollError: v.Error,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func findPeerSession(sessions []birdfmt.BGPSession, protoName string, neighbor netip.Addr, hasNeighbor bool) *birdfmt.BGPSession {
|
||||
for i := range sessions {
|
||||
if peerSessionMatches(sessions[i], protoName, neighbor, hasNeighbor) {
|
||||
return &sessions[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func matchPeerOnSpeakers(peer *store.BGPPeer, views []speakerBGPLive) (
|
||||
bestState string,
|
||||
connectedID string,
|
||||
connectedLabel string,
|
||||
establishedOn []peerSessionOnSpeaker,
|
||||
on []peerSessionOnSpeaker,
|
||||
mismatch bool,
|
||||
) {
|
||||
if peer == nil {
|
||||
return "", "", "", nil, nil, false
|
||||
}
|
||||
neighbor, hasNeighbor := store.ParsePeerNeighbor(peer.Neighbor)
|
||||
protoName := birdfmt.PeerProtocolName(peer.ID)
|
||||
|
||||
for _, v := range views {
|
||||
if v.Error != "" && len(v.Sessions) == 0 {
|
||||
on = append(on, peerSessionOnSpeaker{
|
||||
SpeakerID: v.SpeakerID,
|
||||
Label: v.Label,
|
||||
PollError: v.Error,
|
||||
})
|
||||
continue
|
||||
}
|
||||
sess := findPeerSession(v.Sessions, protoName, neighbor, hasNeighbor)
|
||||
if sess == nil {
|
||||
on = append(on, peerSessionOnSpeaker{
|
||||
SpeakerID: v.SpeakerID,
|
||||
Label: v.Label,
|
||||
State: "absent",
|
||||
})
|
||||
continue
|
||||
}
|
||||
hit := peerSessionOnSpeaker{
|
||||
SpeakerID: v.SpeakerID,
|
||||
Label: v.Label,
|
||||
State: sess.State,
|
||||
}
|
||||
on = append(on, hit)
|
||||
if strings.EqualFold(strings.TrimSpace(sess.State), "Established") {
|
||||
establishedOn = append(establishedOn, hit)
|
||||
}
|
||||
if bestState == "" || sessionStateRank(sess.State) > sessionStateRank(bestState) {
|
||||
bestState = sess.State
|
||||
}
|
||||
}
|
||||
|
||||
if len(establishedOn) > 0 {
|
||||
bestState = "Established"
|
||||
labels := make([]string, 0, len(establishedOn))
|
||||
for _, e := range establishedOn {
|
||||
labels = append(labels, e.Label)
|
||||
}
|
||||
connectedLabel = strings.Join(labels, ", ")
|
||||
if len(establishedOn) == 1 {
|
||||
connectedID = establishedOn[0].SpeakerID
|
||||
}
|
||||
} else if len(on) == 1 && on[0].PollError == "" && on[0].State != "" {
|
||||
connectedID = on[0].SpeakerID
|
||||
connectedLabel = on[0].Label
|
||||
}
|
||||
|
||||
if peer.SpeakerID != nil && strings.TrimSpace(*peer.SpeakerID) != "" && len(establishedOn) > 0 {
|
||||
want := strings.TrimSpace(*peer.SpeakerID)
|
||||
found := false
|
||||
for _, e := range establishedOn {
|
||||
if strings.EqualFold(strings.TrimSpace(e.SpeakerID), want) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
mismatch = !found
|
||||
}
|
||||
return bestState, connectedID, connectedLabel, establishedOn, on, mismatch
|
||||
}
|
||||
|
||||
func peerSessionMatches(sess birdfmt.BGPSession, protoName string, neighbor netip.Addr, hasNeighbor bool) bool {
|
||||
if strings.EqualFold(strings.TrimSpace(sess.Name), protoName) {
|
||||
return true
|
||||
}
|
||||
if !hasNeighbor || strings.TrimSpace(sess.Neighbor) == "" {
|
||||
return false
|
||||
}
|
||||
peerAddr, ok := store.ParsePeerNeighbor(sess.Neighbor)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return peerAddr == neighbor
|
||||
}
|
||||
|
||||
func sessionStateRank(state string) int {
|
||||
switch strings.ToLower(strings.TrimSpace(state)) {
|
||||
case "established":
|
||||
return 100
|
||||
case "openconfirm", "opensent":
|
||||
return 80
|
||||
case "active", "connect":
|
||||
return 60
|
||||
case "idle":
|
||||
return 20
|
||||
default:
|
||||
return 10
|
||||
}
|
||||
}
|
||||
|
||||
func applyPeerLiveFields(row map[string]any, peer *store.BGPPeer, views []speakerBGPLive) {
|
||||
state, connID, connLabel, establishedOn, on, mismatch := matchPeerOnSpeakers(peer, views)
|
||||
row["session_on_speakers"] = on
|
||||
row["established_on_speakers"] = establishedOn
|
||||
row["session_conflict"] = false
|
||||
row["session_mismatch"] = mismatch
|
||||
if state != "" {
|
||||
row["session_state"] = state
|
||||
}
|
||||
if connLabel != "" {
|
||||
row["connected_speaker_label"] = connLabel
|
||||
}
|
||||
row["connected_speaker_id"] = peerLiveSpeakerIDOrNull(connID)
|
||||
}
|
||||
|
||||
func peerLiveSpeakerIDOrNull(id string) any {
|
||||
if strings.TrimSpace(id) == "" {
|
||||
return nil
|
||||
}
|
||||
return id
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"evobgp/internal/birdfmt"
|
||||
"evobgp/internal/store"
|
||||
)
|
||||
|
||||
func TestMatchPeerOnSpeakers_establishedOnReplica(t *testing.T) {
|
||||
peer := &store.BGPPeer{
|
||||
ID: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
|
||||
Neighbor: "198.51.100.2",
|
||||
}
|
||||
views := []speakerBGPLive{
|
||||
{
|
||||
SpeakerID: "master-id",
|
||||
Label: "CP · bgp.shz.su",
|
||||
Sessions: []birdfmt.BGPSession{
|
||||
{Name: birdfmt.PeerProtocolName(peer.ID), Neighbor: "198.51.100.2", State: "Established"},
|
||||
},
|
||||
},
|
||||
{
|
||||
SpeakerID: "replica-id",
|
||||
Label: "bgp2.shz.su",
|
||||
Sessions: []birdfmt.BGPSession{
|
||||
{Name: birdfmt.PeerProtocolName(peer.ID), Neighbor: "198.51.100.2", State: "Established"},
|
||||
},
|
||||
},
|
||||
}
|
||||
state, connID, connLabel, established, on, mismatch := matchPeerOnSpeakers(peer, views)
|
||||
if state != "Established" || connID != "" || connLabel != "CP · bgp.shz.su, bgp2.shz.su" {
|
||||
t.Fatalf("got state=%q conn=%q label=%q", state, connID, connLabel)
|
||||
}
|
||||
if mismatch || len(on) != 2 || len(established) != 2 {
|
||||
t.Fatalf("on=%+v established=%+v mismatch=%v", on, established, mismatch)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchPeerOnSpeakers_multipleEstablished(t *testing.T) {
|
||||
peer := &store.BGPPeer{ID: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", Neighbor: "198.51.100.2/32"}
|
||||
views := []speakerBGPLive{
|
||||
{SpeakerID: "a", Label: "n1", Sessions: []birdfmt.BGPSession{{Name: birdfmt.PeerProtocolName(peer.ID), State: "Established"}}},
|
||||
{SpeakerID: "b", Label: "n2", Sessions: []birdfmt.BGPSession{{Name: birdfmt.PeerProtocolName(peer.ID), State: "Established"}}},
|
||||
}
|
||||
_, connID, label, established, on, mismatch := matchPeerOnSpeakers(peer, views)
|
||||
if mismatch || connID != "" || label != "n1, n2" || len(established) != 2 || len(on) != 2 {
|
||||
t.Fatalf("connID=%q label=%q established=%+v on=%+v mismatch=%v", connID, label, established, on, mismatch)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchPeerOnSpeakers_mismatchConfiguredSpeaker(t *testing.T) {
|
||||
replica := "replica-id"
|
||||
peer := &store.BGPPeer{
|
||||
ID: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
|
||||
Neighbor: "198.51.100.2",
|
||||
SpeakerID: &replica,
|
||||
}
|
||||
views := []speakerBGPLive{
|
||||
{SpeakerID: "master-id", Label: "CP", Sessions: []birdfmt.BGPSession{{Name: birdfmt.PeerProtocolName(peer.ID), State: "Established"}}},
|
||||
{SpeakerID: replica, Label: "bgp2", Sessions: []birdfmt.BGPSession{{Name: birdfmt.PeerProtocolName(peer.ID), State: "Idle"}}},
|
||||
}
|
||||
_, _, _, _, _, mismatch := matchPeerOnSpeakers(peer, views)
|
||||
if !mismatch {
|
||||
t.Fatal("expected mismatch when configured replica has no Established")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchPeerOnSpeakers_pollError(t *testing.T) {
|
||||
peer := &store.BGPPeer{ID: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", Neighbor: "198.51.100.2"}
|
||||
views := []speakerBGPLive{
|
||||
{Label: "CP (local BIRD)", Sessions: []birdfmt.BGPSession{{Name: birdfmt.PeerProtocolName(peer.ID), State: "Established"}}},
|
||||
{SpeakerID: "replica-id", Label: "bgp2.shz.su", Error: "HTTP 404: Not Found"},
|
||||
}
|
||||
_, _, _, established, on, _ := matchPeerOnSpeakers(peer, views)
|
||||
if len(established) != 1 || len(on) != 2 || on[1].PollError == "" {
|
||||
t.Fatalf("on=%+v established=%+v", on, established)
|
||||
}
|
||||
}
|
||||
@@ -9,15 +9,19 @@ import (
|
||||
const (
|
||||
internalErrorDetail = "an internal error occurred"
|
||||
badGatewayDetail = "upstream request failed"
|
||||
notFoundDetail = "resource not found"
|
||||
invalidInputDetail = "invalid request data"
|
||||
cdnExtractDetail = "could not extract prefixes from source"
|
||||
csvInvalidRowDetail = "invalid row in csv file"
|
||||
)
|
||||
|
||||
// Problem is RFC 9457 application/problem+json.
|
||||
type Problem struct {
|
||||
Type string `json:"type,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Status int `json:"status"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
Instance string `json:"instance,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Status int `json:"status"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
Instance string `json:"instance,omitempty"`
|
||||
}
|
||||
|
||||
func writeProblem(w http.ResponseWriter, status int, title, detail string) {
|
||||
|
||||
+117
-35
@@ -55,6 +55,7 @@ func (s *Server) registerV1(m *http.ServeMux) {
|
||||
m.HandleFunc("GET /modules/{module_id}", s.handleGetModule)
|
||||
m.HandleFunc("GET /peers", s.handleListPeers)
|
||||
m.HandleFunc("GET /speakers", s.handleListSpeakers)
|
||||
m.HandleFunc("GET /bundle/signing-public-key", s.handleBundleSigningPublicKey)
|
||||
m.HandleFunc("POST /modules/{module_id}/refresh", s.handleModuleRefresh)
|
||||
m.HandleFunc("POST /tenant/refresh", s.handleTenantRefresh)
|
||||
m.HandleFunc("GET /revisions", s.handleListRevisions)
|
||||
@@ -69,11 +70,15 @@ func (s *Server) registerV1(m *http.ServeMux) {
|
||||
m.HandleFunc("GET /bird/status", s.handleBirdStatus)
|
||||
m.HandleFunc("GET /jobs", s.handleListJobs)
|
||||
m.HandleFunc("GET /jobs/{job_id}", s.handleGetJob)
|
||||
m.HandleFunc("GET /jobs/{job_id}/report", s.handleGetJobReport)
|
||||
m.HandleFunc("POST /jobs/{job_id}/cancel", s.handleCancelJob)
|
||||
m.HandleFunc("GET /speakers/{speaker_id}/revisions/latest", s.handleNodeLatestRevision)
|
||||
m.HandleFunc("GET /speakers/{speaker_id}/bundle/{revision_id}", s.handleNodeBundle)
|
||||
m.HandleFunc("POST /nodes/enroll", s.handleNodeEnroll)
|
||||
s.registerCRUDRoutes(m)
|
||||
s.registerPostgresMonitoringRoutes(m)
|
||||
s.registerPostgresMaintenanceRoutes(m)
|
||||
s.registerMaintenanceRoutes(m)
|
||||
}
|
||||
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -84,12 +89,15 @@ func (s *Server) handleReady(w http.ResponseWriter, r *http.Request) {
|
||||
checks := map[string]string{"store": "ok", "jobs": "memory"}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
|
||||
defer cancel()
|
||||
if s.pgPool != nil {
|
||||
if err := s.pgPool.Ping(ctx); err != nil {
|
||||
if err := s.store.Ping(ctx); err != nil {
|
||||
checks["store"] = "unavailable"
|
||||
if s.pgPool != nil {
|
||||
checks["postgres"] = "unavailable"
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"status": "not_ready", "checks": checks})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"status": "not_ready", "checks": checks})
|
||||
return
|
||||
}
|
||||
if s.pgPool != nil {
|
||||
checks["postgres"] = "ok"
|
||||
} else {
|
||||
checks["store_backend"] = "memory"
|
||||
@@ -163,17 +171,7 @@ func peerJSON(p *store.BGPPeer) map[string]any {
|
||||
}
|
||||
|
||||
func speakerJSON(sp *store.Speaker) map[string]any {
|
||||
m := map[string]any{
|
||||
"id": sp.ID,
|
||||
"role": sp.Role,
|
||||
"endpoint": sp.Endpoint,
|
||||
}
|
||||
if sp.LastAppliedRevisionID != nil {
|
||||
m["last_applied_revision_id"] = *sp.LastAppliedRevisionID
|
||||
} else {
|
||||
m["last_applied_revision_id"] = nil
|
||||
}
|
||||
return m
|
||||
return speakerJSONFromStore(nil, sp)
|
||||
}
|
||||
|
||||
func (s *Server) handleListModules(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -198,6 +196,22 @@ func (s *Server) handleListModules(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
filtered := make([]*store.Module, 0)
|
||||
limit := parseListLimit(r)
|
||||
cursor := r.URL.Query().Get("cursor")
|
||||
if typeFilter == "" && enabledFilter == nil {
|
||||
page, next, more := s.store.ListModulesPage(a.TenantID, cursor, limit)
|
||||
for _, mod := range page {
|
||||
filtered = append(filtered, mod)
|
||||
}
|
||||
items := make([]map[string]any, 0, len(filtered))
|
||||
for _, mod := range filtered {
|
||||
items = append(items, moduleJSON(mod))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"items": items, "next_cursor": strPtrOrNull(next), "has_more": more,
|
||||
})
|
||||
return
|
||||
}
|
||||
for _, mod := range s.store.ListModules(a.TenantID) {
|
||||
if typeFilter != "" && mod.Type != typeFilter {
|
||||
continue
|
||||
@@ -272,21 +286,35 @@ func (s *Server) handleListPeers(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
allPeers := s.store.ListPeers(a.TenantID)
|
||||
page, next, more := store.PaginateOffset(allPeers, r.URL.Query().Get("cursor"), parseListLimit(r))
|
||||
liveStates := s.liveBGPProtocolStates(r.Context())
|
||||
fresh := r != nil && strings.EqualFold(strings.TrimSpace(r.URL.Query().Get("live")), "1")
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 12*time.Second)
|
||||
defer cancel()
|
||||
liveViews := s.collectSpeakerBGPLive(ctx, a.TenantID, fresh)
|
||||
items := make([]map[string]any, 0, len(page))
|
||||
for _, p := range page {
|
||||
row := peerJSON(p)
|
||||
if st, ok := liveStates[peerProtocolNameForID(p.ID)]; ok && strings.TrimSpace(st) != "" {
|
||||
row["session_state"] = strings.TrimSpace(st)
|
||||
}
|
||||
applyPeerLiveFields(row, p, liveViews)
|
||||
items = append(items, row)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"items": items, "next_cursor": strPtrOrNull(next), "has_more": more,
|
||||
"items": items,
|
||||
"next_cursor": strPtrOrNull(next),
|
||||
"has_more": more,
|
||||
"live_speaker_poll": liveSpeakerPollJSON(liveViews),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) liveBGPProtocolStates(ctx context.Context) map[string]string {
|
||||
func (s *Server) liveBGPProtocolStates(r *http.Request) map[string]string {
|
||||
if r != nil && strings.EqualFold(strings.TrimSpace(r.URL.Query().Get("live")), "1") {
|
||||
return s.liveBGPProtocolStatesFresh(r.Context())
|
||||
}
|
||||
if cached, ok := observability.CachedBirdProtocolStates(90 * time.Second); ok {
|
||||
return cached
|
||||
}
|
||||
return s.liveBGPProtocolStatesFresh(r.Context())
|
||||
}
|
||||
|
||||
func (s *Server) liveBGPProtocolStatesFresh(ctx context.Context) map[string]string {
|
||||
sock := strings.TrimSpace(os.Getenv("EVOBGP_BIRDC_SOCKET"))
|
||||
if sock == "" {
|
||||
return map[string]string{}
|
||||
@@ -295,7 +323,9 @@ func (s *Server) liveBGPProtocolStates(ctx context.Context) map[string]string {
|
||||
if err != nil {
|
||||
return map[string]string{}
|
||||
}
|
||||
return parseBGPProtocolStates(out)
|
||||
states := birdfmt.ParseBGPProtocolStates(out)
|
||||
observability.SetBirdProtocolStates(states)
|
||||
return states
|
||||
}
|
||||
|
||||
// parseBGPProtocolStates parses `birdc show protocols all` summary rows into protocol_name -> state.
|
||||
@@ -343,16 +373,9 @@ func extractBGPSessionState(line string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// peerProtocolNameForID must stay in sync with pipeline peer protocol naming.
|
||||
// peerProtocolNameForID forwards to birdfmt for tests and legacy callers.
|
||||
func peerProtocolNameForID(peerID string) string {
|
||||
s := strings.ReplaceAll(strings.TrimSpace(peerID), "-", "")
|
||||
if len(s) > 16 {
|
||||
s = s[:16]
|
||||
}
|
||||
if s == "" {
|
||||
s = "x"
|
||||
}
|
||||
return "evobgp_p_" + s
|
||||
return birdfmt.PeerProtocolName(peerID)
|
||||
}
|
||||
|
||||
func (s *Server) handleListSpeakers(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -365,9 +388,22 @@ func (s *Server) handleListSpeakers(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
speakers := s.store.ListSpeakersForTenant(a.TenantID)
|
||||
fresh := r != nil && strings.EqualFold(strings.TrimSpace(r.URL.Query().Get("live")), "1")
|
||||
var liveByID map[string]map[string]any
|
||||
if fresh {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 12*time.Second)
|
||||
defer cancel()
|
||||
liveByID = s.collectSpeakerLiveStatus(ctx, a.TenantID, true, speakers)
|
||||
}
|
||||
items := make([]map[string]any, 0, len(speakers))
|
||||
for _, sp := range speakers {
|
||||
items = append(items, speakerJSON(sp))
|
||||
row := speakerJSONFromStore(s.store, sp)
|
||||
if liveByID != nil {
|
||||
if live, ok := liveByID[sp.ID]; ok {
|
||||
row["live"] = live
|
||||
}
|
||||
}
|
||||
items = append(items, row)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"items": items, "next_cursor": nil, "has_more": false,
|
||||
@@ -519,7 +555,8 @@ func (s *Server) enqueueModuleRefreshIfEnabled(tenantID, moduleID, trigger strin
|
||||
return
|
||||
}
|
||||
mid := moduleID
|
||||
_, _, _ = s.jobs.Enqueue(tenantID, jobs.KindModuleRefresh, nil, &mid, map[string]any{
|
||||
key := "module_refresh:" + moduleID
|
||||
_, _, _ = s.jobs.Enqueue(tenantID, jobs.KindModuleRefresh, &key, &mid, map[string]any{
|
||||
"module_id": moduleID,
|
||||
"trigger": trigger,
|
||||
})
|
||||
@@ -545,7 +582,7 @@ func (s *Server) handleGetRevision(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.requireAtLeast(w, a, "viewer") {
|
||||
return
|
||||
}
|
||||
rev, err := s.store.GetRevision(a.TenantID, r.PathValue("revision_id"))
|
||||
rev, err := s.store.GetRevisionSummary(a.TenantID, r.PathValue("revision_id"))
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusNotFound, "Not Found", "revision not found")
|
||||
return
|
||||
@@ -586,6 +623,9 @@ func (s *Server) handleRevisionPreview(w http.ResponseWriter, r *http.Request) {
|
||||
for k, v := range rev.PreviewFragments {
|
||||
obj[k] = v
|
||||
}
|
||||
if expanded := pipeline.BuildExpandedBirdPreview(rev.PreviewFragments); expanded != "" {
|
||||
obj[pipeline.AuxBirdFullExpandedKey()] = expanded
|
||||
}
|
||||
writeJSON(w, http.StatusOK, obj)
|
||||
}
|
||||
|
||||
@@ -840,6 +880,44 @@ func (s *Server) handleGetJob(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, j.Snapshot())
|
||||
}
|
||||
|
||||
func (s *Server) handleGetJobReport(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok {
|
||||
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
||||
return
|
||||
}
|
||||
if !s.requireAtLeast(w, a, "viewer") {
|
||||
return
|
||||
}
|
||||
j, err := s.jobs.Get(a.TenantID, r.PathValue("job_id"))
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusNotFound, "Not Found", "job not found")
|
||||
return
|
||||
}
|
||||
snap := j.Snapshot()
|
||||
meta, _ := snap["meta"].(map[string]any)
|
||||
out := map[string]any{
|
||||
"job_id": snap["job_id"],
|
||||
"kind": snap["kind"],
|
||||
"status": snap["status"],
|
||||
"meta": meta,
|
||||
"error": snap["error"],
|
||||
"created_at": snap["created_at"],
|
||||
}
|
||||
if meta != nil {
|
||||
if v, ok := meta["log_entries"]; ok {
|
||||
out["log_entries"] = v
|
||||
}
|
||||
if v, ok := meta["log_total"]; ok {
|
||||
out["log_total"] = v
|
||||
}
|
||||
if v, ok := meta["revision_id"]; ok {
|
||||
out["revision_id"] = v
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (s *Server) handleCancelJob(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok {
|
||||
@@ -911,7 +989,11 @@ func (s *Server) handleNodeBundle(w http.ResponseWriter, r *http.Request) {
|
||||
writeProblem(w, http.StatusNotFound, "Not Found", "revision not found")
|
||||
return
|
||||
}
|
||||
tgz, err := bundle.BuildGzippedTar(rid, sid, rev.PreviewFragments, s.bundlePriv)
|
||||
frags := rev.PreviewFragments
|
||||
if overlaid, err := pipeline.OverlayFragmentsForSpeaker(s.store, a.TenantID, sid, rid, frags); err == nil {
|
||||
frags = overlaid
|
||||
}
|
||||
tgz, err := bundle.BuildGzippedTar(rid, sid, frags, s.bundlePriv)
|
||||
if err != nil {
|
||||
writeInternalError(w, "internal", err)
|
||||
return
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"evobgp/internal/store"
|
||||
)
|
||||
|
||||
func (s *Server) registerAPIKeyRoutes(m *http.ServeMux) {
|
||||
m.HandleFunc("GET /auth/session", s.handleAuthSession)
|
||||
m.HandleFunc("GET /api-keys", s.handleListAPIKeys)
|
||||
m.HandleFunc("POST /api-keys", s.handlePostAPIKey)
|
||||
m.HandleFunc("GET /api-keys/{id}", s.handleGetAPIKey)
|
||||
m.HandleFunc("PATCH /api-keys/{id}", s.handlePatchAPIKey)
|
||||
m.HandleFunc("DELETE /api-keys/{id}", s.handleDeleteAPIKey)
|
||||
m.HandleFunc("POST /api-keys/{id}/rotate", s.handleRotateAPIKey)
|
||||
}
|
||||
|
||||
func (s *Server) handleAuthSession(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") {
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"tenant_id": a.TenantID,
|
||||
"role": a.Role,
|
||||
})
|
||||
}
|
||||
|
||||
func apiKeyJSON(k *store.APIKey) map[string]any {
|
||||
m := map[string]any{
|
||||
"id": k.ID,
|
||||
"name": k.Name,
|
||||
"role": k.Role,
|
||||
"prefix": k.Prefix,
|
||||
"created_at": k.CreatedAt.UTC().Format(time.RFC3339),
|
||||
"updated_at": k.UpdatedAt.UTC().Format(time.RFC3339),
|
||||
}
|
||||
if k.ExpiresAt != nil {
|
||||
m["expires_at"] = k.ExpiresAt.UTC().Format(time.RFC3339)
|
||||
} else {
|
||||
m["expires_at"] = nil
|
||||
}
|
||||
if k.RevokedAt != nil {
|
||||
m["revoked_at"] = k.RevokedAt.UTC().Format(time.RFC3339)
|
||||
} else {
|
||||
m["revoked_at"] = nil
|
||||
}
|
||||
if k.LastUsedAt != nil {
|
||||
m["last_used_at"] = k.LastUsedAt.UTC().Format(time.RFC3339)
|
||||
} else {
|
||||
m["last_used_at"] = nil
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (s *Server) handleListAPIKeys(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "operator") {
|
||||
return
|
||||
}
|
||||
list, err := s.store.ListAPIKeys(a.TenantID)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
writePaginatedListJSON(w, r, list, func(k *store.APIKey) map[string]any {
|
||||
return apiKeyJSON(k)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleGetAPIKey(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "operator") {
|
||||
return
|
||||
}
|
||||
k, err := s.store.GetAPIKey(a.TenantID, r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, apiKeyJSON(k))
|
||||
}
|
||||
|
||||
func (s *Server) handlePostAPIKey(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "operator") {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
ExpiresAt *string `json:"expires_at"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
|
||||
return
|
||||
}
|
||||
in := &store.APIKeyCreate{
|
||||
Name: strings.TrimSpace(body.Name),
|
||||
Role: strings.TrimSpace(body.Role),
|
||||
}
|
||||
if body.ExpiresAt != nil && strings.TrimSpace(*body.ExpiresAt) != "" {
|
||||
t, err := time.Parse(time.RFC3339, strings.TrimSpace(*body.ExpiresAt))
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "invalid expires_at")
|
||||
return
|
||||
}
|
||||
in.ExpiresAt = &t
|
||||
}
|
||||
created, err := s.store.CreateAPIKey(a.TenantID, in)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
if err := s.keyResolver.Reload(s.store); err != nil {
|
||||
writeProblem(w, http.StatusInternalServerError, "Internal Server Error", "failed to reload api keys")
|
||||
return
|
||||
}
|
||||
out := apiKeyJSON(&created.APIKey)
|
||||
out["token"] = created.Token
|
||||
writeJSON(w, http.StatusCreated, out)
|
||||
}
|
||||
|
||||
func (s *Server) handlePatchAPIKey(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "operator") {
|
||||
return
|
||||
}
|
||||
var raw map[string]json.RawMessage
|
||||
if err := json.NewDecoder(r.Body).Decode(&raw); err != nil {
|
||||
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
|
||||
return
|
||||
}
|
||||
patch := &store.APIKeyPatch{}
|
||||
if v, ok := raw["name"]; ok {
|
||||
var name string
|
||||
if err := json.Unmarshal(v, &name); err != nil {
|
||||
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid name")
|
||||
return
|
||||
}
|
||||
patch.Name = &name
|
||||
}
|
||||
if v, ok := raw["role"]; ok {
|
||||
var role string
|
||||
if err := json.Unmarshal(v, &role); err != nil {
|
||||
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid role")
|
||||
return
|
||||
}
|
||||
patch.Role = &role
|
||||
}
|
||||
if v, ok := raw["expires_at"]; ok {
|
||||
if string(v) == "null" {
|
||||
patch.ClearExpiresAt = true
|
||||
} else {
|
||||
var s string
|
||||
if err := json.Unmarshal(v, &s); err != nil {
|
||||
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid expires_at")
|
||||
return
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339, strings.TrimSpace(s))
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "invalid expires_at")
|
||||
return
|
||||
}
|
||||
patch.ExpiresAt = &t
|
||||
}
|
||||
}
|
||||
k, err := s.store.UpdateAPIKey(a.TenantID, r.PathValue("id"), patch)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
if err := s.keyResolver.Reload(s.store); err != nil {
|
||||
writeProblem(w, http.StatusInternalServerError, "Internal Server Error", "failed to reload api keys")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, apiKeyJSON(k))
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteAPIKey(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "operator") {
|
||||
return
|
||||
}
|
||||
if err := s.store.RevokeAPIKey(a.TenantID, r.PathValue("id")); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
if err := s.keyResolver.Reload(s.store); err != nil {
|
||||
writeProblem(w, http.StatusInternalServerError, "Internal Server Error", "failed to reload api keys")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) handleRotateAPIKey(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "operator") {
|
||||
return
|
||||
}
|
||||
rotated, err := s.store.RotateAPIKey(a.TenantID, r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
if err := s.keyResolver.Reload(s.store); err != nil {
|
||||
writeProblem(w, http.StatusInternalServerError, "Internal Server Error", "failed to reload api keys")
|
||||
return
|
||||
}
|
||||
out := apiKeyJSON(&rotated.APIKey)
|
||||
out["token"] = rotated.Token
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBearerDevWithoutInsecureDev(t *testing.T) {
|
||||
srv, err := New(Options{SeedDemo: true, BundleSeedHex: testBundleSeed})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer srv.Close()
|
||||
|
||||
ts := httptest.NewServer(srv.Handler())
|
||||
defer ts.Close()
|
||||
|
||||
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/modules?limit=1", nil)
|
||||
req.Header.Set("Authorization", "Bearer dev")
|
||||
resp, err := ts.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("status=%d body=%s", resp.StatusCode, b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIKeysCRUDAndAuth(t *testing.T) {
|
||||
srv, err := New(Options{SeedDemo: true, BundleSeedHex: testBundleSeed})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer srv.Close()
|
||||
tenant, _, _, _, _ := srv.Store().DemoIDs()
|
||||
mustSetTestAPIKeys(t, srv, "opkey|"+tenant+"|operator")
|
||||
|
||||
ts := httptest.NewServer(srv.Handler())
|
||||
defer ts.Close()
|
||||
client := ts.Client()
|
||||
base := ts.URL
|
||||
|
||||
reqCreate, _ := http.NewRequest(http.MethodPost, base+"/v1/api-keys", strings.NewReader(`{"name":"ci","role":"editor"}`))
|
||||
reqCreate.Header.Set("Authorization", "Bearer opkey")
|
||||
reqCreate.Header.Set("Content-Type", "application/json")
|
||||
respCreate, err := client.Do(reqCreate)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = respCreate.Body.Close() }()
|
||||
if respCreate.StatusCode != http.StatusCreated {
|
||||
b, _ := io.ReadAll(respCreate.Body)
|
||||
t.Fatalf("create status=%d body=%s", respCreate.StatusCode, b)
|
||||
}
|
||||
var created map[string]any
|
||||
if err := json.NewDecoder(respCreate.Body).Decode(&created); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
token, _ := created["token"].(string)
|
||||
if token == "" {
|
||||
t.Fatal("missing token in create response")
|
||||
}
|
||||
id, _ := created["id"].(string)
|
||||
if id == "" {
|
||||
t.Fatal("missing id")
|
||||
}
|
||||
|
||||
reqMod, _ := http.NewRequest(http.MethodGet, base+"/v1/modules?limit=1", nil)
|
||||
reqMod.Header.Set("Authorization", "Bearer "+token)
|
||||
respMod, err := client.Do(reqMod)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = respMod.Body.Close() }()
|
||||
if respMod.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(respMod.Body)
|
||||
t.Fatalf("modules status=%d body=%s", respMod.StatusCode, b)
|
||||
}
|
||||
|
||||
reqDel, _ := http.NewRequest(http.MethodDelete, base+"/v1/api-keys/"+id, nil)
|
||||
reqDel.Header.Set("Authorization", "Bearer opkey")
|
||||
respDel, err := client.Do(reqDel)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = respDel.Body.Close() }()
|
||||
if respDel.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("delete status=%d", respDel.StatusCode)
|
||||
}
|
||||
|
||||
reqAfter, _ := http.NewRequest(http.MethodGet, base+"/v1/modules?limit=1", nil)
|
||||
reqAfter.Header.Set("Authorization", "Bearer "+token)
|
||||
respAfter, err := client.Do(reqAfter)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = respAfter.Body.Close() }()
|
||||
if respAfter.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401 after revoke, got %d", respAfter.StatusCode)
|
||||
}
|
||||
|
||||
mustSetTestAPIKeys(t, srv, "nodekey|"+tenant+"|node,opkey|"+tenant+"|operator")
|
||||
reqNode2, _ := http.NewRequest(http.MethodGet, base+"/v1/api-keys", nil)
|
||||
reqNode2.Header.Set("Authorization", "Bearer nodekey")
|
||||
respNode, err := client.Do(reqNode2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = respNode.Body.Close() }()
|
||||
if respNode.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("node list api-keys status=%d want 403", respNode.StatusCode)
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -64,11 +65,14 @@ func (s *Server) registerCRUDRoutes(m *http.ServeMux) {
|
||||
m.HandleFunc("POST /speakers", s.handlePostSpeaker)
|
||||
m.HandleFunc("GET /speakers/{speaker_id}", s.handleGetSpeakerByID)
|
||||
m.HandleFunc("PATCH /speakers/{speaker_id}", s.handlePatchSpeaker)
|
||||
m.HandleFunc("DELETE /speakers/{speaker_id}", s.handleDeleteSpeaker)
|
||||
|
||||
m.HandleFunc("GET /revisions/{revision_id}/prefixes", s.handleRevisionPrefixes)
|
||||
|
||||
m.HandleFunc("GET /settings", s.handleGetSettings)
|
||||
m.HandleFunc("PATCH /settings", s.handlePatchSettings)
|
||||
|
||||
s.registerAPIKeyRoutes(m)
|
||||
}
|
||||
|
||||
func (s *Server) handlePostModule(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -172,12 +176,15 @@ func (s *Server) handleDeleteModule(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func writeStoreErr(w http.ResponseWriter, err error) {
|
||||
if err != nil {
|
||||
log.Printf("httpapi: store: %v", err)
|
||||
}
|
||||
if err == store.ErrNotFound || err == store.ErrTenantScope {
|
||||
writeProblem(w, http.StatusNotFound, "Not Found", err.Error())
|
||||
writeProblem(w, http.StatusNotFound, "Not Found", notFoundDetail)
|
||||
return
|
||||
}
|
||||
if err == store.ErrInvalidInput {
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", err.Error())
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", invalidInputDetail)
|
||||
return
|
||||
}
|
||||
writeInternalError(w, "store", err)
|
||||
@@ -193,11 +200,7 @@ func (s *Server) handleListCDNSources(w http.ResponseWriter, r *http.Request) {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
items := make([]map[string]any, 0, len(list))
|
||||
for _, x := range list {
|
||||
items = append(items, cdnSourceJSON(x))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": items, "next_cursor": nil, "has_more": false})
|
||||
writePaginatedListJSON(w, r, list, cdnSourceJSON)
|
||||
}
|
||||
|
||||
func cdnSourceJSON(x *store.CDNSource) map[string]any {
|
||||
@@ -239,6 +242,14 @@ func (s *Server) handlePreviewCDNSource(w http.ResponseWriter, r *http.Request)
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "url is required")
|
||||
return
|
||||
}
|
||||
if _, err := pipeline.ValidateCDNURL(u); err != nil {
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", invalidInputDetail)
|
||||
return
|
||||
}
|
||||
if err := pipeline.ResolveCDNURLHost(r.Context(), u); err != nil {
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", invalidInputDetail)
|
||||
return
|
||||
}
|
||||
mod, err := s.store.GetModule(a.TenantID, r.PathValue("module_id"))
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
@@ -258,7 +269,7 @@ func (s *Server) handlePreviewCDNSource(w http.ResponseWriter, r *http.Request)
|
||||
writeBadGateway(w, "cdn preview fetch", err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
writeBadGateway(w, "cdn preview fetch", fmt.Errorf("upstream status: %s", resp.Status))
|
||||
@@ -271,7 +282,8 @@ func (s *Server) handlePreviewCDNSource(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
pfxs, err := pipeline.ExtractCIDRs(string(raw), body.SourceKind, body.PrefixPath)
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", err.Error())
|
||||
log.Printf("httpapi: cdn preview extract: %v", err)
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", cdnExtractDetail)
|
||||
return
|
||||
}
|
||||
items := make([]string, 0, len(pfxs))
|
||||
@@ -300,6 +312,16 @@ func (s *Server) handlePostCDNSource(w http.ResponseWriter, r *http.Request) {
|
||||
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
|
||||
return
|
||||
}
|
||||
if body.URL != "" {
|
||||
if _, err := pipeline.ValidateCDNURL(body.URL); err != nil {
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", invalidInputDetail)
|
||||
return
|
||||
}
|
||||
if err := pipeline.ResolveCDNURLHost(r.Context(), body.URL); err != nil {
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", invalidInputDetail)
|
||||
return
|
||||
}
|
||||
}
|
||||
mid := r.PathValue("module_id")
|
||||
x, err := s.store.CreateCDNSource(a.TenantID, mid, &body)
|
||||
if err != nil {
|
||||
@@ -320,6 +342,16 @@ func (s *Server) handlePatchCDNSource(w http.ResponseWriter, r *http.Request) {
|
||||
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
|
||||
return
|
||||
}
|
||||
if body.URL != nil && strings.TrimSpace(*body.URL) != "" {
|
||||
if _, err := pipeline.ValidateCDNURL(*body.URL); err != nil {
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", invalidInputDetail)
|
||||
return
|
||||
}
|
||||
if err := pipeline.ResolveCDNURLHost(r.Context(), *body.URL); err != nil {
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", invalidInputDetail)
|
||||
return
|
||||
}
|
||||
}
|
||||
mid := r.PathValue("module_id")
|
||||
x, err := s.store.UpdateCDNSource(a.TenantID, mid, r.PathValue("source_id"), &body)
|
||||
if err != nil {
|
||||
@@ -354,11 +386,7 @@ func (s *Server) handleListAS(w http.ResponseWriter, r *http.Request) {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
items := make([]map[string]any, 0, len(list))
|
||||
for _, x := range list {
|
||||
items = append(items, asEntryJSON(x))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||
writePaginatedListJSON(w, r, list, asEntryJSON)
|
||||
}
|
||||
|
||||
func asEntryJSON(x *store.ASEntry) map[string]any {
|
||||
@@ -450,11 +478,7 @@ func (s *Server) handleListDomain(w http.ResponseWriter, r *http.Request) {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
items := make([]map[string]any, 0, len(list))
|
||||
for _, x := range list {
|
||||
items = append(items, domainEntryJSON(x))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||
writePaginatedListJSON(w, r, list, domainEntryJSON)
|
||||
}
|
||||
|
||||
func domainEntryJSON(x *store.DomainEntry) map[string]any {
|
||||
@@ -531,11 +555,7 @@ func (s *Server) handleListIPRange(w http.ResponseWriter, r *http.Request) {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
items := make([]map[string]any, 0, len(list))
|
||||
for _, x := range list {
|
||||
items = append(items, ipRangeJSON(x))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||
writePaginatedListJSON(w, r, list, ipRangeJSON)
|
||||
}
|
||||
|
||||
func ipRangeJSON(x *store.IPRangeEntry) map[string]any {
|
||||
@@ -702,8 +722,8 @@ func (s *Server) handleImportModuleEntriesCSV(w http.ResponseWriter, r *http.Req
|
||||
return
|
||||
}
|
||||
if strings.Contains(err.Error(), "importer: line") {
|
||||
detail := strings.TrimPrefix(err.Error(), "importer: ")
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", detail)
|
||||
log.Printf("httpapi: csv import: %v", err)
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", csvInvalidRowDetail)
|
||||
return
|
||||
}
|
||||
if strings.Contains(err.Error(), "importer: csv import/export") {
|
||||
@@ -983,12 +1003,20 @@ func (s *Server) handlePostSpeaker(w http.ResponseWriter, r *http.Request) {
|
||||
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
|
||||
return
|
||||
}
|
||||
if err := normalizeSpeakerCreate(&body); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
x, err := s.store.CreateSpeaker(a.TenantID, &body)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, speakerJSON(x))
|
||||
resp := speakerJSONFromStore(s.store, x)
|
||||
if meta := store.ParseSpeakerMeta(x.MetaJSON); meta.AgentSecret != "" {
|
||||
resp["agent_secret"] = meta.AgentSecret
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, resp)
|
||||
}
|
||||
|
||||
func (s *Server) handleGetSpeakerByID(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -1001,7 +1029,7 @@ func (s *Server) handleGetSpeakerByID(w http.ResponseWriter, r *http.Request) {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, speakerJSON(x))
|
||||
writeJSON(w, http.StatusOK, speakerJSONFromStore(s.store, x))
|
||||
}
|
||||
|
||||
func (s *Server) handlePatchSpeaker(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -1019,7 +1047,19 @@ func (s *Server) handlePatchSpeaker(w http.ResponseWriter, r *http.Request) {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, speakerJSON(x))
|
||||
writeJSON(w, http.StatusOK, speakerJSONFromStore(s.store, x))
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteSpeaker(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "editor") {
|
||||
return
|
||||
}
|
||||
if err := s.store.DeleteSpeaker(a.TenantID, r.PathValue("speaker_id")); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) handleRevisionPrefixes(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -21,7 +21,7 @@ func TestModuleEntriesCSVImportExportIPRanges(t *testing.T) {
|
||||
defer srv.Close()
|
||||
|
||||
tenant, _, modIP, _, _ := srv.Store().DemoIDs()
|
||||
srv.apiKeys = parseAPIKeysSpec("opkey|" + tenant + "|operator")
|
||||
mustSetTestAPIKeys(t, srv, "opkey|"+tenant+"|operator")
|
||||
|
||||
ts := httptest.NewServer(srv.Handler())
|
||||
defer ts.Close()
|
||||
@@ -34,7 +34,7 @@ func TestModuleEntriesCSVImportExportIPRanges(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer respList.Body.Close()
|
||||
defer func() { _ = respList.Body.Close() }()
|
||||
if respList.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(respList.Body)
|
||||
t.Fatalf("communities status %d: %s", respList.StatusCode, b)
|
||||
@@ -59,7 +59,7 @@ func TestModuleEntriesCSVImportExportIPRanges(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer respImport.Body.Close()
|
||||
defer func() { _ = respImport.Body.Close() }()
|
||||
if respImport.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(respImport.Body)
|
||||
t.Fatalf("import status %d: %s", respImport.StatusCode, b)
|
||||
@@ -80,7 +80,7 @@ func TestModuleEntriesCSVImportExportIPRanges(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer respExport.Body.Close()
|
||||
defer func() { _ = respExport.Body.Close() }()
|
||||
if respExport.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(respExport.Body)
|
||||
t.Fatalf("export status %d: %s", respExport.StatusCode, b)
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNestedModuleListPagination(t *testing.T) {
|
||||
srv, err := New(Options{InsecureDev: true, SeedDemo: true, BundleSeedHex: testBundleSeed})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer srv.Close()
|
||||
tenant, _, modIP, _, _ := srv.Store().DemoIDs()
|
||||
mustSetTestAPIKeys(t, srv, "edkey|"+tenant+"|editor")
|
||||
|
||||
ts := httptest.NewServer(srv.Handler())
|
||||
defer ts.Close()
|
||||
client := ts.Client()
|
||||
base := ts.URL
|
||||
mid := modIP
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
body := strings.NewReader(fmt.Sprintf(`{"prefix":"10.%d.0.0/24"}`, 200+i))
|
||||
req, _ := http.NewRequest(http.MethodPost, base+"/v1/modules/"+mid+"/ip-range-entries", body)
|
||||
req.Header.Set("Authorization", "Bearer edkey")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("create entry %d: status %d", i, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
req, _ := http.NewRequest(http.MethodGet, base+"/v1/modules/"+mid+"/ip-range-entries?limit=2", nil)
|
||||
req.Header.Set("Authorization", "Bearer edkey")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("list status %d: %s", resp.StatusCode, b)
|
||||
}
|
||||
var page1 struct {
|
||||
Items []map[string]any `json:"items"`
|
||||
NextCursor *string `json:"next_cursor"`
|
||||
HasMore bool `json:"has_more"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&page1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(page1.Items) != 2 {
|
||||
t.Fatalf("page1 items: got %d want 2", len(page1.Items))
|
||||
}
|
||||
if !page1.HasMore || page1.NextCursor == nil || *page1.NextCursor == "" {
|
||||
t.Fatalf("page1: has_more=%v next_cursor=%v", page1.HasMore, page1.NextCursor)
|
||||
}
|
||||
|
||||
req2, _ := http.NewRequest(http.MethodGet, base+"/v1/modules/"+mid+"/ip-range-entries?limit=2&cursor="+*page1.NextCursor, nil)
|
||||
req2.Header.Set("Authorization", "Bearer edkey")
|
||||
resp2, err := client.Do(req2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = resp2.Body.Close() }()
|
||||
var page2 struct {
|
||||
Items []map[string]any `json:"items"`
|
||||
HasMore bool `json:"has_more"`
|
||||
}
|
||||
if err := json.NewDecoder(resp2.Body).Decode(&page2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(page1.Items)+len(page2.Items) < 3 {
|
||||
t.Fatalf("expected at least 3 entries across pages, got %d+%d", len(page1.Items), len(page2.Items))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"evobgp/internal/jobs"
|
||||
"evobgp/internal/observability"
|
||||
"evobgp/internal/store"
|
||||
)
|
||||
|
||||
func (s *Server) registerMaintenanceRoutes(m *http.ServeMux) {
|
||||
m.HandleFunc("GET /maintenance/policies", s.handleListMaintenancePolicies)
|
||||
m.HandleFunc("POST /maintenance/policies", s.handleCreateMaintenancePolicy)
|
||||
m.HandleFunc("GET /maintenance/policies/{id}", s.handleGetMaintenancePolicy)
|
||||
m.HandleFunc("PATCH /maintenance/policies/{id}", s.handlePatchMaintenancePolicy)
|
||||
m.HandleFunc("DELETE /maintenance/policies/{id}", s.handleDeleteMaintenancePolicy)
|
||||
m.HandleFunc("GET /maintenance/policies/{id}/hints", s.handleMaintenancePolicyHints)
|
||||
m.HandleFunc("GET /maintenance/config-audit", s.handleListMaintenanceConfigAudit)
|
||||
m.HandleFunc("POST /maintenance/run", s.handleMaintenanceRun)
|
||||
m.HandleFunc("POST /maintenance/dry-run", s.handleMaintenanceDryRun)
|
||||
}
|
||||
|
||||
func maintenancePolicyJSON(p *store.MaintenancePolicy) map[string]any {
|
||||
if p == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
out := map[string]any{
|
||||
"id": p.ID,
|
||||
"name": p.Name,
|
||||
"table_name": p.TableName,
|
||||
"condition": p.Condition,
|
||||
"vacuum_strategy": p.VacuumStrategy,
|
||||
"schedule": p.Schedule,
|
||||
"enabled": p.Enabled,
|
||||
"dry_run_enabled": p.DryRunEnabled,
|
||||
}
|
||||
if p.RetentionPeriodSec != nil {
|
||||
out["retention_period_sec"] = *p.RetentionPeriodSec
|
||||
}
|
||||
if p.MaxRows != nil {
|
||||
out["max_rows"] = *p.MaxRows
|
||||
}
|
||||
if p.LastRunAt != nil {
|
||||
out["last_run_at"] = p.LastRunAt.UTC().Format("2006-01-02T15:04:05Z")
|
||||
}
|
||||
if p.LastStatus != "" {
|
||||
out["last_status"] = p.LastStatus
|
||||
}
|
||||
if p.LastError != "" {
|
||||
out["last_error"] = p.LastError
|
||||
}
|
||||
if !p.CreatedAt.IsZero() {
|
||||
out["created_at"] = p.CreatedAt.UTC().Format("2006-01-02T15:04:05Z")
|
||||
}
|
||||
if !p.UpdatedAt.IsZero() {
|
||||
out["updated_at"] = p.UpdatedAt.UTC().Format("2006-01-02T15:04:05Z")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Server) handleListMaintenancePolicies(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") || !s.requirePostgres(w) {
|
||||
return
|
||||
}
|
||||
cursor := r.URL.Query().Get("cursor")
|
||||
limit := parseLimitQuery(r, 20, 100)
|
||||
items, next, hasMore, err := s.store.ListMaintenancePolicies(cursor, limit)
|
||||
if err != nil {
|
||||
writeInternalError(w, "maintenance_policies_list", err)
|
||||
return
|
||||
}
|
||||
out := make([]map[string]any, 0, len(items))
|
||||
for _, p := range items {
|
||||
out = append(out, maintenancePolicyJSON(p))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": out, "next_cursor": next, "has_more": hasMore})
|
||||
}
|
||||
|
||||
func (s *Server) handleGetMaintenancePolicy(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") || !s.requirePostgres(w) {
|
||||
return
|
||||
}
|
||||
p, err := s.store.GetMaintenancePolicy(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, maintenancePolicyJSON(p))
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateMaintenancePolicy(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requirePostgres(w) || !s.requireOperatorStrict(w, a) {
|
||||
return
|
||||
}
|
||||
var body store.MaintenancePolicy
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeProblem(w, http.StatusBadRequest, "Bad Request", invalidInputDetail)
|
||||
return
|
||||
}
|
||||
p, err := s.store.CreateMaintenancePolicy(&body)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
_ = s.store.AppendMaintenancePolicyConfigAudit(actorPrefix(a), p.ID, "create", nil, maintenancePolicyJSON(p))
|
||||
observability.IncMaintenanceConfigChange("create")
|
||||
s.reloadMaintenanceConfig(r)
|
||||
writeJSON(w, http.StatusCreated, maintenancePolicyJSON(p))
|
||||
}
|
||||
|
||||
func (s *Server) handlePatchMaintenancePolicy(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requirePostgres(w) || !s.requireOperatorStrict(w, a) {
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
before, err := s.store.GetMaintenancePolicy(id)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
var patch store.MaintenancePolicyPatch
|
||||
if err := json.NewDecoder(r.Body).Decode(&patch); err != nil {
|
||||
writeProblem(w, http.StatusBadRequest, "Bad Request", invalidInputDetail)
|
||||
return
|
||||
}
|
||||
updated, err := s.store.UpdateMaintenancePolicy(id, &patch)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
_ = s.store.AppendMaintenancePolicyConfigAudit(actorPrefix(a), id, "update", maintenancePolicyJSON(before), maintenancePolicyJSON(updated))
|
||||
observability.IncMaintenanceConfigChange("update")
|
||||
s.reloadMaintenanceConfig(r)
|
||||
writeJSON(w, http.StatusOK, maintenancePolicyJSON(updated))
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteMaintenancePolicy(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requirePostgres(w) || !s.requireOperatorStrict(w, a) {
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
before, err := s.store.GetMaintenancePolicy(id)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
if err := s.store.DeleteMaintenancePolicy(id); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
_ = s.store.AppendMaintenancePolicyConfigAudit(actorPrefix(a), id, "delete", maintenancePolicyJSON(before), nil)
|
||||
observability.IncMaintenanceConfigChange("delete")
|
||||
s.reloadMaintenanceConfig(r)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) handleMaintenancePolicyHints(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") || !s.requirePostgres(w) {
|
||||
return
|
||||
}
|
||||
if s.maintStats == nil {
|
||||
writeProblem(w, http.StatusServiceUnavailable, "Unavailable", "postgresql backend required")
|
||||
return
|
||||
}
|
||||
p, err := s.store.GetMaintenancePolicy(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
hints, err := s.maintStats.Hints(r.Context(), p.TableName)
|
||||
if err != nil {
|
||||
writeInternalError(w, "maintenance_policy_hints", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, hints)
|
||||
}
|
||||
|
||||
func (s *Server) handleListMaintenanceConfigAudit(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") || !s.requirePostgres(w) {
|
||||
return
|
||||
}
|
||||
cursor := r.URL.Query().Get("cursor")
|
||||
limit := parseLimitQuery(r, 20, 100)
|
||||
items, next, hasMore, err := s.store.ListMaintenancePolicyConfigAudit(cursor, limit)
|
||||
if err != nil {
|
||||
writeInternalError(w, "maintenance_config_audit", err)
|
||||
return
|
||||
}
|
||||
out := make([]map[string]any, 0, len(items))
|
||||
for _, row := range items {
|
||||
out = append(out, map[string]any{
|
||||
"id": row.ID,
|
||||
"policy_id": row.PolicyID,
|
||||
"actor_prefix": row.ActorPrefix,
|
||||
"action": row.Action,
|
||||
"before": row.Before,
|
||||
"after": row.After,
|
||||
"created_at": row.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"),
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": out, "next_cursor": next, "has_more": hasMore})
|
||||
}
|
||||
|
||||
type maintenanceRunBody struct {
|
||||
PolicyID string `json:"policy_id"`
|
||||
}
|
||||
|
||||
func (s *Server) handleMaintenanceRun(w http.ResponseWriter, r *http.Request) {
|
||||
s.enqueueMaintenancePolicy(w, r, false)
|
||||
}
|
||||
|
||||
func (s *Server) handleMaintenanceDryRun(w http.ResponseWriter, r *http.Request) {
|
||||
s.enqueueMaintenancePolicy(w, r, true)
|
||||
}
|
||||
|
||||
func (s *Server) enqueueMaintenancePolicy(w http.ResponseWriter, r *http.Request, dryRun bool) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requirePostgres(w) || !s.requireOperatorStrict(w, a) {
|
||||
return
|
||||
}
|
||||
var body maintenanceRunBody
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeProblem(w, http.StatusBadRequest, "Bad Request", invalidInputDetail)
|
||||
return
|
||||
}
|
||||
policyID := strings.TrimSpace(body.PolicyID)
|
||||
if policyID == "" {
|
||||
writeProblem(w, http.StatusBadRequest, "Bad Request", "policy_id is required")
|
||||
return
|
||||
}
|
||||
if _, err := s.store.GetMaintenancePolicy(policyID); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
kind := "maintenance_policy_run"
|
||||
if !s.checkPgMaintRateLimit(a.TenantID, kind+":"+policyID) {
|
||||
writeProblem(w, http.StatusTooManyRequests, "Too Many Requests", "wait before repeating this maintenance operation")
|
||||
return
|
||||
}
|
||||
idem := strings.TrimSpace(r.Header.Get("Idempotency-Key"))
|
||||
var idemPtr *string
|
||||
if idem != "" {
|
||||
idemPtr = &idem
|
||||
}
|
||||
title := "Maintenance policy run"
|
||||
if dryRun {
|
||||
title = "Maintenance policy dry-run"
|
||||
}
|
||||
j, _, err := s.jobs.Enqueue(a.TenantID, jobs.KindMaintenancePolicyRun, idemPtr, nil, map[string]any{
|
||||
"policy_id": policyID, "dry_run": dryRun, "actor_prefix": actorPrefix(a), "job_title": title,
|
||||
})
|
||||
if err != nil {
|
||||
writeInternalError(w, "maintenance_policy_enqueue", err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Location", "/v1/jobs/"+j.ID)
|
||||
snap := j.Snapshot()
|
||||
writeJSON(w, http.StatusAccepted, map[string]any{"job_id": snap["job_id"], "status": snap["status"]})
|
||||
}
|
||||
|
||||
func (s *Server) reloadMaintenanceConfig(r *http.Request) {
|
||||
if s.maintConfig != nil {
|
||||
_ = s.maintConfig.Reload(r.Context())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMaintenancePoliciesMemoryBackend503(t *testing.T) {
|
||||
srv, err := New(Options{SeedDemo: true, InsecureDev: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer srv.Close()
|
||||
handler := srv.Handler()
|
||||
|
||||
tests := []struct {
|
||||
method string
|
||||
path string
|
||||
body string
|
||||
}{
|
||||
{http.MethodGet, "/v1/maintenance/policies", ""},
|
||||
{http.MethodPost, "/v1/maintenance/policies", `{"name":"x","table_name":"job_audit","schedule":"0 3 * * *"}`},
|
||||
{http.MethodPost, "/v1/maintenance/run", `{"policy_id":"00000000-0000-0000-0000-000000000001"}`},
|
||||
{http.MethodGet, "/v1/maintenance/config-audit", ""},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
|
||||
var req *http.Request
|
||||
if tc.body != "" {
|
||||
req = httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
} else {
|
||||
req = httptest.NewRequest(tc.method, tc.path, nil)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer dev")
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"evobgp/internal/jobs"
|
||||
"evobgp/internal/pgmonitor"
|
||||
)
|
||||
|
||||
var (
|
||||
pgMaintRateMu sync.Mutex
|
||||
pgMaintLastByTK = map[string]time.Time{}
|
||||
)
|
||||
|
||||
func (s *Server) registerPostgresMaintenanceRoutes(m *http.ServeMux) {
|
||||
m.HandleFunc("POST /postgres/vacuum", s.handlePostgresVacuum)
|
||||
m.HandleFunc("POST /postgres/vacuum-analyze", s.handlePostgresVacuumAnalyze)
|
||||
m.HandleFunc("POST /postgres/analyze", s.handlePostgresAnalyze)
|
||||
m.HandleFunc("POST /postgres/reindex", s.handlePostgresReindex)
|
||||
m.HandleFunc("POST /postgres/cleanup", s.handlePostgresCleanup)
|
||||
m.HandleFunc("GET /postgres/maintenance/logs", s.handlePostgresMaintenanceLogs)
|
||||
}
|
||||
|
||||
func (s *Server) requireOperatorStrict(w http.ResponseWriter, a Auth) bool {
|
||||
if strings.ToLower(a.Role) != "operator" {
|
||||
writeProblem(w, http.StatusForbidden, "Forbidden", "operator role required")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Server) checkPgMaintRateLimit(tenantID, kind string) bool {
|
||||
key := tenantID + ":" + kind
|
||||
pgMaintRateMu.Lock()
|
||||
defer pgMaintRateMu.Unlock()
|
||||
if t, ok := pgMaintLastByTK[key]; ok && time.Since(t) < 60*time.Second {
|
||||
return false
|
||||
}
|
||||
pgMaintLastByTK[key] = time.Now().UTC()
|
||||
return true
|
||||
}
|
||||
|
||||
type pgMaintBody struct {
|
||||
Table string `json:"table"`
|
||||
DryRun bool `json:"dry_run"`
|
||||
Index string `json:"index"`
|
||||
Policy string `json:"policy"`
|
||||
PolicyID string `json:"policy_id"`
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
|
||||
func (s *Server) decodePgMaintBody(r *http.Request) (pgMaintBody, bool) {
|
||||
var body pgMaintBody
|
||||
if r.Body == nil || r.ContentLength == 0 {
|
||||
return body, true
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body); err != nil && err != io.EOF {
|
||||
return body, false
|
||||
}
|
||||
return body, true
|
||||
}
|
||||
|
||||
func (s *Server) enqueuePostgresMaint(w http.ResponseWriter, r *http.Request, a Auth, kind string, meta map[string]any) {
|
||||
if !s.requirePostgres(w) || !s.requireOperatorStrict(w, a) {
|
||||
return
|
||||
}
|
||||
if !s.checkPgMaintRateLimit(a.TenantID, kind) {
|
||||
writeProblem(w, http.StatusTooManyRequests, "Too Many Requests", "wait before repeating this maintenance operation")
|
||||
return
|
||||
}
|
||||
idem := strings.TrimSpace(r.Header.Get("Idempotency-Key"))
|
||||
var idemPtr *string
|
||||
if idem != "" {
|
||||
idemPtr = &idem
|
||||
}
|
||||
meta["actor_prefix"] = actorPrefix(a)
|
||||
j, _, err := s.jobs.Enqueue(a.TenantID, kind, idemPtr, nil, meta)
|
||||
if err != nil {
|
||||
writeInternalError(w, "postgres_maint_enqueue", err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Location", "/v1/jobs/"+j.ID)
|
||||
snap := j.Snapshot()
|
||||
writeJSON(w, http.StatusAccepted, map[string]any{"job_id": snap["job_id"], "status": snap["status"]})
|
||||
}
|
||||
|
||||
func (s *Server) handlePostgresVacuum(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok {
|
||||
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
||||
return
|
||||
}
|
||||
body, ok2 := s.decodePgMaintBody(r)
|
||||
if !ok2 {
|
||||
writeProblem(w, http.StatusBadRequest, "Bad Request", invalidInputDetail)
|
||||
return
|
||||
}
|
||||
s.enqueuePostgresMaint(w, r, a, jobs.KindPostgresVacuum, map[string]any{
|
||||
"table": body.Table, "dry_run": body.DryRun, "job_title": "PostgreSQL VACUUM",
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handlePostgresVacuumAnalyze(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok {
|
||||
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
||||
return
|
||||
}
|
||||
body, ok2 := s.decodePgMaintBody(r)
|
||||
if !ok2 {
|
||||
writeProblem(w, http.StatusBadRequest, "Bad Request", invalidInputDetail)
|
||||
return
|
||||
}
|
||||
s.enqueuePostgresMaint(w, r, a, jobs.KindPostgresVacuumAnalyze, map[string]any{
|
||||
"table": body.Table, "dry_run": body.DryRun, "job_title": "PostgreSQL VACUUM ANALYZE",
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handlePostgresAnalyze(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok {
|
||||
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
||||
return
|
||||
}
|
||||
body, ok2 := s.decodePgMaintBody(r)
|
||||
if !ok2 {
|
||||
writeProblem(w, http.StatusBadRequest, "Bad Request", invalidInputDetail)
|
||||
return
|
||||
}
|
||||
s.enqueuePostgresMaint(w, r, a, jobs.KindPostgresAnalyze, map[string]any{
|
||||
"table": body.Table, "dry_run": body.DryRun, "job_title": "PostgreSQL ANALYZE",
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handlePostgresReindex(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok {
|
||||
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
||||
return
|
||||
}
|
||||
body, ok2 := s.decodePgMaintBody(r)
|
||||
if !ok2 {
|
||||
writeProblem(w, http.StatusBadRequest, "Bad Request", invalidInputDetail)
|
||||
return
|
||||
}
|
||||
table := body.Table
|
||||
if table == "" {
|
||||
table = body.Index
|
||||
}
|
||||
s.enqueuePostgresMaint(w, r, a, jobs.KindPostgresReindex, map[string]any{
|
||||
"table": table, "dry_run": body.DryRun, "job_title": "PostgreSQL REINDEX",
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handlePostgresCleanup(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok {
|
||||
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
||||
return
|
||||
}
|
||||
body, ok2 := s.decodePgMaintBody(r)
|
||||
if !ok2 {
|
||||
writeProblem(w, http.StatusBadRequest, "Bad Request", invalidInputDetail)
|
||||
return
|
||||
}
|
||||
policyID := strings.TrimSpace(body.PolicyID)
|
||||
if policyID == "" {
|
||||
policyID = strings.TrimSpace(body.Policy)
|
||||
}
|
||||
if policyID == "" {
|
||||
writeProblem(w, http.StatusBadRequest, "Bad Request", "policy_id is required")
|
||||
return
|
||||
}
|
||||
if _, err := s.store.GetMaintenancePolicy(policyID); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
idem := strings.TrimSpace(r.Header.Get("Idempotency-Key"))
|
||||
var idemPtr *string
|
||||
if idem != "" {
|
||||
idemPtr = &idem
|
||||
}
|
||||
j, _, err := s.jobs.Enqueue(a.TenantID, jobs.KindMaintenancePolicyRun, idemPtr, nil, map[string]any{
|
||||
"policy_id": policyID, "dry_run": body.DryRun, "actor_prefix": actorPrefix(a),
|
||||
"job_title": "PostgreSQL cleanup (deprecated path)",
|
||||
})
|
||||
if err != nil {
|
||||
writeInternalError(w, "postgres_maint_enqueue", err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Location", "/v1/jobs/"+j.ID)
|
||||
snap := j.Snapshot()
|
||||
writeJSON(w, http.StatusAccepted, map[string]any{"job_id": snap["job_id"], "status": snap["status"]})
|
||||
}
|
||||
|
||||
func (s *Server) handlePostgresMaintenanceLogs(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") || !s.requirePostgres(w) {
|
||||
return
|
||||
}
|
||||
cursor := r.URL.Query().Get("cursor")
|
||||
limit := parseLimitQuery(r, 20, 100)
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
items, next, hasMore, err := pgmonitor.ListMaintenanceLogs(ctx, s.pgMonitor.Pool(), cursor, limit)
|
||||
if err != nil {
|
||||
writeInternalError(w, "postgres_maint_logs", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": items, "next_cursor": next, "has_more": hasMore})
|
||||
}
|
||||
|
||||
func actorPrefix(a Auth) string {
|
||||
if len(a.Token) >= 8 {
|
||||
return a.Token[:8]
|
||||
}
|
||||
return a.Role
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (s *Server) registerPostgresMonitoringRoutes(m *http.ServeMux) {
|
||||
m.HandleFunc("GET /monitoring/postgres/overview", s.handlePostgresOverview)
|
||||
m.HandleFunc("GET /monitoring/postgres/queries", s.handlePostgresQueries)
|
||||
m.HandleFunc("GET /monitoring/postgres/locks", s.handlePostgresLocks)
|
||||
m.HandleFunc("GET /monitoring/postgres/tables", s.handlePostgresTables)
|
||||
m.HandleFunc("GET /monitoring/postgres/recommendations", s.handlePostgresRecommendations)
|
||||
m.HandleFunc("GET /monitoring/correlation", s.handleMonitoringCorrelation)
|
||||
}
|
||||
|
||||
func (s *Server) requirePostgres(w http.ResponseWriter) bool {
|
||||
if s.pgMonitor == nil {
|
||||
writeProblem(w, http.StatusServiceUnavailable, "Unavailable", "postgresql backend required")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func parseLimitQuery(r *http.Request, def, max int) int {
|
||||
if v := r.URL.Query().Get("limit"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func (s *Server) handlePostgresOverview(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") || !s.requirePostgres(w) {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
|
||||
defer cancel()
|
||||
out, err := s.pgMonitor.Overview(ctx)
|
||||
if err != nil {
|
||||
writeInternalError(w, "postgres_overview", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (s *Server) handlePostgresQueries(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") || !s.requirePostgres(w) {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
|
||||
defer cancel()
|
||||
out, err := s.pgMonitor.TopQueries(ctx, parseLimitQuery(r, 20, 100))
|
||||
if err != nil {
|
||||
writeInternalError(w, "postgres_queries", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (s *Server) handlePostgresLocks(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") || !s.requirePostgres(w) {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
|
||||
defer cancel()
|
||||
out, err := s.pgMonitor.Locks(ctx)
|
||||
if err != nil {
|
||||
writeInternalError(w, "postgres_locks", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": out})
|
||||
}
|
||||
|
||||
func (s *Server) handlePostgresTables(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") || !s.requirePostgres(w) {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
|
||||
defer cancel()
|
||||
out, err := s.pgMonitor.Tables(ctx, parseLimitQuery(r, 20, 100))
|
||||
if err != nil {
|
||||
writeInternalError(w, "postgres_tables", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": out})
|
||||
}
|
||||
|
||||
func (s *Server) handlePostgresRecommendations(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") || !s.requirePostgres(w) {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
out, err := s.pgMonitor.Recommendations(ctx)
|
||||
if err != nil {
|
||||
writeInternalError(w, "postgres_recommendations", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (s *Server) handleMonitoringCorrelation(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") || !s.requirePostgres(w) {
|
||||
return
|
||||
}
|
||||
window := 60
|
||||
if v := r.URL.Query().Get("window"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
window = n
|
||||
}
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
out, err := s.pgMonitor.Correlation(ctx, window)
|
||||
if err != nil {
|
||||
writeInternalError(w, "monitoring_correlation", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPostgresOverviewMemoryBackend503(t *testing.T) {
|
||||
srv, err := New(Options{SeedDemo: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/monitoring/postgres/overview", nil)
|
||||
req.Header.Set("Authorization", "Bearer dev")
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Handler().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,11 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"evobgp/internal/jobs"
|
||||
"evobgp/internal/maintenance"
|
||||
"evobgp/internal/pgmonitor"
|
||||
"evobgp/internal/store"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
@@ -19,10 +22,12 @@ import (
|
||||
type Server struct {
|
||||
store store.Backend
|
||||
pgPool *pgxpool.Pool
|
||||
pgMonitor *pgmonitor.Service
|
||||
maintConfig *maintenance.ConfigProvider
|
||||
maintStats *maintenance.DBStatsProvider
|
||||
jobs *jobs.Registry
|
||||
bundlePriv ed25519.PrivateKey
|
||||
apiKeys []apiKeyRecord
|
||||
insecureDev bool
|
||||
keyResolver *apiKeyResolver
|
||||
corsOrigins []string
|
||||
cdnHTTP *http.Client
|
||||
mux *http.ServeMux
|
||||
@@ -32,10 +37,10 @@ type Server struct {
|
||||
type Options struct {
|
||||
APIKeys string
|
||||
// DatabaseURL enables PostgreSQL-backed store (migrations applied on connect).
|
||||
DatabaseURL string
|
||||
InsecureDev bool
|
||||
SeedDemo bool
|
||||
BundleSeedHex string
|
||||
DatabaseURL string
|
||||
InsecureDev bool
|
||||
SeedDemo bool
|
||||
BundleSeedHex string
|
||||
CORSAllowedOrigins string
|
||||
}
|
||||
|
||||
@@ -60,13 +65,28 @@ func New(opts Options) (*Server, error) {
|
||||
_, priv, _ = ed25519.GenerateKey(rand.Reader)
|
||||
}
|
||||
|
||||
resolver, err := newAPIKeyResolver(opts.APIKeys, backend)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var pgMon *pgmonitor.Service
|
||||
var maintCfg *maintenance.ConfigProvider
|
||||
var maintStats *maintenance.DBStatsProvider
|
||||
if pool != nil {
|
||||
pgMon = pgmonitor.NewService(pool)
|
||||
maintCfg = maintenance.NewConfigProvider(backend)
|
||||
_ = maintCfg.Reload(context.Background())
|
||||
maintStats = maintenance.NewDBStatsProvider(pgMon)
|
||||
}
|
||||
s := &Server{
|
||||
store: backend,
|
||||
pgPool: pool,
|
||||
pgMonitor: pgMon,
|
||||
maintConfig: maintCfg,
|
||||
maintStats: maintStats,
|
||||
jobs: reg,
|
||||
bundlePriv: priv,
|
||||
apiKeys: parseAPIKeysSpec(opts.APIKeys),
|
||||
insecureDev: opts.InsecureDev && opts.SeedDemo,
|
||||
keyResolver: resolver,
|
||||
corsOrigins: parseCORSOrigins(opts.CORSAllowedOrigins),
|
||||
cdnHTTP: NewCDNHTTPClient(),
|
||||
}
|
||||
@@ -87,3 +107,20 @@ func (s *Server) Store() store.Backend { return s.store }
|
||||
|
||||
// Jobs exposes the in-process async job registry (for scheduler / evobgp-all).
|
||||
func (s *Server) Jobs() *jobs.Registry { return s.jobs }
|
||||
|
||||
// StartBackground starts PostgreSQL monitoring and maintenance schedulers until ctx is cancelled.
|
||||
func (s *Server) StartBackground(ctx context.Context) {
|
||||
if s != nil && s.pgPool != nil {
|
||||
pgmonitor.StartScheduler(ctx, s.pgPool)
|
||||
}
|
||||
if s != nil && s.maintConfig != nil && s.jobs != nil {
|
||||
maintenance.StartScheduler(ctx, s.maintConfig, func(policyID string, dryRun bool, idem string) {
|
||||
key := idem
|
||||
_, _, _ = s.jobs.Enqueue("", jobs.KindMaintenancePolicyRun, &key, nil, map[string]any{
|
||||
"policy_id": policyID,
|
||||
"dry_run": dryRun,
|
||||
"trigger": "scheduler",
|
||||
})
|
||||
}, 30*time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
|
||||
}
|
||||
defer srv.Close()
|
||||
tenant, modCDN, modIP, rev, speaker := srv.Store().DemoIDs()
|
||||
srv.apiKeys = parseAPIKeysSpec("nodekey|" + tenant + "|node,opkey|" + tenant + "|operator,edkey|" + tenant + "|editor")
|
||||
mustSetTestAPIKeys(t, srv, "nodekey|"+tenant+"|node,opkey|"+tenant+"|operator,edkey|"+tenant+"|editor")
|
||||
|
||||
ts := httptest.NewServer(srv.Handler())
|
||||
defer ts.Close()
|
||||
@@ -51,7 +51,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("status %d: %s", resp.StatusCode, b)
|
||||
@@ -76,7 +76,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusAccepted {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("status %d: %s", resp.StatusCode, b)
|
||||
@@ -97,7 +97,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusAccepted {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("status %d: %s", resp.StatusCode, b)
|
||||
@@ -118,7 +118,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("status %d: %s", resp.StatusCode, b)
|
||||
@@ -132,7 +132,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("status %d: %s", resp.StatusCode, b)
|
||||
@@ -162,7 +162,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
_ = resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("%s status %d: %s", path, resp.StatusCode, b)
|
||||
}
|
||||
@@ -185,7 +185,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("status %d: %s", resp.StatusCode, b)
|
||||
@@ -215,7 +215,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("status %d: %s", resp.StatusCode, b)
|
||||
@@ -255,7 +255,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("status %d want 403: %s", resp.StatusCode, b)
|
||||
@@ -269,7 +269,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusAccepted {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("status %d: %s", resp.StatusCode, b)
|
||||
@@ -289,7 +289,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusAccepted {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("status %d: %s", resp.StatusCode, b)
|
||||
@@ -314,7 +314,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("status %d: %s", resp.StatusCode, b)
|
||||
@@ -341,7 +341,7 @@ func waitJob(t *testing.T, client *http.Client, base, token, jobID string) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
_ = resp.Body.Close()
|
||||
var body struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
@@ -405,7 +405,7 @@ func TestVersionEndpoints(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("status %d: %s", resp.StatusCode, b)
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"evobgp/internal/nodedispatch"
|
||||
"evobgp/internal/store"
|
||||
)
|
||||
|
||||
func speakerJSONFromStore(st store.Backend, sp *store.Speaker) map[string]any {
|
||||
if sp == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
meta := store.ParseSpeakerMeta(sp.MetaJSON)
|
||||
m := map[string]any{
|
||||
"id": sp.ID,
|
||||
"role": sp.Role,
|
||||
"endpoint": sp.Endpoint,
|
||||
}
|
||||
if sp.LastAppliedRevisionID != nil {
|
||||
m["last_applied_revision_id"] = *sp.LastAppliedRevisionID
|
||||
} else {
|
||||
m["last_applied_revision_id"] = nil
|
||||
}
|
||||
if st != nil {
|
||||
if rid, at, err := st.LatestPublishedRevision(sp.ID); err == nil && rid != "" {
|
||||
m["published_revision_id"] = rid
|
||||
m["published_at"] = at.UTC().Format(time.RFC3339Nano)
|
||||
} else {
|
||||
m["published_revision_id"] = nil
|
||||
m["published_at"] = nil
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(sp.MetaJSON) != "" && sp.MetaJSON != "{}" {
|
||||
var raw map[string]any
|
||||
if json.Unmarshal([]byte(sp.MetaJSON), &raw) == nil {
|
||||
delete(raw, "agent_secret")
|
||||
if len(raw) > 0 {
|
||||
m["meta_json"] = raw
|
||||
}
|
||||
}
|
||||
}
|
||||
if meta.AgentDomain != "" {
|
||||
m["agent_domain"] = meta.AgentDomain
|
||||
}
|
||||
if meta.NodeIPv4 != "" {
|
||||
m["node_ipv4"] = meta.NodeIPv4
|
||||
}
|
||||
if meta.BirdBgpSourceIPv4 != "" {
|
||||
m["bird_bgp_source_ipv4"] = meta.BirdBgpSourceIPv4
|
||||
}
|
||||
if meta.LastDispatchAt != "" {
|
||||
m["last_dispatch_at"] = meta.LastDispatchAt
|
||||
}
|
||||
if meta.LastDispatchError != "" {
|
||||
m["last_dispatch_error"] = meta.LastDispatchError
|
||||
}
|
||||
if meta.LastDispatchStatus != "" {
|
||||
m["dispatch_status"] = meta.LastDispatchStatus
|
||||
}
|
||||
if meta.SyncStatus != "" {
|
||||
m["sync_status"] = meta.SyncStatus
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (s *Server) handleBundleSigningPublicKey(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") {
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"public_key_base64": s.BundlePublicKeyBase64(),
|
||||
})
|
||||
}
|
||||
|
||||
// normalizeSpeakerCreate fills meta defaults and validates replica fields.
|
||||
func normalizeSpeakerCreate(in *store.Speaker) error {
|
||||
if in == nil {
|
||||
return store.ErrInvalidInput
|
||||
}
|
||||
meta := store.ParseSpeakerMeta(in.MetaJSON)
|
||||
if meta.AgentSecret == "" {
|
||||
b := make([]byte, 24)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return err
|
||||
}
|
||||
meta.AgentSecret = hex.EncodeToString(b)
|
||||
}
|
||||
if meta.AgentPort == 0 {
|
||||
meta.AgentPort = 8443
|
||||
}
|
||||
if meta.NodeIPv4 == "" {
|
||||
meta.NodeIPv4 = store.IPv4FromEndpoint(in.Endpoint)
|
||||
}
|
||||
if meta.BirdBgpSourceIPv4 == "" && meta.NodeIPv4 != "" {
|
||||
meta.BirdBgpSourceIPv4 = meta.NodeIPv4
|
||||
}
|
||||
if meta.BirdBgpSourceIPv4 != "" && !store.ValidIPv4(meta.BirdBgpSourceIPv4) {
|
||||
return store.ErrInvalidInput
|
||||
}
|
||||
if meta.AgentDomain == "" && in.Endpoint != "" {
|
||||
ep := strings.TrimSpace(in.Endpoint)
|
||||
if strings.HasPrefix(ep, "https://") {
|
||||
u := strings.TrimPrefix(ep, "https://")
|
||||
if idx := strings.Index(u, "/"); idx >= 0 {
|
||||
u = u[:idx]
|
||||
}
|
||||
if idx := strings.Index(u, ":"); idx >= 0 {
|
||||
u = u[:idx]
|
||||
}
|
||||
if u != "" && !store.ValidIPv4(u) {
|
||||
meta.AgentDomain = u
|
||||
}
|
||||
}
|
||||
}
|
||||
in.MetaJSON = store.SpeakerMetaJSON(meta)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) recordSpeakerDispatch(tenantID string, sp *store.Speaker, res nodedispatch.Result) {
|
||||
if s == nil || s.store == nil || sp == nil {
|
||||
return
|
||||
}
|
||||
patch := store.SpeakerMeta{
|
||||
LastDispatchAt: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
LastDispatchStatus: res.Status,
|
||||
}
|
||||
if res.Error != "" {
|
||||
patch.LastDispatchError = res.Error
|
||||
patch.SyncStatus = "error"
|
||||
} else if res.Status == "ok" {
|
||||
patch.LastDispatchError = ""
|
||||
patch.SyncStatus = "synced"
|
||||
}
|
||||
meta := store.MergeSpeakerMetaJSON(sp.MetaJSON, patch)
|
||||
_, _ = s.store.UpdateSpeaker(tenantID, sp.ID, &store.SpeakerPatch{MetaJSON: &meta})
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"evobgp/internal/birdfmt"
|
||||
"evobgp/internal/nodedispatch"
|
||||
"evobgp/internal/store"
|
||||
)
|
||||
|
||||
func countBGPSessions(sessions []birdfmt.BGPSession) (total, established int) {
|
||||
total = len(sessions)
|
||||
for _, s := range sessions {
|
||||
if strings.EqualFold(strings.TrimSpace(s.State), "Established") {
|
||||
established++
|
||||
}
|
||||
}
|
||||
return total, established
|
||||
}
|
||||
|
||||
func speakerLiveStatusJSON(sp *store.Speaker, view speakerBGPLive, health *nodedispatch.AgentHealthResult) map[string]any {
|
||||
total, established := countBGPSessions(view.Sessions)
|
||||
m := map[string]any{
|
||||
"label": view.Label,
|
||||
"bgp_poll_ok": view.Error == "",
|
||||
"bgp_sessions_total": total,
|
||||
"bgp_established": established,
|
||||
}
|
||||
if view.Error != "" {
|
||||
m["bgp_poll_error"] = view.Error
|
||||
}
|
||||
if health != nil {
|
||||
m["agent_ok"] = health.OK
|
||||
if health.Error != "" {
|
||||
m["agent_error"] = health.Error
|
||||
}
|
||||
if health.LastSyncAt != "" {
|
||||
m["agent_last_sync_at"] = health.LastSyncAt
|
||||
}
|
||||
if health.LastAppliedRevisionID != "" {
|
||||
m["agent_last_applied_revision_id"] = health.LastAppliedRevisionID
|
||||
}
|
||||
} else if sp != nil && strings.EqualFold(strings.TrimSpace(sp.Role), "master") {
|
||||
m["agent_ok"] = view.Error == ""
|
||||
if view.Error != "" {
|
||||
m["agent_error"] = view.Error
|
||||
}
|
||||
} else if sp != nil && store.SpeakerNeedsRemoteDispatch(sp.Role, store.ParseSpeakerMeta(sp.MetaJSON)) {
|
||||
m["agent_ok"] = false
|
||||
m["agent_error"] = "agent health not polled"
|
||||
}
|
||||
if len(view.Sessions) > 0 {
|
||||
sess := make([]map[string]any, 0, len(view.Sessions))
|
||||
for _, s := range view.Sessions {
|
||||
row := map[string]any{
|
||||
"name": s.Name,
|
||||
"state": s.State,
|
||||
}
|
||||
if strings.TrimSpace(s.Neighbor) != "" {
|
||||
row["neighbor"] = s.Neighbor
|
||||
}
|
||||
sess = append(sess, row)
|
||||
}
|
||||
m["sessions"] = sess
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (s *Server) collectSpeakerLiveStatus(ctx context.Context, tenantID string, fresh bool, speakers []*store.Speaker) map[string]map[string]any {
|
||||
views := s.collectSpeakerBGPLive(ctx, tenantID, fresh)
|
||||
viewByID := make(map[string]speakerBGPLive, len(views))
|
||||
for _, v := range views {
|
||||
if v.SpeakerID != "" {
|
||||
viewByID[v.SpeakerID] = v
|
||||
}
|
||||
}
|
||||
|
||||
opts := nodedispatch.Options{Timeout: 8 * time.Second}
|
||||
type healthWrap struct {
|
||||
id string
|
||||
h nodedispatch.AgentHealthResult
|
||||
}
|
||||
healthCh := make(chan healthWrap, len(speakers))
|
||||
var wg sync.WaitGroup
|
||||
for _, sp := range speakers {
|
||||
if sp == nil {
|
||||
continue
|
||||
}
|
||||
meta := store.ParseSpeakerMeta(sp.MetaJSON)
|
||||
if !store.SpeakerNeedsRemoteDispatch(sp.Role, meta) {
|
||||
continue
|
||||
}
|
||||
wg.Add(1)
|
||||
go func(speaker *store.Speaker) {
|
||||
defer wg.Done()
|
||||
healthCh <- healthWrap{
|
||||
id: speaker.ID,
|
||||
h: nodedispatch.FetchAgentHealth(ctx, speaker, opts),
|
||||
}
|
||||
}(sp)
|
||||
}
|
||||
wg.Wait()
|
||||
close(healthCh)
|
||||
healthByID := make(map[string]nodedispatch.AgentHealthResult, len(speakers))
|
||||
for hw := range healthCh {
|
||||
healthByID[hw.id] = hw.h
|
||||
}
|
||||
|
||||
out := make(map[string]map[string]any, len(speakers))
|
||||
for _, sp := range speakers {
|
||||
if sp == nil {
|
||||
continue
|
||||
}
|
||||
view, ok := viewByID[sp.ID]
|
||||
if !ok {
|
||||
view = speakerBGPLive{SpeakerID: sp.ID, Label: speakerDisplayLabel(sp)}
|
||||
}
|
||||
var hp *nodedispatch.AgentHealthResult
|
||||
if h, ok := healthByID[sp.ID]; ok {
|
||||
hCopy := h
|
||||
hp = &hCopy
|
||||
}
|
||||
out[sp.ID] = speakerLiveStatusJSON(sp, view, hp)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"evobgp/internal/birdfmt"
|
||||
"evobgp/internal/nodedispatch"
|
||||
"evobgp/internal/store"
|
||||
)
|
||||
|
||||
func TestCountBGPSessions(t *testing.T) {
|
||||
total, est := countBGPSessions([]birdfmt.BGPSession{
|
||||
{Name: "p1", State: "Established"},
|
||||
{Name: "p2", State: "Idle"},
|
||||
{Name: "p3", State: "established"},
|
||||
})
|
||||
if total != 3 || est != 2 {
|
||||
t.Fatalf("total=%d established=%d", total, est)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpeakerLiveStatusJSON_masterUsesBirdPoll(t *testing.T) {
|
||||
sp := &store.Speaker{ID: "m1", Role: "master", Endpoint: "https://cp.example"}
|
||||
view := speakerBGPLive{
|
||||
SpeakerID: "m1",
|
||||
Label: "CP · cp.example",
|
||||
Sessions: []birdfmt.BGPSession{
|
||||
{Name: "evobgp_peer_x", State: "Established"},
|
||||
},
|
||||
}
|
||||
m := speakerLiveStatusJSON(sp, view, nil)
|
||||
if m["agent_ok"] != true || m["bgp_established"] != 1 || m["bgp_sessions_total"] != 1 {
|
||||
t.Fatalf("got %#v", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpeakerLiveStatusJSON_replicaWithHealth(t *testing.T) {
|
||||
sp := &store.Speaker{
|
||||
ID: "r1",
|
||||
Role: "replica",
|
||||
Endpoint: "https://node.example",
|
||||
MetaJSON: `{"agent_domain":"node.example","agent_secret":"s"}`,
|
||||
}
|
||||
view := speakerBGPLive{
|
||||
SpeakerID: "r1",
|
||||
Label: "node.example",
|
||||
Sessions: []birdfmt.BGPSession{{Name: "p", State: "Idle"}},
|
||||
}
|
||||
health := &nodedispatch.AgentHealthResult{
|
||||
OK: true,
|
||||
LastSyncAt: "2026-05-21T12:00:00Z",
|
||||
LastAppliedRevisionID: "rev-1",
|
||||
}
|
||||
m := speakerLiveStatusJSON(sp, view, health)
|
||||
if m["agent_ok"] != true || m["agent_last_sync_at"] != "2026-05-21T12:00:00Z" {
|
||||
t.Fatalf("got %#v", m)
|
||||
}
|
||||
if m["bgp_established"] != 0 || m["bgp_poll_ok"] != true {
|
||||
t.Fatalf("bgp fields: %#v", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpeakerLiveStatusJSON_pollError(t *testing.T) {
|
||||
sp := &store.Speaker{ID: "r1", Role: "replica", MetaJSON: `{"agent_domain":"x.example"}`}
|
||||
view := speakerBGPLive{SpeakerID: "r1", Label: "x.example", Error: "HTTP 503"}
|
||||
health := &nodedispatch.AgentHealthResult{OK: false, Error: "timeout"}
|
||||
m := speakerLiveStatusJSON(sp, view, health)
|
||||
if m["bgp_poll_ok"] != false || m["bgp_poll_error"] != "HTTP 503" {
|
||||
t.Fatalf("got %#v", m)
|
||||
}
|
||||
if m["agent_ok"] != false {
|
||||
t.Fatalf("agent_ok: %#v", m)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGetSpeaker_redactsAgentSecret(t *testing.T) {
|
||||
srv, err := New(Options{InsecureDev: true, SeedDemo: true, BundleSeedHex: testBundleSeed})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer srv.Close()
|
||||
tenant, _, _, _, demoSpk := srv.Store().DemoIDs()
|
||||
mustSetTestAPIKeys(t, srv, "vwkey|"+tenant+"|viewer")
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/speakers/"+demoSpk, nil)
|
||||
req.Header.Set("Authorization", "Bearer vwkey")
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Handler().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out["agent_secret"] != nil {
|
||||
t.Fatalf("agent_secret must not appear at top level: %#v", out["agent_secret"])
|
||||
}
|
||||
meta, _ := out["meta_json"].(map[string]any)
|
||||
if meta != nil {
|
||||
if v, ok := meta["agent_secret"]; ok && v != nil && v != "" {
|
||||
t.Fatalf("agent_secret must be redacted from meta_json: %#v", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListSpeakers_redactsAgentSecret(t *testing.T) {
|
||||
srv, err := New(Options{InsecureDev: true, SeedDemo: true, BundleSeedHex: testBundleSeed})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer srv.Close()
|
||||
tenant, _, _, _, _ := srv.Store().DemoIDs()
|
||||
mustSetTestAPIKeys(t, srv, "vwkey|"+tenant+"|viewer")
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/speakers", nil)
|
||||
req.Header.Set("Authorization", "Bearer vwkey")
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Handler().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
body := rec.Body.String()
|
||||
if strings.Contains(body, "agent_secret") {
|
||||
t.Fatalf("list response must not contain agent_secret: %s", body)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPostSpeaker_defaultsFromEndpointIP(t *testing.T) {
|
||||
srv, err := New(Options{InsecureDev: true, SeedDemo: true, BundleSeedHex: testBundleSeed})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer srv.Close()
|
||||
tenant, _, _, _, _ := srv.Store().DemoIDs()
|
||||
mustSetTestAPIKeys(t, srv, "edkey|"+tenant+"|editor")
|
||||
|
||||
body := `{"endpoint":"https://203.0.113.55:8443","role":"replica"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/speakers", strings.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer edkey")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Handler().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out["agent_secret"] == nil || out["agent_secret"] == "" {
|
||||
t.Fatal("expected agent_secret on create")
|
||||
}
|
||||
if out["node_ipv4"] != "203.0.113.55" {
|
||||
t.Fatalf("node_ipv4: %#v", out["node_ipv4"])
|
||||
}
|
||||
if out["bird_bgp_source_ipv4"] != "203.0.113.55" {
|
||||
t.Fatalf("bird_bgp_source_ipv4: %#v", out["bird_bgp_source_ipv4"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteSpeaker(t *testing.T) {
|
||||
srv, err := New(Options{InsecureDev: true, SeedDemo: true, BundleSeedHex: testBundleSeed})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer srv.Close()
|
||||
tenant, _, _, _, demoSpk := srv.Store().DemoIDs()
|
||||
mustSetTestAPIKeys(t, srv, "edkey|"+tenant+"|editor")
|
||||
|
||||
req := httptest.NewRequest(http.MethodDelete, "/v1/speakers/"+demoSpk, nil)
|
||||
req.Header.Set("Authorization", "Bearer edkey")
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Handler().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if _, err := srv.Store().GetSpeaker(tenant, demoSpk); err == nil {
|
||||
t.Fatal("speaker should be deleted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetBundleSigningPublicKey(t *testing.T) {
|
||||
srv, err := New(Options{InsecureDev: true, SeedDemo: true, BundleSeedHex: testBundleSeed})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer srv.Close()
|
||||
tenant, _, _, _, _ := srv.Store().DemoIDs()
|
||||
mustSetTestAPIKeys(t, srv, "vwkey|"+tenant+"|viewer")
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/bundle/signing-public-key", nil)
|
||||
req.Header.Set("Authorization", "Bearer vwkey")
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Handler().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var out map[string]any
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &out)
|
||||
if out["public_key_base64"] == nil || out["public_key_base64"] == "" {
|
||||
t.Fatalf("missing public_key_base64: %#v", out)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package httpapi
|
||||
|
||||
import "testing"
|
||||
|
||||
func mustSetTestAPIKeys(t *testing.T, srv *Server, spec string) {
|
||||
t.Helper()
|
||||
resolver, err := newAPIKeyResolver(spec, srv.store)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv.keyResolver = resolver
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package httpclient
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultBreakerThreshold = 5
|
||||
defaultBreakerCooldown = 30 * time.Second
|
||||
)
|
||||
|
||||
type hostBreaker struct {
|
||||
mu sync.Mutex
|
||||
failures int
|
||||
openUntil time.Time
|
||||
}
|
||||
|
||||
var hostBreakers sync.Map // string -> *hostBreaker
|
||||
|
||||
func breakerForHost(host string) *hostBreaker {
|
||||
if host == "" {
|
||||
host = "_"
|
||||
}
|
||||
v, _ := hostBreakers.LoadOrStore(host, &hostBreaker{})
|
||||
return v.(*hostBreaker)
|
||||
}
|
||||
|
||||
func (b *hostBreaker) allow() bool {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return time.Now().After(b.openUntil)
|
||||
}
|
||||
|
||||
func (b *hostBreaker) recordSuccess() {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
b.failures = 0
|
||||
b.openUntil = time.Time{}
|
||||
}
|
||||
|
||||
func (b *hostBreaker) recordFailure() {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
b.failures++
|
||||
if b.failures >= defaultBreakerThreshold {
|
||||
b.openUntil = time.Now().Add(defaultBreakerCooldown)
|
||||
b.failures = 0
|
||||
}
|
||||
}
|
||||
|
||||
// ResetHostBreakers clears all circuit breakers (tests only).
|
||||
func ResetHostBreakers() {
|
||||
hostBreakers = sync.Map{}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package httpclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDoWithBreaker_opensAfterFailures(t *testing.T) {
|
||||
ResetHostBreakers()
|
||||
var calls atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls.Add(1)
|
||||
http.Error(w, "fail", http.StatusBadGateway)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
hc := New(5 * time.Second)
|
||||
for i := 0; i < defaultBreakerThreshold*3; i++ {
|
||||
req, _ := http.NewRequest(http.MethodGet, srv.URL, nil)
|
||||
_, _ = DoWithBreaker(context.Background(), hc, req, 1)
|
||||
}
|
||||
req, _ := http.NewRequest(http.MethodGet, srv.URL, nil)
|
||||
_, err := DoWithBreaker(context.Background(), hc, req, 1)
|
||||
if err == nil || err.Error() == "" {
|
||||
t.Fatal("expected circuit open error")
|
||||
}
|
||||
if got := calls.Load(); got == 0 {
|
||||
t.Fatal("expected at least one upstream call")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
// Package httpclient provides shared HTTP clients and retry helpers for outbound calls.
|
||||
package httpclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
const DefaultTimeout = 45 * time.Second
|
||||
|
||||
// New returns an HTTP client with timeout and tuned idle connection pooling.
|
||||
func New(timeout time.Duration) *http.Client {
|
||||
if timeout <= 0 {
|
||||
timeout = DefaultTimeout
|
||||
}
|
||||
tr := http.DefaultTransport.(*http.Transport).Clone()
|
||||
tr.MaxIdleConns = 100
|
||||
tr.MaxIdleConnsPerHost = 10
|
||||
return &http.Client{Timeout: timeout, Transport: tr}
|
||||
}
|
||||
|
||||
// DoWithRetry executes hc.Do(req) up to maxAttempts times with linear backoff.
|
||||
func DoWithRetry(ctx context.Context, hc *http.Client, req *http.Request, maxAttempts int) (*http.Response, error) {
|
||||
if maxAttempts <= 0 {
|
||||
maxAttempts = 3
|
||||
}
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < maxAttempts; attempt++ {
|
||||
if attempt > 0 {
|
||||
wait := time.Duration(attempt) * 2 * time.Second
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(wait):
|
||||
}
|
||||
if req.GetBody != nil {
|
||||
body, err := req.GetBody()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Body = body
|
||||
}
|
||||
}
|
||||
reqClone := req.Clone(ctx)
|
||||
resp, err := hc.Do(reqClone)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
if resp.StatusCode >= 500 {
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
lastErr = fmt.Errorf("httpclient: upstream %s", resp.Status)
|
||||
continue
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
if lastErr != nil {
|
||||
return nil, lastErr
|
||||
}
|
||||
return nil, fmt.Errorf("httpclient: request failed after %d attempts", maxAttempts)
|
||||
}
|
||||
|
||||
// DoWithBreaker applies per-host circuit breaking then retries transient failures.
|
||||
func DoWithBreaker(ctx context.Context, hc *http.Client, req *http.Request, maxAttempts int) (*http.Response, error) {
|
||||
if req == nil || req.URL == nil {
|
||||
return nil, fmt.Errorf("httpclient: nil request")
|
||||
}
|
||||
br := breakerForHost(req.URL.Hostname())
|
||||
if !br.allow() {
|
||||
return nil, fmt.Errorf("httpclient: circuit open for %s", req.URL.Hostname())
|
||||
}
|
||||
resp, err := DoWithRetry(ctx, hc, req, maxAttempts)
|
||||
if err != nil {
|
||||
br.recordFailure()
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode >= 500 {
|
||||
br.recordFailure()
|
||||
return resp, nil
|
||||
}
|
||||
br.recordSuccess()
|
||||
return resp, nil
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package httpclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDoWithRetry_retriesOn500(t *testing.T) {
|
||||
var calls int
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
if calls < 3 {
|
||||
http.Error(w, "fail", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, srv.URL, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := DoWithRetry(context.Background(), New(5*time.Second), req, 3)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status %d", resp.StatusCode)
|
||||
}
|
||||
if calls != 3 {
|
||||
t.Fatalf("want 3 calls, got %d", calls)
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,11 @@ package ingest
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"evobgp/internal/broker"
|
||||
"evobgp/internal/config"
|
||||
"evobgp/internal/httpclient"
|
||||
"evobgp/internal/pipeline"
|
||||
"evobgp/internal/store"
|
||||
)
|
||||
@@ -24,7 +24,7 @@ func Run(ctx context.Context, deps *Deps) {
|
||||
if deps == nil || deps.Store == nil {
|
||||
log.Fatalf("evobgp-ingest: missing store (pass ingest.Deps from BootstrapWorkers or evobgp-all)")
|
||||
}
|
||||
hc := &http.Client{Timeout: 45 * time.Second}
|
||||
hc := httpclient.New(httpclient.DefaultTimeout)
|
||||
t := time.NewTicker(60 * time.Second)
|
||||
defer t.Stop()
|
||||
log.Printf("evobgp-ingest: active (CDN conditional GET / ETag prefetch)")
|
||||
@@ -34,7 +34,10 @@ func Run(ctx context.Context, deps *Deps) {
|
||||
log.Printf("evobgp-ingest: stopped")
|
||||
return
|
||||
case <-t.C:
|
||||
if err := pipeline.PrefetchCDNSourceETags(context.Background(), deps.Store, hc); err != nil {
|
||||
prefetchCtx, cancel := context.WithTimeout(ctx, 50*time.Second)
|
||||
err := pipeline.PrefetchCDNSourceETags(prefetchCtx, deps.Store, hc)
|
||||
cancel()
|
||||
if err != nil {
|
||||
log.Printf("evobgp-ingest: prefetch: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package jobs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultJobTimeoutModuleRefresh = 10 * time.Minute
|
||||
defaultJobTimeoutTenantRefresh = 15 * time.Minute
|
||||
defaultJobTimeoutDeployApply = 5 * time.Minute
|
||||
defaultJobTimeoutPeerReconcile = 10 * time.Minute
|
||||
defaultJobTimeoutRollback = 5 * time.Minute
|
||||
defaultJobTimeoutBirdReload = 2 * time.Minute
|
||||
)
|
||||
|
||||
func jobTimeout(kind string) time.Duration {
|
||||
envKey := map[string]string{
|
||||
KindModuleRefresh: "EVOBGP_JOB_TIMEOUT_MODULE_REFRESH",
|
||||
KindTenantRefresh: "EVOBGP_JOB_TIMEOUT_TENANT_REFRESH",
|
||||
KindDeployApply: "EVOBGP_JOB_TIMEOUT_DEPLOY_APPLY",
|
||||
KindPeerReconcile: "EVOBGP_JOB_TIMEOUT_PEER_RECONCILE",
|
||||
KindRevisionRollback: "EVOBGP_JOB_TIMEOUT_ROLLBACK",
|
||||
KindBirdReload: "EVOBGP_JOB_TIMEOUT_BIRD_RELOAD",
|
||||
}[kind]
|
||||
if envKey != "" {
|
||||
if d, err := time.ParseDuration(os.Getenv(envKey)); err == nil && d > 0 {
|
||||
return d
|
||||
}
|
||||
}
|
||||
switch kind {
|
||||
case KindModuleRefresh:
|
||||
return defaultJobTimeoutModuleRefresh
|
||||
case KindTenantRefresh:
|
||||
return defaultJobTimeoutTenantRefresh
|
||||
case KindDeployApply:
|
||||
return defaultJobTimeoutDeployApply
|
||||
case KindPeerReconcile:
|
||||
return defaultJobTimeoutPeerReconcile
|
||||
case KindRevisionRollback:
|
||||
return defaultJobTimeoutRollback
|
||||
case KindBirdReload:
|
||||
return defaultJobTimeoutBirdReload
|
||||
default:
|
||||
if n, err := strconv.Atoi(os.Getenv("EVOBGP_JOB_TIMEOUT_SEC")); err == nil && n > 0 {
|
||||
return time.Duration(n) * time.Second
|
||||
}
|
||||
return defaultJobTimeoutModuleRefresh
|
||||
}
|
||||
}
|
||||
|
||||
// workContext returns a timeout context that also cancels when the job is cancelled.
|
||||
func (j *Job) workContext() (context.Context, context.CancelFunc) {
|
||||
if j == nil {
|
||||
return context.Background(), func() {}
|
||||
}
|
||||
timeout := jobTimeout(j.Kind)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
go func() {
|
||||
ticker := time.NewTicker(500 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if j.IsCancelRequested() {
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
return ctx, cancel
|
||||
}
|
||||
+99
-5
@@ -9,6 +9,8 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"evobgp/internal/observability"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
@@ -178,6 +180,10 @@ type Registry struct {
|
||||
byID map[string]*Job
|
||||
byIdempo map[idempoKey]*Job
|
||||
workerStart func(j *Job)
|
||||
workerSem chan struct{}
|
||||
onTerminal func(j *Job)
|
||||
onEnqueued func(j *Job)
|
||||
onRunning func(j *Job)
|
||||
}
|
||||
|
||||
type idempoKey struct {
|
||||
@@ -186,13 +192,82 @@ type idempoKey struct {
|
||||
}
|
||||
|
||||
func NewRegistry(workerStart func(j *Job)) *Registry {
|
||||
maxWorkers := registryMaxConcurrentJobs()
|
||||
return &Registry{
|
||||
byID: make(map[string]*Job),
|
||||
byIdempo: make(map[idempoKey]*Job),
|
||||
workerStart: workerStart,
|
||||
workerSem: make(chan struct{}, maxWorkers),
|
||||
}
|
||||
}
|
||||
|
||||
// SetTerminalHook registers a best-effort callback when jobs reach a terminal state.
|
||||
func (r *Registry) SetTerminalHook(fn func(j *Job)) {
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.onTerminal = fn
|
||||
}
|
||||
|
||||
// SetPersistHooks registers best-effort callbacks for job lifecycle persistence.
|
||||
func (r *Registry) SetPersistHooks(onEnqueued, onRunning, onTerminal func(j *Job)) {
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.onEnqueued = onEnqueued
|
||||
r.onRunning = onRunning
|
||||
if onTerminal != nil {
|
||||
r.onTerminal = onTerminal
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Registry) fireEnqueued(j *Job) {
|
||||
if r == nil || j == nil {
|
||||
return
|
||||
}
|
||||
r.mu.RLock()
|
||||
fn := r.onEnqueued
|
||||
r.mu.RUnlock()
|
||||
if fn != nil {
|
||||
fn(j)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Registry) fireRunning(j *Job) {
|
||||
if r == nil || j == nil {
|
||||
return
|
||||
}
|
||||
r.mu.RLock()
|
||||
fn := r.onRunning
|
||||
r.mu.RUnlock()
|
||||
if fn != nil {
|
||||
fn(j)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Registry) fireTerminal(j *Job) {
|
||||
if r == nil || j == nil {
|
||||
return
|
||||
}
|
||||
r.mu.RLock()
|
||||
fn := r.onTerminal
|
||||
r.mu.RUnlock()
|
||||
if fn != nil {
|
||||
fn(j)
|
||||
}
|
||||
}
|
||||
|
||||
func registryMaxConcurrentJobs() int {
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(os.Getenv("EVOBGP_JOB_MAX_CONCURRENT"))); err == nil && n > 0 {
|
||||
return n
|
||||
}
|
||||
return 8
|
||||
}
|
||||
|
||||
// pruneTerminalIfOver удаляет самые старые завершённые джобы (succeeded/failed/cancelled), пока len(byID) > maxJobs.
|
||||
func (r *Registry) pruneTerminalIfOver(maxJobs int) {
|
||||
if r == nil || maxJobs <= 0 || len(r.byID) <= maxJobs {
|
||||
@@ -236,15 +311,18 @@ func (r *Registry) pruneTerminalIfOver(maxJobs int) {
|
||||
// Enqueue creates a job or returns an existing one for the same idempotency key.
|
||||
func (r *Registry) Enqueue(tenantID, kind string, idempotencyKey *string, moduleID *string, meta map[string]any) (*Job, bool, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
maxJobs := registryMaxJobsFromEnv()
|
||||
r.pruneTerminalIfOver(maxJobs)
|
||||
|
||||
if idempotencyKey != nil && *idempotencyKey != "" {
|
||||
k := idempoKey{tenant: tenantID, key: *idempotencyKey}
|
||||
if existing, ok := r.byIdempo[k]; ok {
|
||||
return existing, false, nil
|
||||
st := existing.statusLocked()
|
||||
if st == StatusQueued || st == StatusRunning {
|
||||
r.mu.Unlock()
|
||||
return existing, false, nil
|
||||
}
|
||||
delete(r.byIdempo, k)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -263,9 +341,25 @@ func (r *Registry) Enqueue(tenantID, kind string, idempotencyKey *string, module
|
||||
}
|
||||
r.byID[j.ID] = j
|
||||
r.pruneTerminalIfOver(maxJobs)
|
||||
enqueuedHook := r.onEnqueued
|
||||
workerStart := r.workerStart
|
||||
r.mu.Unlock()
|
||||
|
||||
if r.workerStart != nil {
|
||||
go r.workerStart(j)
|
||||
if enqueuedHook != nil {
|
||||
enqueuedHook(j)
|
||||
}
|
||||
if workerStart != nil {
|
||||
go func() {
|
||||
r.workerSem <- struct{}{}
|
||||
active := len(r.workerSem)
|
||||
capacity := cap(r.workerSem)
|
||||
observability.RecordJobQueueDepth(active, capacity)
|
||||
defer func() {
|
||||
<-r.workerSem
|
||||
observability.RecordJobQueueDepth(len(r.workerSem), capacity)
|
||||
}()
|
||||
workerStart(j)
|
||||
}()
|
||||
}
|
||||
return j, true, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package jobs
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"evobgp/internal/maintenance"
|
||||
"evobgp/internal/pgmonitor"
|
||||
"evobgp/internal/store"
|
||||
)
|
||||
|
||||
func (w *Worker) maintenanceExecutor() *maintenance.PolicyExecutor {
|
||||
if w == nil {
|
||||
return nil
|
||||
}
|
||||
return &maintenance.PolicyExecutor{Store: w.Store, Pool: w.PgPool}
|
||||
}
|
||||
|
||||
func (w *Worker) runMaintenancePolicy(j *Job) {
|
||||
if w == nil || w.PgPool == nil {
|
||||
j.Fail("postgresql not configured")
|
||||
return
|
||||
}
|
||||
policyID, _ := j.Meta["policy_id"].(string)
|
||||
policyID = strings.TrimSpace(policyID)
|
||||
if policyID == "" {
|
||||
j.Fail("missing policy_id in job meta")
|
||||
return
|
||||
}
|
||||
dryRun, _ := j.Meta["dry_run"].(bool)
|
||||
actor, _ := j.Meta["actor_prefix"].(string)
|
||||
ctx, cancel := j.workContext()
|
||||
defer cancel()
|
||||
|
||||
pol, err := w.Store.GetMaintenancePolicy(policyID)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
j.Fail("maintenance policy not found")
|
||||
return
|
||||
}
|
||||
j.Fail(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
auditID, _ := pgmonitor.InsertMaintenanceAuditWithPolicy(ctx, w.PgPool, j.TenantID, actor, "maintenance_policy_run", pol.TableName, policyID, dryRun)
|
||||
exec := w.maintenanceExecutor()
|
||||
detail, err := exec.Execute(ctx, pol, dryRun)
|
||||
var errMsg *string
|
||||
status := StatusSucceeded
|
||||
if err != nil {
|
||||
s := err.Error()
|
||||
errMsg = &s
|
||||
status = StatusFailed
|
||||
_ = w.Store.TouchMaintenancePolicyRun(policyID, status, s)
|
||||
j.Fail(s)
|
||||
} else {
|
||||
j.mergeMeta(map[string]any{"maintenance": detail, "audit_id": auditID, "policy_id": policyID})
|
||||
j.Succeed()
|
||||
}
|
||||
if auditID != "" {
|
||||
_ = pgmonitor.FinishMaintenanceAudit(ctx, w.PgPool, auditID, status, detail, errMsg)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package jobs
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"evobgp/internal/pgmonitor"
|
||||
)
|
||||
|
||||
func (w *Worker) pgService() *pgmonitor.Service {
|
||||
if w == nil || w.PgPool == nil {
|
||||
return nil
|
||||
}
|
||||
return pgmonitor.NewService(w.PgPool)
|
||||
}
|
||||
|
||||
func (w *Worker) runPostgresMetricsRefresh(j *Job) {
|
||||
s := w.pgService()
|
||||
if s == nil {
|
||||
j.Fail("postgresql not configured")
|
||||
return
|
||||
}
|
||||
ctx, cancel := j.workContext()
|
||||
defer cancel()
|
||||
if err := s.RefreshMetricsSnapshot(ctx); err != nil {
|
||||
j.Fail(err.Error())
|
||||
return
|
||||
}
|
||||
j.Succeed()
|
||||
}
|
||||
|
||||
func (w *Worker) runPostgresSlowQueryAgg(j *Job) {
|
||||
s := w.pgService()
|
||||
if s == nil {
|
||||
j.Fail("postgresql not configured")
|
||||
return
|
||||
}
|
||||
ctx, cancel := j.workContext()
|
||||
defer cancel()
|
||||
if err := s.AggregateSlowQueries(ctx, 30); err != nil {
|
||||
j.Fail(err.Error())
|
||||
return
|
||||
}
|
||||
j.Succeed()
|
||||
}
|
||||
|
||||
func (w *Worker) runPostgresTableBloat(j *Job) {
|
||||
s := w.pgService()
|
||||
if s == nil {
|
||||
j.Fail("postgresql not configured")
|
||||
return
|
||||
}
|
||||
ctx, cancel := j.workContext()
|
||||
defer cancel()
|
||||
if err := s.EstimateTableBloat(ctx); err != nil {
|
||||
j.Fail(err.Error())
|
||||
return
|
||||
}
|
||||
j.Succeed()
|
||||
}
|
||||
|
||||
func (w *Worker) runPostgresIndexUsage(j *Job) {
|
||||
s := w.pgService()
|
||||
if s == nil {
|
||||
j.Fail("postgresql not configured")
|
||||
return
|
||||
}
|
||||
ctx, cancel := j.workContext()
|
||||
defer cancel()
|
||||
if err := s.AnalyzeIndexUsage(ctx); err != nil {
|
||||
j.Fail(err.Error())
|
||||
return
|
||||
}
|
||||
j.Succeed()
|
||||
}
|
||||
|
||||
func (w *Worker) runPostgresAutovacuumLag(j *Job) {
|
||||
s := w.pgService()
|
||||
if s == nil {
|
||||
j.Fail("postgresql not configured")
|
||||
return
|
||||
}
|
||||
ctx, cancel := j.workContext()
|
||||
defer cancel()
|
||||
if err := s.DetectAutovacuumLag(ctx); err != nil {
|
||||
j.Fail(err.Error())
|
||||
return
|
||||
}
|
||||
j.Succeed()
|
||||
}
|
||||
|
||||
func (w *Worker) runPostgresMaint(j *Job, kind string) {
|
||||
if w == nil || w.PgPool == nil {
|
||||
j.Fail("postgresql not configured")
|
||||
return
|
||||
}
|
||||
table, _ := j.Meta["table"].(string)
|
||||
dryRun, _ := j.Meta["dry_run"].(bool)
|
||||
actor, _ := j.Meta["actor_prefix"].(string)
|
||||
ctx, cancel := j.workContext()
|
||||
defer cancel()
|
||||
auditID, _ := pgmonitor.InsertMaintenanceAudit(ctx, w.PgPool, j.TenantID, actor, kind, table, dryRun)
|
||||
detail, err := pgmonitor.ExecMaintenance(ctx, w.PgPool, kind, table, dryRun)
|
||||
var errMsg *string
|
||||
status := StatusSucceeded
|
||||
if err != nil {
|
||||
s := err.Error()
|
||||
errMsg = &s
|
||||
status = StatusFailed
|
||||
j.Fail(s)
|
||||
} else {
|
||||
j.mergeMeta(map[string]any{"maintenance": detail, "audit_id": auditID})
|
||||
j.Succeed()
|
||||
}
|
||||
if auditID != "" {
|
||||
_ = pgmonitor.FinishMaintenanceAudit(ctx, w.PgPool, auditID, status, detail, errMsg)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Worker) runPostgresCleanup(j *Job) {
|
||||
j.Fail("postgres_cleanup deprecated: configure maintenance_policy in UI and use maintenance_policy_run")
|
||||
}
|
||||
|
||||
// EnqueuePostgresAnalyzerJobs enqueues periodic analyzer jobs (global tenant id).
|
||||
func EnqueuePostgresAnalyzerJobs(reg *Registry, tenantID string) {
|
||||
if reg == nil || tenantID == "" {
|
||||
return
|
||||
}
|
||||
kinds := []string{
|
||||
KindPostgresMetricsRefresh,
|
||||
KindPostgresSlowQueryAgg,
|
||||
KindPostgresTableBloat,
|
||||
KindPostgresIndexUsage,
|
||||
KindPostgresAutovacuumLag,
|
||||
}
|
||||
for _, k := range kinds {
|
||||
key := fmt.Sprintf("pgmon-%s-%s", k, tenantID)
|
||||
idem := key
|
||||
_, _, _ = reg.Enqueue(tenantID, k, &idem, nil, map[string]any{"trigger": "scheduler"})
|
||||
}
|
||||
}
|
||||
+153
-18
@@ -13,9 +13,13 @@ import (
|
||||
|
||||
"evobgp/internal/birddeploy"
|
||||
"evobgp/internal/birdfmt"
|
||||
"evobgp/internal/httpclient"
|
||||
"evobgp/internal/nodedispatch"
|
||||
"evobgp/internal/observability"
|
||||
"evobgp/internal/pipeline"
|
||||
"evobgp/internal/store"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// mergeBirdPostApplyMeta attaches a birdc snapshot after deploy/reload (best-effort).
|
||||
@@ -41,17 +45,29 @@ func mergeBirdPostApplyMeta(j *Job) {
|
||||
}
|
||||
|
||||
const (
|
||||
KindModuleRefresh = "module_refresh"
|
||||
KindTenantRefresh = "tenant_refresh"
|
||||
KindPeerReconcile = "peer_reconcile"
|
||||
KindDeployApply = "deploy_apply"
|
||||
KindRevisionRollback = "revision_rollback"
|
||||
KindBirdReload = "bird_reload"
|
||||
KindModuleRefresh = "module_refresh"
|
||||
KindTenantRefresh = "tenant_refresh"
|
||||
KindPeerReconcile = "peer_reconcile"
|
||||
KindDeployApply = "deploy_apply"
|
||||
KindRevisionRollback = "revision_rollback"
|
||||
KindBirdReload = "bird_reload"
|
||||
KindPostgresMetricsRefresh = "postgres_metrics_refresh"
|
||||
KindPostgresSlowQueryAgg = "postgres_slow_query_aggregate"
|
||||
KindPostgresTableBloat = "postgres_table_bloat_estimate"
|
||||
KindPostgresIndexUsage = "postgres_index_usage_analyze"
|
||||
KindPostgresAutovacuumLag = "postgres_autovacuum_lag_detect"
|
||||
KindPostgresVacuum = "postgres_vacuum"
|
||||
KindPostgresVacuumAnalyze = "postgres_vacuum_analyze"
|
||||
KindPostgresAnalyze = "postgres_analyze"
|
||||
KindPostgresReindex = "postgres_reindex"
|
||||
KindPostgresCleanup = "postgres_cleanup"
|
||||
KindMaintenancePolicyRun = "maintenance_policy_run"
|
||||
)
|
||||
|
||||
// Worker executes queued jobs against store.Backend (memory or SQL).
|
||||
type Worker struct {
|
||||
Store store.Backend
|
||||
PgPool *pgxpool.Pool
|
||||
HTTPClient *http.Client // optional; CDN refresh uses this (default 45s timeout).
|
||||
// Registry is set after BootstrapWorkers creates the job queue; used to chain deploy_apply after refresh/rollback.
|
||||
Registry *Registry
|
||||
@@ -69,7 +85,7 @@ type revisionLogEntry struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
var defaultWorkerHTTP = &http.Client{Timeout: 45 * time.Second}
|
||||
var defaultWorkerHTTP = httpclient.New(httpclient.DefaultTimeout)
|
||||
|
||||
func (w *Worker) httpClient() *http.Client {
|
||||
if w != nil && w.HTTPClient != nil {
|
||||
@@ -82,6 +98,9 @@ func (w *Worker) httpClient() *http.Client {
|
||||
func (w *Worker) Process(j *Job) {
|
||||
defer func() {
|
||||
observability.RecordJobTerminal(j.Kind, j.statusLocked())
|
||||
if w != nil && w.Registry != nil {
|
||||
w.Registry.fireTerminal(j)
|
||||
}
|
||||
}()
|
||||
|
||||
if w == nil || w.Store == nil {
|
||||
@@ -90,6 +109,9 @@ func (w *Worker) Process(j *Job) {
|
||||
return
|
||||
}
|
||||
j.MarkRunning()
|
||||
if w != nil && w.Registry != nil {
|
||||
w.Registry.fireRunning(j)
|
||||
}
|
||||
if j.IsCancelRequested() {
|
||||
j.MarkCancelled()
|
||||
return
|
||||
@@ -102,7 +124,17 @@ func (w *Worker) Process(j *Job) {
|
||||
j.Fail("missing module_id in job meta")
|
||||
return
|
||||
}
|
||||
if err := pipeline.RefreshModuleIngest(context.Background(), w.Store, w.httpClient(), j.TenantID, mid); err != nil {
|
||||
ctx, cancel := j.workContext()
|
||||
defer cancel()
|
||||
if ctx.Err() != nil {
|
||||
j.MarkCancelled()
|
||||
return
|
||||
}
|
||||
if err := pipeline.RefreshModuleIngest(ctx, w.Store, w.httpClient(), j.TenantID, mid); err != nil {
|
||||
if ctx.Err() != nil {
|
||||
j.MarkCancelled()
|
||||
return
|
||||
}
|
||||
j.Fail(err.Error())
|
||||
return
|
||||
}
|
||||
@@ -121,16 +153,44 @@ func (w *Worker) Process(j *Job) {
|
||||
j.Succeed()
|
||||
return
|
||||
}
|
||||
ctx, cancel := j.workContext()
|
||||
defer cancel()
|
||||
ctl := &birdfmt.BirdCtl{
|
||||
Socket: sock,
|
||||
Birdc: strings.TrimSpace(os.Getenv("EVOBGP_BIRDC_BIN")),
|
||||
}
|
||||
if err := ctl.Configure(context.Background()); err != nil {
|
||||
if err := ctl.Configure(ctx); err != nil {
|
||||
if ctx.Err() != nil {
|
||||
j.MarkCancelled()
|
||||
return
|
||||
}
|
||||
j.Fail(err.Error())
|
||||
return
|
||||
}
|
||||
mergeBirdPostApplyMeta(j)
|
||||
j.Succeed()
|
||||
case KindPostgresMetricsRefresh:
|
||||
w.runPostgresMetricsRefresh(j)
|
||||
case KindPostgresSlowQueryAgg:
|
||||
w.runPostgresSlowQueryAgg(j)
|
||||
case KindPostgresTableBloat:
|
||||
w.runPostgresTableBloat(j)
|
||||
case KindPostgresIndexUsage:
|
||||
w.runPostgresIndexUsage(j)
|
||||
case KindPostgresAutovacuumLag:
|
||||
w.runPostgresAutovacuumLag(j)
|
||||
case KindPostgresVacuum:
|
||||
w.runPostgresMaint(j, "vacuum")
|
||||
case KindPostgresVacuumAnalyze:
|
||||
w.runPostgresMaint(j, "vacuum_analyze")
|
||||
case KindPostgresAnalyze:
|
||||
w.runPostgresMaint(j, "analyze")
|
||||
case KindPostgresReindex:
|
||||
w.runPostgresMaint(j, "reindex")
|
||||
case KindPostgresCleanup:
|
||||
w.runPostgresCleanup(j)
|
||||
case KindMaintenancePolicyRun:
|
||||
w.runMaintenancePolicy(j)
|
||||
default:
|
||||
j.Fail("unknown job kind")
|
||||
}
|
||||
@@ -152,9 +212,14 @@ func (w *Worker) runPeerReconcile(j *Job) {
|
||||
return
|
||||
}
|
||||
if len(latest) == 0 {
|
||||
// First run fallback: render full tenant state once if no baseline revision exists yet.
|
||||
rid, err := pipeline.RenderTenantRevision(context.Background(), w.Store, w.httpClient(), j.TenantID, triggerModuleID)
|
||||
ctx, cancel := j.workContext()
|
||||
defer cancel()
|
||||
rid, err := pipeline.RenderTenantRevision(ctx, w.Store, w.httpClient(), j.TenantID, triggerModuleID)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
j.MarkCancelled()
|
||||
return
|
||||
}
|
||||
j.Fail(err.Error())
|
||||
return
|
||||
}
|
||||
@@ -171,8 +236,14 @@ func (w *Worker) runPeerReconcile(j *Job) {
|
||||
}
|
||||
cursor = next
|
||||
}
|
||||
rid, err := pipeline.RenderTenantRevisionFromPrefixes(context.Background(), w.Store, w.httpClient(), j.TenantID, triggerModuleID, rows)
|
||||
ctx, cancel := j.workContext()
|
||||
defer cancel()
|
||||
rid, err := pipeline.RenderTenantRevisionFromPrefixes(ctx, w.Store, w.httpClient(), j.TenantID, triggerModuleID, rows)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
j.MarkCancelled()
|
||||
return
|
||||
}
|
||||
j.Fail(err.Error())
|
||||
return
|
||||
}
|
||||
@@ -188,8 +259,8 @@ func (w *Worker) runPeerReconcile(j *Job) {
|
||||
} else {
|
||||
j.mergeMeta(map[string]any{"log_build_error": err.Error()})
|
||||
}
|
||||
j.Succeed()
|
||||
w.enqueueDeployAllSpeakers(j, j.TenantID, revID)
|
||||
j.Succeed()
|
||||
}
|
||||
|
||||
func (w *Worker) peerTriggerModuleID(tenantID string, latest []*store.Revision) (string, error) {
|
||||
@@ -230,7 +301,13 @@ func (w *Worker) runTenantRefresh(j *Job) {
|
||||
j.Fail("missing module_ids in job meta")
|
||||
return
|
||||
}
|
||||
if err := pipeline.RefreshTenantModules(context.Background(), w.Store, w.httpClient(), j.TenantID, moduleIDs); err != nil {
|
||||
ctx, cancel := j.workContext()
|
||||
defer cancel()
|
||||
if err := pipeline.RefreshTenantModules(ctx, w.Store, w.httpClient(), j.TenantID, moduleIDs); err != nil {
|
||||
if ctx.Err() != nil {
|
||||
j.MarkCancelled()
|
||||
return
|
||||
}
|
||||
j.Fail(err.Error())
|
||||
return
|
||||
}
|
||||
@@ -275,6 +352,8 @@ func (w *Worker) finishModuleRefreshSuccess(j *Job, triggerModuleID string) {
|
||||
mu := w.tenantRefreshMu(j.TenantID)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
ctx, cancel := j.workContext()
|
||||
defer cancel()
|
||||
deferDeploy := false
|
||||
if w.Registry != nil {
|
||||
deferDeploy = w.Registry.CountOtherActiveRefresh(j.TenantID, j.ID) > 0
|
||||
@@ -288,8 +367,12 @@ func (w *Worker) finishModuleRefreshSuccess(j *Job, triggerModuleID string) {
|
||||
return
|
||||
}
|
||||
|
||||
rev, err := pipeline.RenderTenantRevision(context.Background(), w.Store, w.httpClient(), j.TenantID, triggerModuleID)
|
||||
rev, err := pipeline.RenderTenantRevision(ctx, w.Store, w.httpClient(), j.TenantID, triggerModuleID)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
j.MarkCancelled()
|
||||
return
|
||||
}
|
||||
j.Fail(err.Error())
|
||||
return
|
||||
}
|
||||
@@ -303,8 +386,8 @@ func (w *Worker) finishModuleRefreshSuccess(j *Job, triggerModuleID string) {
|
||||
} else {
|
||||
j.mergeMeta(map[string]any{"log_build_error": err.Error()})
|
||||
}
|
||||
j.Succeed()
|
||||
w.enqueueDeployAllSpeakers(j, j.TenantID, rev)
|
||||
j.Succeed()
|
||||
}
|
||||
|
||||
// enqueueDeployAllSpeakers queues the same work as POST /v1/apply (all speakers, no speaker_id).
|
||||
@@ -335,6 +418,8 @@ func (w *Worker) runDeployApply(j *Job) {
|
||||
j.Fail("missing revision_id in job meta")
|
||||
return
|
||||
}
|
||||
ctx, cancel := j.workContext()
|
||||
defer cancel()
|
||||
activeDir := strings.TrimSpace(os.Getenv("EVOBGP_BIRD_ACTIVE_DIR"))
|
||||
if activeDir != "" {
|
||||
revObj, err := w.Store.GetRevision(j.TenantID, revID)
|
||||
@@ -354,12 +439,17 @@ func (w *Worker) runDeployApply(j *Job) {
|
||||
Socket: strings.TrimSpace(os.Getenv("EVOBGP_BIRDC_SOCKET")),
|
||||
}
|
||||
ctl := &birdfmt.BirdCtl{Bird: cfg.BirdBin, Birdc: cfg.BirdcBin, Socket: cfg.Socket}
|
||||
if err := birddeploy.ApplyRevision(context.Background(), ctl, revObj, cfg); err != nil {
|
||||
if err := birddeploy.ApplyRevision(ctx, ctl, revObj, cfg); err != nil {
|
||||
if ctx.Err() != nil {
|
||||
j.MarkCancelled()
|
||||
return
|
||||
}
|
||||
j.Fail(err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
applied := make([]string, 0, 8)
|
||||
var dispatchResults []nodedispatch.Result
|
||||
applyOne := func(speakerID string) error {
|
||||
if err := w.Store.SetLastAppliedRevision(j.TenantID, speakerID, revID); err != nil {
|
||||
return err
|
||||
@@ -371,21 +461,60 @@ func (w *Worker) runDeployApply(j *Job) {
|
||||
applied = append(applied, speakerID)
|
||||
return nil
|
||||
}
|
||||
dispatchSpeaker := func(sp *store.Speaker) {
|
||||
if !nodedispatch.Enabled() || sp == nil {
|
||||
return
|
||||
}
|
||||
meta := store.ParseSpeakerMeta(sp.MetaJSON)
|
||||
if !store.SpeakerNeedsRemoteDispatch(sp.Role, meta) {
|
||||
return
|
||||
}
|
||||
ctx2, cancel := context.WithTimeout(ctx, 35*time.Second)
|
||||
defer cancel()
|
||||
res := nodedispatch.WakeSpeaker(ctx2, sp, nodedispatch.Options{RevisionID: revID})
|
||||
dispatchResults = append(dispatchResults, res)
|
||||
patch := store.SpeakerMeta{
|
||||
LastDispatchAt: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
LastDispatchStatus: res.Status,
|
||||
}
|
||||
if res.Error != "" {
|
||||
patch.LastDispatchError = res.Error
|
||||
patch.SyncStatus = "error"
|
||||
} else if res.Status == "ok" {
|
||||
patch.LastDispatchError = ""
|
||||
patch.SyncStatus = "synced"
|
||||
}
|
||||
merged := store.MergeSpeakerMetaJSON(sp.MetaJSON, patch)
|
||||
_, _ = w.Store.UpdateSpeaker(j.TenantID, sp.ID, &store.SpeakerPatch{MetaJSON: &merged})
|
||||
}
|
||||
if hasSpeaker && spk != "" {
|
||||
if err := applyOne(spk); err != nil {
|
||||
j.Fail(err.Error())
|
||||
return
|
||||
}
|
||||
if sp, err := w.Store.GetSpeaker(j.TenantID, spk); err == nil {
|
||||
dispatchSpeaker(sp)
|
||||
}
|
||||
if len(dispatchResults) > 0 {
|
||||
j.mergeMeta(map[string]any{"node_dispatch": map[string]any{
|
||||
"revision_id": revID,
|
||||
"results": dispatchResults,
|
||||
}})
|
||||
}
|
||||
mergeBirdPostApplyMeta(j)
|
||||
j.Succeed()
|
||||
return
|
||||
}
|
||||
for _, sp := range w.Store.ListSpeakersForTenant(j.TenantID) {
|
||||
speakers := w.Store.ListSpeakersForTenant(j.TenantID)
|
||||
for _, sp := range speakers {
|
||||
if err := applyOne(sp.ID); err != nil {
|
||||
j.Fail(err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
for _, sp := range speakers {
|
||||
dispatchSpeaker(sp)
|
||||
}
|
||||
j.mergeMeta(map[string]any{
|
||||
"apply_summary": map[string]any{
|
||||
"revision_id": revID,
|
||||
@@ -394,6 +523,12 @@ func (w *Worker) runDeployApply(j *Job) {
|
||||
"message": fmt.Sprintf("Ревизия %s применена на %d спикерах", shortID(revID), len(applied)),
|
||||
},
|
||||
})
|
||||
if len(dispatchResults) > 0 {
|
||||
j.mergeMeta(map[string]any{"node_dispatch": map[string]any{
|
||||
"revision_id": revID,
|
||||
"results": dispatchResults,
|
||||
}})
|
||||
}
|
||||
mergeBirdPostApplyMeta(j)
|
||||
j.Succeed()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package maintenance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"evobgp/internal/store"
|
||||
)
|
||||
|
||||
// ConfigProvider caches maintenance policies from store.Backend with hot reload.
|
||||
type ConfigProvider struct {
|
||||
store store.Backend
|
||||
mu sync.RWMutex
|
||||
items []*store.MaintenancePolicy
|
||||
}
|
||||
|
||||
// NewConfigProvider constructs a provider; call Reload before use.
|
||||
func NewConfigProvider(st store.Backend) *ConfigProvider {
|
||||
return &ConfigProvider{store: st}
|
||||
}
|
||||
|
||||
// Reload loads all policies from the database into memory.
|
||||
func (c *ConfigProvider) Reload(ctx context.Context) error {
|
||||
if c == nil || c.store == nil {
|
||||
return nil
|
||||
}
|
||||
_ = ctx
|
||||
items, _, _, err := c.store.ListMaintenancePolicies("", 1000)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cp := make([]*store.MaintenancePolicy, len(items))
|
||||
copy(cp, items)
|
||||
c.mu.Lock()
|
||||
c.items = cp
|
||||
c.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Snapshot returns a copy of cached policies.
|
||||
func (c *ConfigProvider) Snapshot() []*store.MaintenancePolicy {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
out := make([]*store.MaintenancePolicy, len(c.items))
|
||||
copy(out, c.items)
|
||||
return out
|
||||
}
|
||||
|
||||
// Get returns one policy by id from cache or store.
|
||||
func (c *ConfigProvider) Get(ctx context.Context, id string) (*store.MaintenancePolicy, error) {
|
||||
if c == nil || c.store == nil {
|
||||
return nil, store.ErrNotFound
|
||||
}
|
||||
c.mu.RLock()
|
||||
for _, p := range c.items {
|
||||
if p.ID == id {
|
||||
cp := *p
|
||||
c.mu.RUnlock()
|
||||
return &cp, nil
|
||||
}
|
||||
}
|
||||
c.mu.RUnlock()
|
||||
return c.store.GetMaintenancePolicy(id)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package maintenance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"evobgp/internal/store"
|
||||
)
|
||||
|
||||
func TestConfigProviderReloadAndSnapshot(t *testing.T) {
|
||||
mem := store.NewMemory()
|
||||
ret := 3600
|
||||
if _, err := mem.CreateMaintenancePolicy(&store.MaintenancePolicy{
|
||||
Name: "p1",
|
||||
TableName: "job_audit",
|
||||
Schedule: "0 3 * * *",
|
||||
RetentionPeriodSec: &ret,
|
||||
Enabled: true,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cp := NewConfigProvider(mem)
|
||||
if err := cp.Reload(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
snap := cp.Snapshot()
|
||||
if len(snap) != 1 || snap[0].Name != "p1" {
|
||||
t.Fatalf("snapshot: %+v", snap)
|
||||
}
|
||||
|
||||
newName := "p1-updated"
|
||||
if _, err := mem.UpdateMaintenancePolicy(snap[0].ID, &store.MaintenancePolicyPatch{Name: &newName}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cp.Reload(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
snap2 := cp.Snapshot()
|
||||
if len(snap2) != 1 || snap2[0].Name != newName {
|
||||
t.Fatalf("after reload: %+v", snap2)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package maintenance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"evobgp/internal/pgmonitor"
|
||||
)
|
||||
|
||||
// TableHints are PostgreSQL statistics hints for UI recommendations.
|
||||
type TableHints struct {
|
||||
TableName string `json:"table_name"`
|
||||
DeadTuples int64 `json:"n_dead_tup"`
|
||||
BloatRatio float64 `json:"bloat_ratio,omitempty"`
|
||||
LastAutovacuum string `json:"last_autovacuum,omitempty"`
|
||||
RecommendVacuum bool `json:"recommend_vacuum"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
Refs []string `json:"refs,omitempty"`
|
||||
}
|
||||
|
||||
// DBStatsProvider wraps pgmonitor for maintenance policy hints.
|
||||
type DBStatsProvider struct {
|
||||
pg *pgmonitor.Service
|
||||
}
|
||||
|
||||
// NewDBStatsProvider constructs a stats provider.
|
||||
func NewDBStatsProvider(pg *pgmonitor.Service) *DBStatsProvider {
|
||||
return &DBStatsProvider{pg: pg}
|
||||
}
|
||||
|
||||
// Hints returns table-level vacuum/bloat hints.
|
||||
func (d *DBStatsProvider) Hints(ctx context.Context, tableName string) (TableHints, error) {
|
||||
out := TableHints{TableName: tableName}
|
||||
if d == nil || d.pg == nil {
|
||||
return out, fmt.Errorf("maintenance: postgres monitoring not configured")
|
||||
}
|
||||
if err := ValidateTableName(tableName); err != nil {
|
||||
return out, err
|
||||
}
|
||||
tables, err := d.pg.Tables(ctx, 100)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
for _, t := range tables {
|
||||
if t.Relname != tableName {
|
||||
continue
|
||||
}
|
||||
out.DeadTuples = t.DeadTuples
|
||||
out.BloatRatio = t.BloatRatio
|
||||
if t.LastAutovacuum != nil {
|
||||
out.LastAutovacuum = t.LastAutovacuum.UTC().Format("2006-01-02T15:04:05Z")
|
||||
}
|
||||
if t.BloatRatio > 0.2 && t.DeadTuples > 5000 {
|
||||
out.RecommendVacuum = true
|
||||
out.Detail = "Высокая доля n_dead_tup; рекомендуется VACUUM."
|
||||
out.Refs = []string{t.Relname}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
out.Detail = "Таблица не найдена в pg_stat_user_tables (top by size)."
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Package maintenance implements PostgreSQL maintenance policies loaded from the database.
|
||||
package maintenance
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user