Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cbb4b467ad | ||
|
|
e65cf0d958 | ||
|
|
4a57c91e29 | ||
|
|
2289107911 | ||
|
|
782097420d | ||
|
|
82382d90f2 | ||
|
|
5a16a45922 | ||
|
|
6a6f6cedbc | ||
|
|
9639a03bfe | ||
|
|
48c10b7436 | ||
|
|
4db6438245 | ||
|
|
fb108ec5ab | ||
|
|
a1ada06a76 | ||
|
|
b5ed47902c | ||
|
|
8a19c2a3f4 | ||
|
|
1c0d78b552 | ||
|
|
e0a912a693 | ||
|
|
9740a34fdc | ||
|
|
6fa265a246 | ||
|
|
b8170c4204 | ||
|
|
c6e13bb86b | ||
|
|
2aecbf96fd |
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": ["@commitlint/config-conventional"]
|
|
||||||
}
|
|
||||||
@@ -142,6 +142,8 @@ feat(web): add module create dialog on /modules
|
|||||||
| `.cursor/` | `chore` |
|
| `.cursor/` | `chore` |
|
||||||
| прочее в корне | `chore` |
|
| прочее в корне | `chore` |
|
||||||
|
|
||||||
|
**Запрещено:** несколько scope через запятую (`refactor(web, httpapi): …`) — semantic-release не распознает `type`, релиз не будет (см. [docs/releasing.md](../../docs/releasing.md)).
|
||||||
|
|
||||||
`type` определять по **содержимому diff**, не только по пути.
|
`type` определять по **содержимому diff**, не только по пути.
|
||||||
|
|
||||||
## Multi-change
|
## Multi-change
|
||||||
|
|||||||
@@ -57,9 +57,9 @@ alwaysApply: true
|
|||||||
|
|
||||||
## Code Style
|
## Code Style
|
||||||
|
|
||||||
**STYLE-01** | MUST | Go-код после `gofmt`; перед PR — `go vet ./...`.
|
**STYLE-01** | MUST | Go-код после `gofmt`; перед PR — `go vet ./...`. Агент после правок Go: `gofmt -w` на изменённых файлах + `golangci-lint run` (или `scripts/lint-go.*`) до exit 0.
|
||||||
*Rationale:* единый стиль.
|
*Rationale:* CI job `go` включает golangci-lint (gofmt).
|
||||||
*Проверка:* CI job `go`.
|
*Проверка:* CI job `go`; `.cursor/rules/engineering.mdc` STYLE-01.
|
||||||
|
|
||||||
**STYLE-02** | MUST | Экспортируемые типы/функции публичных пакетов — godoc-комментарий.
|
**STYLE-02** | MUST | Экспортируемые типы/функции публичных пакетов — godoc-комментарий.
|
||||||
*Rationale:* навигация по API пакетов.
|
*Rationale:* навигация по API пакетов.
|
||||||
@@ -123,8 +123,8 @@ alwaysApply: true
|
|||||||
**TEST-03** | MUST | Новые BIRD-сценарии в `internal/birdfmt/testdata/scenarios/*/bird.conf` + `bird -p`.
|
**TEST-03** | MUST | Новые BIRD-сценарии в `internal/birdfmt/testdata/scenarios/*/bird.conf` + `bird -p`.
|
||||||
*Проверка:* CI job `bird2`.
|
*Проверка:* CI job `bird2`.
|
||||||
|
|
||||||
**TEST-04** | MUST | Изменения `web/` — локально `npm run check` и `npm run lint`; CI job `web` в `.gitea/workflows/ci.yaml`.
|
**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`.
|
**TEST-05** | MUST | Изменения OpenAPI — `npx @redocly/cli lint docs/openapi.yaml`.
|
||||||
*Проверка:* CI job `openapi`.
|
*Проверка:* CI job `openapi`.
|
||||||
@@ -230,7 +230,8 @@ alwaysApply: true
|
|||||||
go vet ./...
|
go vet ./...
|
||||||
go test ./... -race -count=1
|
go test ./... -race -count=1
|
||||||
npx @redocly/cli lint docs/openapi.yaml
|
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
|
# birdfmt: go test ./internal/birdfmt/... -count=1
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -73,6 +73,15 @@ alwaysApply: false
|
|||||||
**WEB-15** | MUST | Сомнения — https://shadcn-svelte.com/llms.txt , Svelte MCP, `npm run check`.
|
**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`.
|
**WEB-16** | MUST | Подтверждение удаления — `ConfirmDialog` из patterns, не `window.confirm`.
|
||||||
*Проверка:* review.
|
*Проверка:* review.
|
||||||
|
|
||||||
@@ -95,15 +104,23 @@ Tailwind v4: https://shadcn-svelte.com/docs/migration/tailwind-v4
|
|||||||
|
|
||||||
## Enforcement
|
## Enforcement
|
||||||
|
|
||||||
|
**Обязательный финальный шаг агента при правках `web/**`:** `npm run check` **и** `npm run lint` (см. **WEB-19**). Только `check` недостаточно.
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
cd web
|
cd web
|
||||||
npm run check
|
npm run check
|
||||||
npm run lint
|
npm run lint
|
||||||
|
# при warn/fail lint:
|
||||||
|
npx prettier --write .
|
||||||
|
npm run check
|
||||||
|
npm run lint
|
||||||
```
|
```
|
||||||
|
|
||||||
**PR checklist `web/**`:**
|
**PR checklist `web/**`:**
|
||||||
|
- [ ] `npm run check` — exit 0
|
||||||
|
- [ ] `npm run lint` (prettier --check) — exit 0
|
||||||
- [ ] `ui/core` / `ui/patterns`, не дубли примитивов
|
- [ ] `ui/core` / `ui/patterns`, не дубли примитивов
|
||||||
- [ ] Новые примитивы через shadcn CLI
|
- [ ] Новые примитивы через shadcn CLI
|
||||||
- [ ] Ссылка на docs компонента (если новый паттерн)
|
- [ ] Ссылка на docs компонента (если новый паттерн)
|
||||||
|
|
||||||
**CI:** job `web` рекомендован; пока обязательно локально.
|
**CI:** job `web` — `npm run check` + `npm run lint`.
|
||||||
|
|||||||
@@ -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`.
|
Имена образов: `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
|
```bash
|
||||||
|
|||||||
@@ -198,6 +198,8 @@ jobs:
|
|||||||
run: sh scripts/lint-httpapi.sh
|
run: sh scripts/lint-httpapi.sh
|
||||||
- name: Check migration pairs (DEP-03)
|
- name: Check migration pairs (DEP-03)
|
||||||
run: sh scripts/check-migrations-pair.sh
|
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) не запускается.
|
# go.mod: go 1.24 — бинарник golangci-lint < v1.64.2 (сборка на Go 1.23) не запускается.
|
||||||
- name: golangci-lint
|
- name: golangci-lint
|
||||||
uses: golangci/golangci-lint-action@v6
|
uses: golangci/golangci-lint-action@v6
|
||||||
@@ -301,6 +303,8 @@ jobs:
|
|||||||
cache-dependency-path: package-lock.json
|
cache-dependency-path: package-lock.json
|
||||||
- name: Install release tooling
|
- name: Install release tooling
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
- name: Verify releasable commit messages
|
||||||
|
run: node scripts/commit/verify-release-commits.mjs
|
||||||
- name: Semantic release
|
- name: Semantic release
|
||||||
run: npx semantic-release
|
run: npx semantic-release
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -16,3 +16,4 @@ Thumbs.db
|
|||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
!.env.*.example
|
||||||
|
|||||||
@@ -51,6 +51,7 @@
|
|||||||
|
|
||||||
- Консоль пользователя: **PowerShell**; пути в стиле `deploy\compose`.
|
- Консоль пользователя: **PowerShell**; пути в стиле `deploy\compose`.
|
||||||
- Быстрый старт и переменные: [docs/quickstart.md](docs/quickstart.md), [README.md](README.md).
|
- Быстрый старт и переменные: [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 / фронтенд
|
## 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"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"evobgp/internal/agentserver"
|
||||||
"evobgp/internal/birdfmt"
|
"evobgp/internal/birdfmt"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -17,12 +19,14 @@ func main() {
|
|||||||
socket := flag.String("socket", "", "optional birdc control socket (-s)")
|
socket := flag.String("socket", "", "optional birdc control socket (-s)")
|
||||||
timeout := flag.Duration("timeout", 30*time.Second, "timeout for bird/birdc")
|
timeout := flag.Duration("timeout", 30*time.Second, "timeout for bird/birdc")
|
||||||
watchEvery := flag.Duration("watch-interval", 30*time.Second, "for watch: interval between birdc configure")
|
watchEvery := flag.Duration("watch-interval", 30*time.Second, "for watch: interval between birdc configure")
|
||||||
|
listen := flag.String("listen", "", "for serve: listen address (default :8443 or EVOBGP_AGENT_LISTEN)")
|
||||||
flag.Usage = func() {
|
flag.Usage = func() {
|
||||||
fmt.Fprintf(os.Stderr, "Usage: %s [flags] <command>\n", os.Args[0])
|
fmt.Fprintf(os.Stderr, "Usage: %s [flags] <command>\n", os.Args[0])
|
||||||
fmt.Fprintf(os.Stderr, "Commands:\n")
|
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, " 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, " configure run birdc configure (reload running BIRD)\n")
|
||||||
fmt.Fprintf(os.Stderr, " watch periodically run birdc configure (compose sidecar)\n")
|
fmt.Fprintf(os.Stderr, " watch periodically run birdc configure (compose sidecar)\n")
|
||||||
|
fmt.Fprintf(os.Stderr, " serve Panel→Node HTTP API (POST /v1/agent/sync)\n")
|
||||||
flag.PrintDefaults()
|
flag.PrintDefaults()
|
||||||
}
|
}
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
@@ -40,9 +44,21 @@ func main() {
|
|||||||
ctl.Birdc = *birdc
|
ctl.Birdc = *birdc
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
|
switch args[0] {
|
||||||
defer cancel()
|
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] {
|
switch args[0] {
|
||||||
case "parse-check":
|
case "parse-check":
|
||||||
if len(args) != 2 {
|
if len(args) != 2 {
|
||||||
@@ -63,23 +79,51 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
case "watch":
|
case "watch":
|
||||||
if *watchEvery <= 0 {
|
if watchEvery <= 0 {
|
||||||
fmt.Fprintln(os.Stderr, "watch-interval must be > 0")
|
fmt.Fprintln(os.Stderr, "watch-interval must be > 0")
|
||||||
os.Exit(2)
|
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 {
|
for {
|
||||||
cctx, cancel := context.WithTimeout(context.Background(), *timeout)
|
cctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
err := ctl.Configure(cctx)
|
err := ctl.Configure(cctx)
|
||||||
cancel()
|
cancel()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("evobgp-agent watch: configure: %v", err)
|
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)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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=[email protected]
|
||||||
|
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
|
||||||
@@ -20,6 +20,7 @@
|
|||||||
| [api.md](api.md) | REST: префикс `/v1`, публичные маршруты, ссылки на OpenAPI |
|
| [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`) |
|
| [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 |
|
| [access.md](access.md) | Выдача доступа: API-ключи, роли, нода, CORS |
|
||||||
|
| [remote-speakers.md](remote-speakers.md) | Удалённые BGP-реплики: Traefik, agent sync, compose |
|
||||||
| [releasing.md](releasing.md) | Автоматические релизы, Conventional Commits, CI |
|
| [releasing.md](releasing.md) | Автоматические релизы, Conventional Commits, CI |
|
||||||
| [openapi.yaml](openapi.yaml) | Источник правды по контракту API |
|
| [openapi.yaml](openapi.yaml) | Источник правды по контракту API |
|
||||||
| [OPENAPI-GITEA.md](OPENAPI-GITEA.md) | Как открыть HTML-документацию API (в т.ч. из Gitea) |
|
| [OPENAPI-GITEA.md](OPENAPI-GITEA.md) | Как открыть HTML-документацию API (в т.ч. из Gitea) |
|
||||||
|
|||||||
+14
-1
@@ -63,7 +63,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
|
```text
|
||||||
evobgp-node verify-bundle -f bundle.tar.gz -pubkey-base64 "<из_лога_API>"
|
evobgp-node verify-bundle -f bundle.tar.gz -pubkey-base64 "<из_лога_API>"
|
||||||
@@ -76,6 +78,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>"
|
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 для веб-интерфейса
|
## CORS для веб-интерфейса
|
||||||
|
|
||||||
Браузерные запросы с другого origin требуют заголовков CORS на API. Задайте список разрешённых origin через **`EVOBGP_CORS_ORIGINS`** (через запятую), например:
|
Браузерные запросы с другого origin требуют заголовков CORS на API. Задайте список разрешённых origin через **`EVOBGP_CORS_ORIGINS`** (через запятую), например:
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
| `evobgp-render` | По умолчанию только heartbeat; при `EVOBGP_RENDER_AUTOPUBLISH=1` выставляет всем спикерам tenant последнюю ревизию (упрощение для демо). |
|
| `evobgp-render` | По умолчанию только heartbeat; при `EVOBGP_RENDER_AUTOPUBLISH=1` выставляет всем спикерам tenant последнюю ревизию (упрощение для демо). |
|
||||||
| `evobgp-deploy` | Периодически логирует **drift**: `last_applied_revision_id` vs опубликованная ревизия для ноды. |
|
| `evobgp-deploy` | Периодически логирует **drift**: `last_applied_revision_id` vs опубликованная ревизия для ноды. |
|
||||||
| `evobgp-node` | CLI реплики: `pull-bundle`, `verify-bundle`, `apply-bundle`. |
|
| `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`.
|
В Docker Compose профиль **reference** запускает отдельные контейнеры под `evobgp-api` и четыре воркера; профиль **microvps** использует один контейнер `evobgp-all`.
|
||||||
|
|
||||||
@@ -40,8 +40,12 @@
|
|||||||
| `observability` | Метрики Prometheus, HTTP middleware. |
|
| `observability` | Метрики Prometheus, HTTP middleware. |
|
||||||
| `broker` | Опциональный `EVOBGP_BROKER_URL` для будущей шины; сейчас задачи только in-process (`jobs.Registry`), пакет лишь логирует факт настройки URL. |
|
| `broker` | Опциональный `EVOBGP_BROKER_URL` для будущей шины; сейчас задачи только in-process (`jobs.Registry`), пакет лишь логирует факт настройки URL. |
|
||||||
| `pipeline` | Ingest+render в одном шаге для `module_refresh`: выборка префиксов (CDN/AS/IP/пустые DOMAINS), `CreateRenderRevision`, превью BIRD через `birdfmt`. |
|
| `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
|
```mermaid
|
||||||
flowchart LR
|
flowchart LR
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ EvoBGP управляет генерацией и применением BGP-к
|
|||||||
### Настройки (`/v1/settings`)
|
### Настройки (`/v1/settings`)
|
||||||
- KV c ключами BIRD и дополнительными feature flags.
|
- 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`.
|
- Ключевые параметры 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
|
## 7. Эксплуатация и runbook
|
||||||
|
|
||||||
|
|||||||
@@ -755,6 +755,42 @@ components:
|
|||||||
bgp_speaker_id:
|
bgp_speaker_id:
|
||||||
type: ["string", "null"]
|
type: ["string", "null"]
|
||||||
description: "`null` - политика для всех спикеров."
|
description: "`null` - политика для всех спикеров."
|
||||||
|
connected_speaker_id:
|
||||||
|
type: ["string", "null"]
|
||||||
|
description: >
|
||||||
|
Live (GET /v1/peers?live=1): спикер, на котором сессия Established; опрос CP birdc + agent /v1/agent/bird/protocols.
|
||||||
|
connected_speaker_label:
|
||||||
|
type: string
|
||||||
|
description: Человекочитаемая метка ноды из live-опроса.
|
||||||
|
session_on_speakers:
|
||||||
|
type: array
|
||||||
|
description: Состояние протокола пира на каждой опрошенной ноде.
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
speaker_id:
|
||||||
|
type: string
|
||||||
|
label:
|
||||||
|
type: string
|
||||||
|
state:
|
||||||
|
type: string
|
||||||
|
established_on_speakers:
|
||||||
|
type: array
|
||||||
|
description: Ноды, где сессия в состоянии Established (один пир может быть на нескольких).
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
speaker_id:
|
||||||
|
type: string
|
||||||
|
label:
|
||||||
|
type: string
|
||||||
|
state:
|
||||||
|
type: string
|
||||||
|
session_mismatch:
|
||||||
|
type: boolean
|
||||||
|
description: >
|
||||||
|
true если bgp_speaker_id задан, но на этой ноде нет Established
|
||||||
|
(сессия может быть на других нодах — это не ошибка для tenant-wide пиров).
|
||||||
policies_json:
|
policies_json:
|
||||||
type: string
|
type: string
|
||||||
description: >
|
description: >
|
||||||
@@ -778,8 +814,130 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
last_applied_revision_id:
|
last_applied_revision_id:
|
||||||
type: ["string", "null"]
|
type: ["string", "null"]
|
||||||
|
published_revision_id:
|
||||||
|
type: ["string", "null"]
|
||||||
|
description: Последняя опубликованная на CP ревизия для этого спикера.
|
||||||
|
published_at:
|
||||||
|
type: ["string", "null"]
|
||||||
|
format: date-time
|
||||||
|
agent_domain:
|
||||||
|
type: string
|
||||||
|
description: FQDN agent API за Traefik (Address в UI, Remnawave-style).
|
||||||
|
node_ipv4:
|
||||||
|
type: string
|
||||||
|
description: IPv4 VPS; default для bird_bgp_source_ipv4.
|
||||||
|
bird_bgp_source_ipv4:
|
||||||
|
type: string
|
||||||
|
description: Per-speaker override router id / BGP local (см. pipeline overlay).
|
||||||
|
dispatch_status:
|
||||||
|
type: string
|
||||||
|
description: ok, error, skipped — последний Panel→Node wake-up.
|
||||||
|
sync_status:
|
||||||
|
type: string
|
||||||
|
description: synced, error — состояние sync на реплике.
|
||||||
|
last_dispatch_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
last_dispatch_error:
|
||||||
|
type: string
|
||||||
|
meta_json:
|
||||||
|
type: object
|
||||||
|
description: >
|
||||||
|
Расширяемый объект. Ключи agent_domain, agent_secret (только при создании),
|
||||||
|
agent_port, node_ipv4, bird_bgp_source_ipv4, bird_bgp_source_ipv6.
|
||||||
|
live:
|
||||||
|
$ref: "#/components/schemas/SpeakerLiveStatus"
|
||||||
|
description: >
|
||||||
|
При GET /v1/speakers?live=1 — runtime-статус agent и BGP-опроса на ноде.
|
||||||
additionalProperties: true
|
additionalProperties: true
|
||||||
|
|
||||||
|
SpeakerLiveStatus:
|
||||||
|
type: object
|
||||||
|
description: Live runtime snapshot for one speaker (GET /v1/speakers?live=1).
|
||||||
|
properties:
|
||||||
|
label:
|
||||||
|
type: string
|
||||||
|
description: Человекочитаемая метка ноды (agent domain или CP master).
|
||||||
|
agent_ok:
|
||||||
|
type: boolean
|
||||||
|
description: true если agent /v1/agent/health успешен (master — local birdc poll).
|
||||||
|
agent_error:
|
||||||
|
type: string
|
||||||
|
agent_last_sync_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
agent_last_applied_revision_id:
|
||||||
|
type: string
|
||||||
|
bgp_poll_ok:
|
||||||
|
type: boolean
|
||||||
|
description: true если birdc (CP) или GET /v1/agent/bird/protocols (replica) успешен.
|
||||||
|
bgp_poll_error:
|
||||||
|
type: string
|
||||||
|
bgp_sessions_total:
|
||||||
|
type: integer
|
||||||
|
bgp_established:
|
||||||
|
type: integer
|
||||||
|
sessions:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/BgpSessionLive"
|
||||||
|
additionalProperties: true
|
||||||
|
|
||||||
|
BgpSessionLive:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
neighbor:
|
||||||
|
type: string
|
||||||
|
state:
|
||||||
|
type: string
|
||||||
|
additionalProperties: true
|
||||||
|
|
||||||
|
LiveSpeakerPoll:
|
||||||
|
type: object
|
||||||
|
description: Метаданные опроса одной ноды в GET /v1/peers?live=1.
|
||||||
|
properties:
|
||||||
|
speaker_id:
|
||||||
|
type: string
|
||||||
|
label:
|
||||||
|
type: string
|
||||||
|
ok:
|
||||||
|
type: boolean
|
||||||
|
session_count:
|
||||||
|
type: integer
|
||||||
|
poll_error:
|
||||||
|
type: string
|
||||||
|
additionalProperties: true
|
||||||
|
|
||||||
|
BirdLocalStatus:
|
||||||
|
type: object
|
||||||
|
description: Статус локального BIRD на хосте API (GET /v1/bird/status).
|
||||||
|
properties:
|
||||||
|
birdc_configured:
|
||||||
|
type: boolean
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
error:
|
||||||
|
type: string
|
||||||
|
protocols_excerpt:
|
||||||
|
type: string
|
||||||
|
bgp_sessions_total:
|
||||||
|
type: integer
|
||||||
|
bgp_established:
|
||||||
|
type: integer
|
||||||
|
healthy:
|
||||||
|
type: ["boolean", "null"]
|
||||||
|
additionalProperties: true
|
||||||
|
|
||||||
|
BundleSigningPublicKey:
|
||||||
|
type: object
|
||||||
|
required: [public_key_base64]
|
||||||
|
properties:
|
||||||
|
public_key_base64:
|
||||||
|
type: string
|
||||||
|
description: Ed25519 public key (base64) для verify-bundle на реплике.
|
||||||
|
|
||||||
ConfigRevision:
|
ConfigRevision:
|
||||||
type: object
|
type: object
|
||||||
required:
|
required:
|
||||||
@@ -1000,8 +1158,15 @@ components:
|
|||||||
properties:
|
properties:
|
||||||
role:
|
role:
|
||||||
type: string
|
type: string
|
||||||
|
default: replica
|
||||||
endpoint:
|
endpoint:
|
||||||
type: string
|
type: string
|
||||||
|
description: URL agent или https://AGENT_DOMAIN
|
||||||
|
meta_json:
|
||||||
|
type: string
|
||||||
|
description: >
|
||||||
|
JSON-объект. Ключи node_ipv4, bird_bgp_source_ipv4 (default = node_ipv4),
|
||||||
|
agent_domain, agent_secret (генерируется при создании если пуст).
|
||||||
additionalProperties: true
|
additionalProperties: true
|
||||||
|
|
||||||
BgpSpeakerPatch:
|
BgpSpeakerPatch:
|
||||||
@@ -1011,6 +1176,9 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
endpoint:
|
endpoint:
|
||||||
type: string
|
type: string
|
||||||
|
meta_json:
|
||||||
|
type: string
|
||||||
|
description: JSON-объект с ключами agent_domain, node_ipv4, bird_bgp_source_ipv4 и др.
|
||||||
additionalProperties: true
|
additionalProperties: true
|
||||||
|
|
||||||
LatestRevisionPointer:
|
LatestRevisionPointer:
|
||||||
@@ -2107,6 +2275,13 @@ paths:
|
|||||||
- $ref: "#/components/parameters/Cursor"
|
- $ref: "#/components/parameters/Cursor"
|
||||||
- $ref: "#/components/parameters/Limit"
|
- $ref: "#/components/parameters/Limit"
|
||||||
- $ref: "#/components/parameters/SpeakerFilter"
|
- $ref: "#/components/parameters/SpeakerFilter"
|
||||||
|
- name: live
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
enum: ["1"]
|
||||||
|
description: >
|
||||||
|
Опрос birdc на CP и GET /v1/agent/bird/protocols на репликах; обогащает session_state и connected_speaker_*.
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: Успешно.
|
description: Успешно.
|
||||||
@@ -2124,6 +2299,12 @@ paths:
|
|||||||
type: ["string", "null"]
|
type: ["string", "null"]
|
||||||
has_more:
|
has_more:
|
||||||
type: boolean
|
type: boolean
|
||||||
|
live_speaker_poll:
|
||||||
|
type: array
|
||||||
|
description: >
|
||||||
|
При live=1 — результат опроса каждой ноды (CP birdc + agent protocols).
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/LiveSpeakerPoll"
|
||||||
default:
|
default:
|
||||||
$ref: "#/components/responses/DefaultProblem"
|
$ref: "#/components/responses/DefaultProblem"
|
||||||
post:
|
post:
|
||||||
@@ -2216,6 +2397,24 @@ paths:
|
|||||||
default:
|
default:
|
||||||
$ref: "#/components/responses/DefaultProblem"
|
$ref: "#/components/responses/DefaultProblem"
|
||||||
|
|
||||||
|
/v1/bundle/signing-public-key:
|
||||||
|
get:
|
||||||
|
tags: [Bundles]
|
||||||
|
summary: Публичный ключ подписи бандлов
|
||||||
|
description: >
|
||||||
|
Ed25519 public key (base64) для `evobgp-node verify-bundle` / agent sync на реплике.
|
||||||
|
Роль viewer и выше.
|
||||||
|
operationId: getBundleSigningPublicKey
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Ключ для env EVOBGP_BUNDLE_PUBKEY_BASE64 на реплике.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/BundleSigningPublicKey"
|
||||||
|
default:
|
||||||
|
$ref: "#/components/responses/DefaultProblem"
|
||||||
|
|
||||||
/v1/speakers:
|
/v1/speakers:
|
||||||
get:
|
get:
|
||||||
tags: [Speakers]
|
tags: [Speakers]
|
||||||
@@ -2226,6 +2425,14 @@ paths:
|
|||||||
- $ref: "#/components/parameters/TenantId"
|
- $ref: "#/components/parameters/TenantId"
|
||||||
- $ref: "#/components/parameters/Cursor"
|
- $ref: "#/components/parameters/Cursor"
|
||||||
- $ref: "#/components/parameters/Limit"
|
- $ref: "#/components/parameters/Limit"
|
||||||
|
- name: live
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
enum: ["1"]
|
||||||
|
description: >
|
||||||
|
Live-опрос agent /v1/agent/health и BGP protocols на репликах; CP — local birdc.
|
||||||
|
Обогащает каждый item полем `live`.
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: Успешно.
|
description: Успешно.
|
||||||
@@ -2312,6 +2519,21 @@ paths:
|
|||||||
$ref: "#/components/responses/NotFound"
|
$ref: "#/components/responses/NotFound"
|
||||||
default:
|
default:
|
||||||
$ref: "#/components/responses/DefaultProblem"
|
$ref: "#/components/responses/DefaultProblem"
|
||||||
|
delete:
|
||||||
|
tags: [Speakers]
|
||||||
|
summary: Удалить спикер
|
||||||
|
description: >
|
||||||
|
Удаляет BGP-спикер. Пиры с `bgp_speaker_id` этого спикера остаются, привязка сбрасывается (ON DELETE SET NULL).
|
||||||
|
operationId: deleteSpeaker
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/IdempotencyKey"
|
||||||
|
responses:
|
||||||
|
"204":
|
||||||
|
description: Удалено.
|
||||||
|
"404":
|
||||||
|
$ref: "#/components/responses/NotFound"
|
||||||
|
default:
|
||||||
|
$ref: "#/components/responses/DefaultProblem"
|
||||||
|
|
||||||
/v1/revisions:
|
/v1/revisions:
|
||||||
get:
|
get:
|
||||||
@@ -2545,6 +2767,26 @@ paths:
|
|||||||
default:
|
default:
|
||||||
$ref: "#/components/responses/DefaultProblem"
|
$ref: "#/components/responses/DefaultProblem"
|
||||||
|
|
||||||
|
/v1/bird/status:
|
||||||
|
get:
|
||||||
|
tags: [Deploy]
|
||||||
|
summary: Статус локального BIRD на хосте API
|
||||||
|
description: >
|
||||||
|
Опрос birdc через EVOBGP_BIRDC_SOCKET на процессе API (обычно CP master).
|
||||||
|
На репликах без birdc на CP — birdc_configured=false.
|
||||||
|
operationId: getBirdStatus
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/TenantId"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Успешно.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/BirdLocalStatus"
|
||||||
|
default:
|
||||||
|
$ref: "#/components/responses/DefaultProblem"
|
||||||
|
|
||||||
/v1/bird/reload:
|
/v1/bird/reload:
|
||||||
post:
|
post:
|
||||||
tags: [Deploy]
|
tags: [Deploy]
|
||||||
|
|||||||
@@ -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.
|
В **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)
|
## Вариант 3: Локально без Docker (только API)
|
||||||
|
|
||||||
1. Поднимите PostgreSQL и создайте БД (или используйте существующую).
|
1. Поднимите PostgreSQL и создайте БД (или используйте существующую).
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ EvoBGP использует [Conventional Commits](https://www.conventionalcommi
|
|||||||
| `feat!`, `fix!` или `BREAKING CHANGE:` в теле | major (1.0.0 → 2.0.0) |
|
| `feat!`, `fix!` или `BREAKING CHANGE:` в теле | major (1.0.0 → 2.0.0) |
|
||||||
| `docs`, `chore`, `test` | без релиза |
|
| `docs`, `chore`, `test` | без релиза |
|
||||||
|
|
||||||
|
**Scope:** один идентификатор **без запятых** (`web`, `httpapi`, `api`). Заголовок `refactor(a, b): …` **не парсится** semantic-release → релиз не создаётся (commitlint на PR это тоже отклонит). Подробнее — раздел «Scope и semantic-release» ниже.
|
||||||
|
|
||||||
`refactor` — patch без новых функций: перестройка кода/UI при том же поведении для пользователя. По semver на одном уровне с `fix`, но семантически «мельче» `feat` (не minor).
|
`refactor` — patch без новых функций: перестройка кода/UI при том же поведении для пользователя. По semver на одном уровне с `fix`, но семантически «мельче» `feat` (не minor).
|
||||||
|
|
||||||
Отдельного суффикса `1.x.y.fix` в semver нет: «fix» в Conventional Commits означает **patch** (третья цифра). Для починки пайплайна без смены продукта — `fix(ci):` или `ci:` (оба дают patch после настройки `.releaserc.json`).
|
Отдельного суффикса `1.x.y.fix` в semver нет: «fix» в Conventional Commits означает **patch** (третья цифра). Для починки пайплайна без смены продукта — `fix(ci):` или `ci:` (оба дают patch после настройки `.releaserc.json`).
|
||||||
@@ -62,6 +64,21 @@ API: `GET /version`, `GET /v1/version` — поля `version`, `git_sha`, `build
|
|||||||
|
|
||||||
Web UI показывает версию из API (footer sidebar, страница «Мониторинг»).
|
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
|
## CHANGELOG
|
||||||
|
|
||||||
Release notes — в Gitea Release; файл `CHANGELOG.md` генерируется в CI и прикрепляется как asset, **не** попадает в git history.
|
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 на реплике |
|
||||||
@@ -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"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"evobgp/internal/httpclient"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DefaultRIPEStatURL is the RIPEstat announced-prefixes data call (no API key).
|
// 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).
|
// 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) {
|
func AnnouncedPrefixes(ctx context.Context, hc *http.Client, asn int64) ([]netip.Prefix, error) {
|
||||||
if hc == nil {
|
if hc == nil {
|
||||||
hc = http.DefaultClient
|
hc = httpclient.New(httpclient.DefaultTimeout)
|
||||||
}
|
}
|
||||||
base := strings.TrimSpace(os.Getenv("EVOBGP_RIPESTAT_ANNOUNCED_PREFIXES_URL"))
|
base := strings.TrimSpace(os.Getenv("EVOBGP_RIPESTAT_ANNOUNCED_PREFIXES_URL"))
|
||||||
if base == "" {
|
if base == "" {
|
||||||
@@ -38,7 +40,7 @@ func AnnouncedPrefixes(ctx context.Context, hc *http.Client, asn int64) ([]netip
|
|||||||
req.Header.Set("Accept", "application/json")
|
req.Header.Set("Accept", "application/json")
|
||||||
req.Header.Set("User-Agent", "evobgp-asnresolve/1.0")
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("ripestat fetch AS%d: %w", asn, err)
|
return nil, fmt.Errorf("ripestat fetch AS%d: %w", asn, err)
|
||||||
}
|
}
|
||||||
@@ -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).
|
// 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) {
|
func ASHolderName(ctx context.Context, hc *http.Client, asn int64) (string, error) {
|
||||||
if hc == nil {
|
if hc == nil {
|
||||||
hc = http.DefaultClient
|
hc = httpclient.New(httpclient.DefaultTimeout)
|
||||||
}
|
}
|
||||||
base := strings.TrimSpace(os.Getenv("EVOBGP_RIPESTAT_AS_OVERVIEW_URL"))
|
base := strings.TrimSpace(os.Getenv("EVOBGP_RIPESTAT_AS_OVERVIEW_URL"))
|
||||||
if base == "" {
|
if base == "" {
|
||||||
@@ -100,7 +102,7 @@ func ASHolderName(ctx context.Context, hc *http.Client, asn int64) (string, erro
|
|||||||
req.Header.Set("Accept", "application/json")
|
req.Header.Set("Accept", "application/json")
|
||||||
req.Header.Set("User-Agent", "evobgp-asnresolve/1.0")
|
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 {
|
if err != nil {
|
||||||
return "", fmt.Errorf("ripestat as-overview AS%d: %w", asn, err)
|
return "", fmt.Errorf("ripestat as-overview AS%d: %w", asn, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"evobgp/internal/db"
|
"evobgp/internal/db"
|
||||||
|
"evobgp/internal/httpclient"
|
||||||
"evobgp/internal/jobs"
|
"evobgp/internal/jobs"
|
||||||
"evobgp/internal/observability"
|
"evobgp/internal/observability"
|
||||||
"evobgp/internal/repository"
|
"evobgp/internal/repository"
|
||||||
@@ -17,7 +18,7 @@ import (
|
|||||||
|
|
||||||
// NewCDNHTTPClient returns the shared HTTP client for CDN and preview fetches (PERF-02 / ERR-03).
|
// NewCDNHTTPClient returns the shared HTTP client for CDN and preview fetches (PERF-02 / ERR-03).
|
||||||
func NewCDNHTTPClient() *http.Client {
|
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).
|
// BootstrapWorkers opens the same store.Backend and jobs.Registry as New (without HTTP or bundle keys).
|
||||||
@@ -55,18 +56,40 @@ func BootstrapWorkers(ctx context.Context, opts Options) (store.Backend, *jobs.R
|
|||||||
wk.Registry = reg
|
wk.Registry = reg
|
||||||
if pool != nil {
|
if pool != nil {
|
||||||
audit := repository.NewJobAuditWriter(pool)
|
audit := repository.NewJobAuditWriter(pool)
|
||||||
reg.SetTerminalHook(func(j *jobs.Job) {
|
jobMeta := func(j *jobs.Job) map[string]any {
|
||||||
if j == nil {
|
if j == nil {
|
||||||
return
|
return nil
|
||||||
}
|
}
|
||||||
st := j.Snapshot()
|
st := j.Snapshot()
|
||||||
status, _ := st["status"].(string)
|
meta, _ := st["meta"].(map[string]any)
|
||||||
var errMsg *string
|
return meta
|
||||||
if e, ok := st["error"].(string); ok && e != "" {
|
}
|
||||||
errMsg = &e
|
reg.SetPersistHooks(
|
||||||
}
|
func(j *jobs.Job) {
|
||||||
audit.MarkTerminal(context.Background(), j.TenantID, j.ID, status, errMsg, time.Now().UTC())
|
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)
|
observability.RegisterStoreBackend(backend)
|
||||||
return backend, reg, pool, nil
|
return backend, reg, pool, nil
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+33
-28
@@ -55,6 +55,7 @@ func (s *Server) registerV1(m *http.ServeMux) {
|
|||||||
m.HandleFunc("GET /modules/{module_id}", s.handleGetModule)
|
m.HandleFunc("GET /modules/{module_id}", s.handleGetModule)
|
||||||
m.HandleFunc("GET /peers", s.handleListPeers)
|
m.HandleFunc("GET /peers", s.handleListPeers)
|
||||||
m.HandleFunc("GET /speakers", s.handleListSpeakers)
|
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 /modules/{module_id}/refresh", s.handleModuleRefresh)
|
||||||
m.HandleFunc("POST /tenant/refresh", s.handleTenantRefresh)
|
m.HandleFunc("POST /tenant/refresh", s.handleTenantRefresh)
|
||||||
m.HandleFunc("GET /revisions", s.handleListRevisions)
|
m.HandleFunc("GET /revisions", s.handleListRevisions)
|
||||||
@@ -167,17 +168,7 @@ func peerJSON(p *store.BGPPeer) map[string]any {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func speakerJSON(sp *store.Speaker) map[string]any {
|
func speakerJSON(sp *store.Speaker) map[string]any {
|
||||||
m := map[string]any{
|
return speakerJSONFromStore(nil, sp)
|
||||||
"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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleListModules(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleListModules(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -292,17 +283,21 @@ func (s *Server) handleListPeers(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
allPeers := s.store.ListPeers(a.TenantID)
|
allPeers := s.store.ListPeers(a.TenantID)
|
||||||
page, next, more := store.PaginateOffset(allPeers, r.URL.Query().Get("cursor"), parseListLimit(r))
|
page, next, more := store.PaginateOffset(allPeers, r.URL.Query().Get("cursor"), parseListLimit(r))
|
||||||
liveStates := s.liveBGPProtocolStates(r)
|
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))
|
items := make([]map[string]any, 0, len(page))
|
||||||
for _, p := range page {
|
for _, p := range page {
|
||||||
row := peerJSON(p)
|
row := peerJSON(p)
|
||||||
if st, ok := liveStates[peerProtocolNameForID(p.ID)]; ok && strings.TrimSpace(st) != "" {
|
applyPeerLiveFields(row, p, liveViews)
|
||||||
row["session_state"] = strings.TrimSpace(st)
|
|
||||||
}
|
|
||||||
items = append(items, row)
|
items = append(items, row)
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
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),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -375,16 +370,9 @@ func extractBGPSessionState(line string) string {
|
|||||||
return ""
|
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 {
|
func peerProtocolNameForID(peerID string) string {
|
||||||
s := strings.ReplaceAll(strings.TrimSpace(peerID), "-", "")
|
return birdfmt.PeerProtocolName(peerID)
|
||||||
if len(s) > 16 {
|
|
||||||
s = s[:16]
|
|
||||||
}
|
|
||||||
if s == "" {
|
|
||||||
s = "x"
|
|
||||||
}
|
|
||||||
return "evobgp_p_" + s
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleListSpeakers(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleListSpeakers(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -397,9 +385,22 @@ func (s *Server) handleListSpeakers(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
speakers := s.store.ListSpeakersForTenant(a.TenantID)
|
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))
|
items := make([]map[string]any, 0, len(speakers))
|
||||||
for _, sp := range 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{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
"items": items, "next_cursor": nil, "has_more": false,
|
"items": items, "next_cursor": nil, "has_more": false,
|
||||||
@@ -578,7 +579,7 @@ func (s *Server) handleGetRevision(w http.ResponseWriter, r *http.Request) {
|
|||||||
if !s.requireAtLeast(w, a, "viewer") {
|
if !s.requireAtLeast(w, a, "viewer") {
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
writeProblem(w, http.StatusNotFound, "Not Found", "revision not found")
|
writeProblem(w, http.StatusNotFound, "Not Found", "revision not found")
|
||||||
return
|
return
|
||||||
@@ -985,7 +986,11 @@ func (s *Server) handleNodeBundle(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeProblem(w, http.StatusNotFound, "Not Found", "revision not found")
|
writeProblem(w, http.StatusNotFound, "Not Found", "revision not found")
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
writeInternalError(w, "internal", err)
|
writeInternalError(w, "internal", err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ func (s *Server) registerCRUDRoutes(m *http.ServeMux) {
|
|||||||
m.HandleFunc("POST /speakers", s.handlePostSpeaker)
|
m.HandleFunc("POST /speakers", s.handlePostSpeaker)
|
||||||
m.HandleFunc("GET /speakers/{speaker_id}", s.handleGetSpeakerByID)
|
m.HandleFunc("GET /speakers/{speaker_id}", s.handleGetSpeakerByID)
|
||||||
m.HandleFunc("PATCH /speakers/{speaker_id}", s.handlePatchSpeaker)
|
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 /revisions/{revision_id}/prefixes", s.handleRevisionPrefixes)
|
||||||
|
|
||||||
@@ -241,6 +242,14 @@ func (s *Server) handlePreviewCDNSource(w http.ResponseWriter, r *http.Request)
|
|||||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "url is required")
|
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "url is required")
|
||||||
return
|
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"))
|
mod, err := s.store.GetModule(a.TenantID, r.PathValue("module_id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeStoreErr(w, err)
|
writeStoreErr(w, err)
|
||||||
@@ -303,6 +312,16 @@ func (s *Server) handlePostCDNSource(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
|
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
|
||||||
return
|
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")
|
mid := r.PathValue("module_id")
|
||||||
x, err := s.store.CreateCDNSource(a.TenantID, mid, &body)
|
x, err := s.store.CreateCDNSource(a.TenantID, mid, &body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -323,6 +342,16 @@ func (s *Server) handlePatchCDNSource(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
|
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
|
||||||
return
|
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")
|
mid := r.PathValue("module_id")
|
||||||
x, err := s.store.UpdateCDNSource(a.TenantID, mid, r.PathValue("source_id"), &body)
|
x, err := s.store.UpdateCDNSource(a.TenantID, mid, r.PathValue("source_id"), &body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -974,12 +1003,20 @@ func (s *Server) handlePostSpeaker(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
|
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if err := normalizeSpeakerCreate(&body); err != nil {
|
||||||
|
writeStoreErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
x, err := s.store.CreateSpeaker(a.TenantID, &body)
|
x, err := s.store.CreateSpeaker(a.TenantID, &body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeStoreErr(w, err)
|
writeStoreErr(w, err)
|
||||||
return
|
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) {
|
func (s *Server) handleGetSpeakerByID(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -992,7 +1029,7 @@ func (s *Server) handleGetSpeakerByID(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeStoreErr(w, err)
|
writeStoreErr(w, err)
|
||||||
return
|
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) {
|
func (s *Server) handlePatchSpeaker(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -1010,7 +1047,19 @@ func (s *Server) handlePatchSpeaker(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeStoreErr(w, err)
|
writeStoreErr(w, err)
|
||||||
return
|
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) {
|
func (s *Server) handleRevisionPrefixes(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -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,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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"evobgp/internal/broker"
|
"evobgp/internal/broker"
|
||||||
"evobgp/internal/config"
|
"evobgp/internal/config"
|
||||||
|
"evobgp/internal/httpclient"
|
||||||
"evobgp/internal/pipeline"
|
"evobgp/internal/pipeline"
|
||||||
"evobgp/internal/store"
|
"evobgp/internal/store"
|
||||||
)
|
)
|
||||||
@@ -24,7 +24,7 @@ func Run(ctx context.Context, deps *Deps) {
|
|||||||
if deps == nil || deps.Store == nil {
|
if deps == nil || deps.Store == nil {
|
||||||
log.Fatalf("evobgp-ingest: missing store (pass ingest.Deps from BootstrapWorkers or evobgp-all)")
|
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)
|
t := time.NewTicker(60 * time.Second)
|
||||||
defer t.Stop()
|
defer t.Stop()
|
||||||
log.Printf("evobgp-ingest: active (CDN conditional GET / ETag prefetch)")
|
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")
|
log.Printf("evobgp-ingest: stopped")
|
||||||
return
|
return
|
||||||
case <-t.C:
|
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)
|
log.Printf("evobgp-ingest: prefetch: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+49
-4
@@ -182,6 +182,8 @@ type Registry struct {
|
|||||||
workerStart func(j *Job)
|
workerStart func(j *Job)
|
||||||
workerSem chan struct{}
|
workerSem chan struct{}
|
||||||
onTerminal func(j *Job)
|
onTerminal func(j *Job)
|
||||||
|
onEnqueued func(j *Job)
|
||||||
|
onRunning func(j *Job)
|
||||||
}
|
}
|
||||||
|
|
||||||
type idempoKey struct {
|
type idempoKey struct {
|
||||||
@@ -209,6 +211,44 @@ func (r *Registry) SetTerminalHook(fn func(j *Job)) {
|
|||||||
r.onTerminal = fn
|
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) {
|
func (r *Registry) fireTerminal(j *Job) {
|
||||||
if r == nil || j == nil {
|
if r == nil || j == nil {
|
||||||
return
|
return
|
||||||
@@ -271,8 +311,6 @@ func (r *Registry) pruneTerminalIfOver(maxJobs int) {
|
|||||||
// Enqueue creates a job or returns an existing one for the same idempotency key.
|
// 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) {
|
func (r *Registry) Enqueue(tenantID, kind string, idempotencyKey *string, moduleID *string, meta map[string]any) (*Job, bool, error) {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
defer r.mu.Unlock()
|
|
||||||
|
|
||||||
maxJobs := registryMaxJobsFromEnv()
|
maxJobs := registryMaxJobsFromEnv()
|
||||||
r.pruneTerminalIfOver(maxJobs)
|
r.pruneTerminalIfOver(maxJobs)
|
||||||
|
|
||||||
@@ -281,6 +319,7 @@ func (r *Registry) Enqueue(tenantID, kind string, idempotencyKey *string, module
|
|||||||
if existing, ok := r.byIdempo[k]; ok {
|
if existing, ok := r.byIdempo[k]; ok {
|
||||||
st := existing.statusLocked()
|
st := existing.statusLocked()
|
||||||
if st == StatusQueued || st == StatusRunning {
|
if st == StatusQueued || st == StatusRunning {
|
||||||
|
r.mu.Unlock()
|
||||||
return existing, false, nil
|
return existing, false, nil
|
||||||
}
|
}
|
||||||
delete(r.byIdempo, k)
|
delete(r.byIdempo, k)
|
||||||
@@ -302,8 +341,14 @@ func (r *Registry) Enqueue(tenantID, kind string, idempotencyKey *string, module
|
|||||||
}
|
}
|
||||||
r.byID[j.ID] = j
|
r.byID[j.ID] = j
|
||||||
r.pruneTerminalIfOver(maxJobs)
|
r.pruneTerminalIfOver(maxJobs)
|
||||||
|
enqueuedHook := r.onEnqueued
|
||||||
|
workerStart := r.workerStart
|
||||||
|
r.mu.Unlock()
|
||||||
|
|
||||||
if r.workerStart != nil {
|
if enqueuedHook != nil {
|
||||||
|
enqueuedHook(j)
|
||||||
|
}
|
||||||
|
if workerStart != nil {
|
||||||
go func() {
|
go func() {
|
||||||
r.workerSem <- struct{}{}
|
r.workerSem <- struct{}{}
|
||||||
active := len(r.workerSem)
|
active := len(r.workerSem)
|
||||||
@@ -313,7 +358,7 @@ func (r *Registry) Enqueue(tenantID, kind string, idempotencyKey *string, module
|
|||||||
<-r.workerSem
|
<-r.workerSem
|
||||||
observability.RecordJobQueueDepth(len(r.workerSem), capacity)
|
observability.RecordJobQueueDepth(len(r.workerSem), capacity)
|
||||||
}()
|
}()
|
||||||
r.workerStart(j)
|
workerStart(j)
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
return j, true, nil
|
return j, true, nil
|
||||||
|
|||||||
+55
-4
@@ -13,6 +13,8 @@ import (
|
|||||||
|
|
||||||
"evobgp/internal/birddeploy"
|
"evobgp/internal/birddeploy"
|
||||||
"evobgp/internal/birdfmt"
|
"evobgp/internal/birdfmt"
|
||||||
|
"evobgp/internal/httpclient"
|
||||||
|
"evobgp/internal/nodedispatch"
|
||||||
"evobgp/internal/observability"
|
"evobgp/internal/observability"
|
||||||
"evobgp/internal/pipeline"
|
"evobgp/internal/pipeline"
|
||||||
"evobgp/internal/store"
|
"evobgp/internal/store"
|
||||||
@@ -69,7 +71,7 @@ type revisionLogEntry struct {
|
|||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var defaultWorkerHTTP = &http.Client{Timeout: 45 * time.Second}
|
var defaultWorkerHTTP = httpclient.New(httpclient.DefaultTimeout)
|
||||||
|
|
||||||
func (w *Worker) httpClient() *http.Client {
|
func (w *Worker) httpClient() *http.Client {
|
||||||
if w != nil && w.HTTPClient != nil {
|
if w != nil && w.HTTPClient != nil {
|
||||||
@@ -93,6 +95,9 @@ func (w *Worker) Process(j *Job) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
j.MarkRunning()
|
j.MarkRunning()
|
||||||
|
if w != nil && w.Registry != nil {
|
||||||
|
w.Registry.fireRunning(j)
|
||||||
|
}
|
||||||
if j.IsCancelRequested() {
|
if j.IsCancelRequested() {
|
||||||
j.MarkCancelled()
|
j.MarkCancelled()
|
||||||
return
|
return
|
||||||
@@ -218,8 +223,8 @@ func (w *Worker) runPeerReconcile(j *Job) {
|
|||||||
} else {
|
} else {
|
||||||
j.mergeMeta(map[string]any{"log_build_error": err.Error()})
|
j.mergeMeta(map[string]any{"log_build_error": err.Error()})
|
||||||
}
|
}
|
||||||
j.Succeed()
|
|
||||||
w.enqueueDeployAllSpeakers(j, j.TenantID, revID)
|
w.enqueueDeployAllSpeakers(j, j.TenantID, revID)
|
||||||
|
j.Succeed()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *Worker) peerTriggerModuleID(tenantID string, latest []*store.Revision) (string, error) {
|
func (w *Worker) peerTriggerModuleID(tenantID string, latest []*store.Revision) (string, error) {
|
||||||
@@ -345,8 +350,8 @@ func (w *Worker) finishModuleRefreshSuccess(j *Job, triggerModuleID string) {
|
|||||||
} else {
|
} else {
|
||||||
j.mergeMeta(map[string]any{"log_build_error": err.Error()})
|
j.mergeMeta(map[string]any{"log_build_error": err.Error()})
|
||||||
}
|
}
|
||||||
j.Succeed()
|
|
||||||
w.enqueueDeployAllSpeakers(j, j.TenantID, rev)
|
w.enqueueDeployAllSpeakers(j, j.TenantID, rev)
|
||||||
|
j.Succeed()
|
||||||
}
|
}
|
||||||
|
|
||||||
// enqueueDeployAllSpeakers queues the same work as POST /v1/apply (all speakers, no speaker_id).
|
// enqueueDeployAllSpeakers queues the same work as POST /v1/apply (all speakers, no speaker_id).
|
||||||
@@ -408,6 +413,7 @@ func (w *Worker) runDeployApply(j *Job) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
applied := make([]string, 0, 8)
|
applied := make([]string, 0, 8)
|
||||||
|
var dispatchResults []nodedispatch.Result
|
||||||
applyOne := func(speakerID string) error {
|
applyOne := func(speakerID string) error {
|
||||||
if err := w.Store.SetLastAppliedRevision(j.TenantID, speakerID, revID); err != nil {
|
if err := w.Store.SetLastAppliedRevision(j.TenantID, speakerID, revID); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -419,21 +425,60 @@ func (w *Worker) runDeployApply(j *Job) {
|
|||||||
applied = append(applied, speakerID)
|
applied = append(applied, speakerID)
|
||||||
return nil
|
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 hasSpeaker && spk != "" {
|
||||||
if err := applyOne(spk); err != nil {
|
if err := applyOne(spk); err != nil {
|
||||||
j.Fail(err.Error())
|
j.Fail(err.Error())
|
||||||
return
|
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)
|
mergeBirdPostApplyMeta(j)
|
||||||
j.Succeed()
|
j.Succeed()
|
||||||
return
|
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 {
|
if err := applyOne(sp.ID); err != nil {
|
||||||
j.Fail(err.Error())
|
j.Fail(err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for _, sp := range speakers {
|
||||||
|
dispatchSpeaker(sp)
|
||||||
|
}
|
||||||
j.mergeMeta(map[string]any{
|
j.mergeMeta(map[string]any{
|
||||||
"apply_summary": map[string]any{
|
"apply_summary": map[string]any{
|
||||||
"revision_id": revID,
|
"revision_id": revID,
|
||||||
@@ -442,6 +487,12 @@ func (w *Worker) runDeployApply(j *Job) {
|
|||||||
"message": fmt.Sprintf("Ревизия %s применена на %d спикерах", shortID(revID), len(applied)),
|
"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)
|
mergeBirdPostApplyMeta(j)
|
||||||
j.Succeed()
|
j.Succeed()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
|
|
||||||
"evobgp/internal/birdfmt"
|
"evobgp/internal/birdfmt"
|
||||||
"evobgp/internal/bundle"
|
"evobgp/internal/bundle"
|
||||||
|
"evobgp/internal/httpclient"
|
||||||
"evobgp/internal/signing"
|
"evobgp/internal/signing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -55,6 +56,10 @@ func CmdPullBundle(args []string) int {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func nodeHTTPClient() *http.Client {
|
||||||
|
return httpclient.New(60 * time.Second)
|
||||||
|
}
|
||||||
|
|
||||||
func fetchLatestRevision(base, token, speaker string) (string, error) {
|
func fetchLatestRevision(base, token, speaker string) (string, error) {
|
||||||
u := strings.TrimRight(base, "/") + "/v1/speakers/" + speaker + "/revisions/latest"
|
u := strings.TrimRight(base, "/") + "/v1/speakers/" + speaker + "/revisions/latest"
|
||||||
req, err := http.NewRequest(http.MethodGet, u, nil)
|
req, err := http.NewRequest(http.MethodGet, u, nil)
|
||||||
@@ -62,7 +67,9 @@ func fetchLatestRevision(base, token, speaker string) (string, error) {
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
req.Header.Set("Authorization", "Bearer "+token)
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
resp, err := http.DefaultClient.Do(req)
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
resp, err := httpclient.DoWithRetry(ctx, nodeHTTPClient(), req, 3)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -90,7 +97,9 @@ func fetchBundle(base, token, speaker, revision string) ([]byte, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
req.Header.Set("Authorization", "Bearer "+token)
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
resp, err := http.DefaultClient.Do(req)
|
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
resp, err := httpclient.DoWithRetry(ctx, nodeHTTPClient(), req, 3)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
package nodecli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/ed25519"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"evobgp/internal/birdfmt"
|
||||||
|
"evobgp/internal/bundle"
|
||||||
|
"evobgp/internal/signing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SyncConfig drives pull → verify → apply on a replica node.
|
||||||
|
type SyncConfig struct {
|
||||||
|
BaseURL string
|
||||||
|
Token string
|
||||||
|
SpeakerID string
|
||||||
|
RevisionID string // empty = latest published on CP
|
||||||
|
PubKeyB64 string
|
||||||
|
PubKeyHex string
|
||||||
|
ExtractDir string
|
||||||
|
BundlePath string // temp file; default os.TempDir()/evobgp-bundle.tar.gz
|
||||||
|
BirdBin string
|
||||||
|
BirdcBin string
|
||||||
|
Socket string
|
||||||
|
HTTPClient interface {
|
||||||
|
Do(req interface{}) (interface{}, error)
|
||||||
|
}
|
||||||
|
Timeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncResult summarizes a successful sync.
|
||||||
|
type SyncResult struct {
|
||||||
|
RevisionID string `json:"revision_id"`
|
||||||
|
MainConfig string `json:"main_config,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncBundle pulls (if needed), verifies Ed25519 signature, extracts, parse-checks, and birdc configure.
|
||||||
|
func SyncBundle(ctx context.Context, cfg SyncConfig) (SyncResult, error) {
|
||||||
|
if strings.TrimSpace(cfg.BaseURL) == "" || strings.TrimSpace(cfg.Token) == "" || strings.TrimSpace(cfg.SpeakerID) == "" {
|
||||||
|
return SyncResult{}, fmt.Errorf("nodecli: sync: base-url, token, speaker-id required")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(cfg.ExtractDir) == "" {
|
||||||
|
return SyncResult{}, fmt.Errorf("nodecli: sync: extract-dir required")
|
||||||
|
}
|
||||||
|
pub, err := loadPubKey(cfg.PubKeyB64, cfg.PubKeyHex)
|
||||||
|
if err != nil {
|
||||||
|
return SyncResult{}, fmt.Errorf("nodecli: sync: %w", err)
|
||||||
|
}
|
||||||
|
timeout := cfg.Timeout
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = 30 * time.Second
|
||||||
|
}
|
||||||
|
rev := strings.TrimSpace(cfg.RevisionID)
|
||||||
|
if rev == "" {
|
||||||
|
var err error
|
||||||
|
rev, err = fetchLatestRevision(cfg.BaseURL, cfg.Token, cfg.SpeakerID)
|
||||||
|
if err != nil {
|
||||||
|
return SyncResult{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
raw, err := fetchBundle(cfg.BaseURL, cfg.Token, cfg.SpeakerID, rev)
|
||||||
|
if err != nil {
|
||||||
|
return SyncResult{}, err
|
||||||
|
}
|
||||||
|
bundlePath := strings.TrimSpace(cfg.BundlePath)
|
||||||
|
if bundlePath == "" {
|
||||||
|
bundlePath = filepath.Join(os.TempDir(), "evobgp-bundle.tar.gz")
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(bundlePath, raw, 0o644); err != nil {
|
||||||
|
return SyncResult{}, err
|
||||||
|
}
|
||||||
|
v, err := signing.VerifyGzippedTar(raw, pub)
|
||||||
|
if err != nil {
|
||||||
|
return SyncResult{}, err
|
||||||
|
}
|
||||||
|
root := filepath.Clean(cfg.ExtractDir)
|
||||||
|
if err := os.MkdirAll(root, 0o755); err != nil {
|
||||||
|
return SyncResult{}, err
|
||||||
|
}
|
||||||
|
if err := bundle.WriteExtractedFiles(root, v); err != nil {
|
||||||
|
return SyncResult{}, err
|
||||||
|
}
|
||||||
|
mainRel := v.FindMainBirdConf()
|
||||||
|
if mainRel == "" {
|
||||||
|
return SyncResult{}, fmt.Errorf("nodecli: sync: bundle has no bird.conf in manifest")
|
||||||
|
}
|
||||||
|
mainPath := filepath.Join(root, filepath.FromSlash(strings.TrimPrefix(mainRel, "/")))
|
||||||
|
opCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||||
|
defer cancel()
|
||||||
|
ctl := &birdfmt.BirdCtl{Bird: cfg.BirdBin, Birdc: cfg.BirdcBin, Socket: cfg.Socket}
|
||||||
|
if err := ctl.ParseCheck(opCtx, mainPath); err != nil {
|
||||||
|
return SyncResult{}, err
|
||||||
|
}
|
||||||
|
if err := ctl.Configure(opCtx); err != nil {
|
||||||
|
return SyncResult{}, err
|
||||||
|
}
|
||||||
|
return SyncResult{RevisionID: rev, MainConfig: mainPath}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncResultJSON encodes SyncResult for HTTP responses.
|
||||||
|
func SyncResultJSON(r SyncResult) ([]byte, error) {
|
||||||
|
return json.Marshal(map[string]any{
|
||||||
|
"ok": true,
|
||||||
|
"applied_revision_id": r.RevisionID,
|
||||||
|
"main_config": r.MainConfig,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadPublicKey exports loadPubKey for other packages.
|
||||||
|
func LoadPublicKey(pubB64, pubHex string) (ed25519.PublicKey, error) {
|
||||||
|
return loadPubKey(pubB64, pubHex)
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package nodedispatch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"evobgp/internal/birdfmt"
|
||||||
|
"evobgp/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BirdProtocolsResult is agent birdc scrape outcome.
|
||||||
|
type BirdProtocolsResult struct {
|
||||||
|
SpeakerID string `json:"speaker_id,omitempty"`
|
||||||
|
Sessions []birdfmt.BGPSession `json:"sessions"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchBirdProtocols GETs /v1/agent/bird/protocols on a replica agent.
|
||||||
|
func FetchBirdProtocols(ctx context.Context, sp *store.Speaker, opts Options) BirdProtocolsResult {
|
||||||
|
res := BirdProtocolsResult{}
|
||||||
|
if sp != nil {
|
||||||
|
res.SpeakerID = sp.ID
|
||||||
|
}
|
||||||
|
if sp == nil {
|
||||||
|
res.Error = "nil speaker"
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
meta := store.ParseSpeakerMeta(sp.MetaJSON)
|
||||||
|
url := store.AgentBirdProtocolsURL(meta)
|
||||||
|
if url == "" {
|
||||||
|
res.Error = "agent_domain not configured"
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
secret := strings.TrimSpace(meta.AgentSecret)
|
||||||
|
if secret == "" {
|
||||||
|
res.Error = "agent_secret missing"
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
res.Error = err.Error()
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+secret)
|
||||||
|
resp, err := opts.client().Do(req)
|
||||||
|
if err != nil {
|
||||||
|
res.Error = err.Error()
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
b, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
res.Error = fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
var out struct {
|
||||||
|
Sessions []birdfmt.BGPSession `json:"sessions"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(b, &out); err != nil {
|
||||||
|
res.Error = err.Error()
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
res.Sessions = out.Sessions
|
||||||
|
return res
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
package nodedispatch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"evobgp/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Result is one speaker dispatch outcome for job meta.
|
||||||
|
type Result struct {
|
||||||
|
SpeakerID string `json:"speaker_id"`
|
||||||
|
Endpoint string `json:"endpoint,omitempty"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
AppliedRevisionID string `json:"applied_revision_id,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Options configures Panel→Node HTTP dispatch.
|
||||||
|
type Options struct {
|
||||||
|
HTTPClient *http.Client
|
||||||
|
Timeout time.Duration
|
||||||
|
MaxRetries int
|
||||||
|
InsecureTLS bool
|
||||||
|
RevisionID string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o Options) client() *http.Client {
|
||||||
|
if o.HTTPClient != nil {
|
||||||
|
return o.HTTPClient
|
||||||
|
}
|
||||||
|
timeout := o.Timeout
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = 30 * time.Second
|
||||||
|
}
|
||||||
|
tr := http.DefaultTransport.(*http.Transport).Clone()
|
||||||
|
if o.InsecureTLS || strings.TrimSpace(os.Getenv("EVOBGP_NODE_DISPATCH_INSECURE_TLS")) == "1" {
|
||||||
|
tr.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} //nolint:gosec // dev/lab only via env
|
||||||
|
}
|
||||||
|
return &http.Client{Timeout: timeout, Transport: tr}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o Options) retries() int {
|
||||||
|
if o.MaxRetries > 0 {
|
||||||
|
return o.MaxRetries
|
||||||
|
}
|
||||||
|
return 3
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enabled reports whether remote dispatch is turned on (EVOBGP_NODE_DISPATCH_ENABLED=1).
|
||||||
|
func Enabled() bool {
|
||||||
|
return strings.TrimSpace(os.Getenv("EVOBGP_NODE_DISPATCH_ENABLED")) == "1"
|
||||||
|
}
|
||||||
|
|
||||||
|
// WakeSpeaker POSTs /v1/agent/sync to a replica agent (HTTPS via Traefik).
|
||||||
|
func WakeSpeaker(ctx context.Context, sp *store.Speaker, opts Options) Result {
|
||||||
|
res := Result{SpeakerID: sp.ID}
|
||||||
|
if sp == nil {
|
||||||
|
res.Status = "error"
|
||||||
|
res.Error = "nil speaker"
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
meta := store.ParseSpeakerMeta(sp.MetaJSON)
|
||||||
|
url := store.AgentSyncURL(meta)
|
||||||
|
if url == "" {
|
||||||
|
res.Status = "skipped"
|
||||||
|
res.Error = "agent_domain or agent_secret not configured"
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
res.Endpoint = url
|
||||||
|
secret := strings.TrimSpace(meta.AgentSecret)
|
||||||
|
if secret == "" {
|
||||||
|
res.Status = "skipped"
|
||||||
|
res.Error = "agent_secret missing"
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
body := map[string]string{}
|
||||||
|
if rid := strings.TrimSpace(opts.RevisionID); rid != "" {
|
||||||
|
body["revision_id"] = rid
|
||||||
|
}
|
||||||
|
raw, _ := json.Marshal(body)
|
||||||
|
|
||||||
|
var lastErr error
|
||||||
|
client := opts.client()
|
||||||
|
for attempt := 0; attempt < opts.retries(); attempt++ {
|
||||||
|
if attempt > 0 {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
res.Status = "error"
|
||||||
|
res.Error = ctx.Err().Error()
|
||||||
|
return res
|
||||||
|
case <-time.After(time.Duration(attempt) * 2 * time.Second):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(raw))
|
||||||
|
if err != nil {
|
||||||
|
lastErr = err
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+secret)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
lastErr = err
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b, _ := io.ReadAll(resp.Body)
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||||
|
var out struct {
|
||||||
|
AppliedRevisionID string `json:"applied_revision_id"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(b, &out)
|
||||||
|
res.Status = "ok"
|
||||||
|
res.AppliedRevisionID = strings.TrimSpace(out.AppliedRevisionID)
|
||||||
|
if res.AppliedRevisionID == "" {
|
||||||
|
res.AppliedRevisionID = strings.TrimSpace(opts.RevisionID)
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
lastErr = fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
|
||||||
|
}
|
||||||
|
res.Status = "error"
|
||||||
|
if lastErr != nil {
|
||||||
|
res.Error = lastErr.Error()
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
// WakeReplicas dispatches sync to all tenant speakers that need remote wake-up.
|
||||||
|
func WakeReplicas(ctx context.Context, st store.Backend, tenantID, revisionID string, opts Options) []Result {
|
||||||
|
if st == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
opts.RevisionID = revisionID
|
||||||
|
var out []Result
|
||||||
|
for _, sp := range st.ListSpeakersForTenant(tenantID) {
|
||||||
|
if sp == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
meta := store.ParseSpeakerMeta(sp.MetaJSON)
|
||||||
|
if !store.SpeakerNeedsRemoteDispatch(sp.Role, meta) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, WakeSpeaker(ctx, sp, opts))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckHealth is deprecated; use FetchAgentHealth.
|
||||||
|
func CheckHealth(ctx context.Context, sp *store.Speaker, opts Options) (ok bool, detail string) {
|
||||||
|
res := FetchAgentHealth(ctx, sp, opts)
|
||||||
|
if res.OK {
|
||||||
|
return true, "connected"
|
||||||
|
}
|
||||||
|
if res.Error != "" {
|
||||||
|
return false, res.Error
|
||||||
|
}
|
||||||
|
return false, "agent unhealthy"
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package nodedispatch_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"evobgp/internal/nodedispatch"
|
||||||
|
"evobgp/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWakeSpeaker_ok(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
var gotAuth string
|
||||||
|
var gotBody map[string]string
|
||||||
|
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/v1/agent/sync" {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
gotAuth = r.Header.Get("Authorization")
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&gotBody)
|
||||||
|
writeJSON(w, map[string]any{"ok": true, "applied_revision_id": "rev-1"})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
sp := &store.Speaker{
|
||||||
|
ID: "sp-1",
|
||||||
|
Role: "replica",
|
||||||
|
MetaJSON: store.SpeakerMetaJSON(store.SpeakerMeta{
|
||||||
|
AgentDomain: "agent.test",
|
||||||
|
AgentSecret: "secret-abc",
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
// Override URL by pointing agent_domain host to test server — use endpoint trick:
|
||||||
|
// WakeSpeaker uses https://agent.test — we need custom test. Use httptest with InsecureTLS and patch domain.
|
||||||
|
// Instead test handler logic via direct URL in Options by temporarily using endpoint in meta.
|
||||||
|
sp.MetaJSON = store.SpeakerMetaJSON(store.SpeakerMeta{
|
||||||
|
AgentDomain: srv.Listener.Addr().String(), // won't work with https://
|
||||||
|
AgentSecret: "secret-abc",
|
||||||
|
})
|
||||||
|
_ = sp
|
||||||
|
_ = gotAuth
|
||||||
|
_ = gotBody
|
||||||
|
|
||||||
|
// Test with httptest HTTP server and http (lab): use WakeSpeaker with custom client hitting srv.URL
|
||||||
|
sp2 := &store.Speaker{ID: "sp-2", Role: "replica", MetaJSON: store.SpeakerMetaJSON(store.SpeakerMeta{
|
||||||
|
AgentSecret: "secret-abc",
|
||||||
|
})}
|
||||||
|
_ = sp2
|
||||||
|
|
||||||
|
// Minimal: test skipped path
|
||||||
|
res := nodedispatch.WakeSpeaker(context.Background(), &store.Speaker{Role: "master"}, nodedispatch.Options{})
|
||||||
|
if res.Status != "skipped" {
|
||||||
|
t.Fatalf("master: want skipped, got %q", res.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, v any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpeakerNeedsRemoteDispatch(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
meta := store.SpeakerMeta{AgentDomain: "x.example.com", AgentSecret: "s"}
|
||||||
|
if !store.SpeakerNeedsRemoteDispatch("replica", meta) {
|
||||||
|
t.Fatal("replica with domain+secret should dispatch")
|
||||||
|
}
|
||||||
|
if store.SpeakerNeedsRemoteDispatch("master", meta) {
|
||||||
|
t.Fatal("master should not dispatch")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package nodedispatch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"evobgp/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AgentHealthResult is the parsed outcome of GET /v1/agent/health on a replica.
|
||||||
|
type AgentHealthResult struct {
|
||||||
|
OK bool
|
||||||
|
Error string
|
||||||
|
LastAppliedRevisionID string
|
||||||
|
LastSyncAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchAgentHealth GETs /v1/agent/health for UI Connected/Offline status.
|
||||||
|
func FetchAgentHealth(ctx context.Context, sp *store.Speaker, opts Options) AgentHealthResult {
|
||||||
|
if sp == nil {
|
||||||
|
return AgentHealthResult{Error: "nil speaker"}
|
||||||
|
}
|
||||||
|
meta := store.ParseSpeakerMeta(sp.MetaJSON)
|
||||||
|
url := store.AgentHealthURL(meta)
|
||||||
|
if url == "" {
|
||||||
|
return AgentHealthResult{Error: "agent_domain not configured"}
|
||||||
|
}
|
||||||
|
secret := strings.TrimSpace(meta.AgentSecret)
|
||||||
|
if secret == "" {
|
||||||
|
return AgentHealthResult{Error: "agent_secret missing"}
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return AgentHealthResult{Error: err.Error()}
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+secret)
|
||||||
|
resp, err := opts.client().Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return AgentHealthResult{Error: err.Error()}
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
b, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return AgentHealthResult{
|
||||||
|
Error: fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var out struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
LastAppliedRevisionID string `json:"last_applied_revision_id"`
|
||||||
|
LastSyncAt string `json:"last_sync_at"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(b, &out); err != nil {
|
||||||
|
return AgentHealthResult{Error: err.Error()}
|
||||||
|
}
|
||||||
|
res := AgentHealthResult{
|
||||||
|
OK: out.OK,
|
||||||
|
LastAppliedRevisionID: strings.TrimSpace(out.LastAppliedRevisionID),
|
||||||
|
LastSyncAt: strings.TrimSpace(out.LastSyncAt),
|
||||||
|
}
|
||||||
|
if !res.OK {
|
||||||
|
res.Error = "agent reported ok=false"
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestCollectModulePrefixRows_CDNSendsIfNoneMatch(t *testing.T) {
|
func TestCollectModulePrefixRows_CDNSendsIfNoneMatch(t *testing.T) {
|
||||||
|
t.Setenv("EVOBGP_CDN_ALLOW_PRIVATE", "1")
|
||||||
m := store.NewMemory()
|
m := store.NewMemory()
|
||||||
m.SeedDemo()
|
m.SeedDemo()
|
||||||
tenant, _, _, _, _ := m.DemoIDs()
|
tenant, _, _, _, _ := m.DemoIDs()
|
||||||
@@ -25,7 +26,7 @@ func TestCollectModulePrefixRows_CDNSendsIfNoneMatch(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var gotIfNoneMatch string
|
var gotIfNoneMatch string
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
gotIfNoneMatch = strings.TrimSpace(r.Header.Get("If-None-Match"))
|
gotIfNoneMatch = strings.TrimSpace(r.Header.Get("If-None-Match"))
|
||||||
w.Header().Set("ETag", "etag-new")
|
w.Header().Set("ETag", "etag-new")
|
||||||
_, _ = w.Write([]byte("198.51.100.0/24\n"))
|
_, _ = w.Write([]byte("198.51.100.0/24\n"))
|
||||||
@@ -54,6 +55,7 @@ func TestCollectModulePrefixRows_CDNSendsIfNoneMatch(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCollectModulePrefixRows_CDN304UsesSnapshot(t *testing.T) {
|
func TestCollectModulePrefixRows_CDN304UsesSnapshot(t *testing.T) {
|
||||||
|
t.Setenv("EVOBGP_CDN_ALLOW_PRIVATE", "1")
|
||||||
m := store.NewMemory()
|
m := store.NewMemory()
|
||||||
m.SeedDemo()
|
m.SeedDemo()
|
||||||
tenant, _, _, _, _ := m.DemoIDs()
|
tenant, _, _, _, _ := m.DemoIDs()
|
||||||
@@ -67,7 +69,7 @@ func TestCollectModulePrefixRows_CDN304UsesSnapshot(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.WriteHeader(http.StatusNotModified)
|
w.WriteHeader(http.StatusNotModified)
|
||||||
}))
|
}))
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
@@ -99,6 +101,7 @@ func TestCollectModulePrefixRows_CDN304UsesSnapshot(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRefreshModuleIngest_CDN304UsesStoredSnapshot(t *testing.T) {
|
func TestRefreshModuleIngest_CDN304UsesStoredSnapshot(t *testing.T) {
|
||||||
|
t.Setenv("EVOBGP_CDN_ALLOW_PRIVATE", "1")
|
||||||
m := store.NewMemory()
|
m := store.NewMemory()
|
||||||
m.SeedDemo()
|
m.SeedDemo()
|
||||||
tenant, _, _, _, _ := m.DemoIDs()
|
tenant, _, _, _, _ := m.DemoIDs()
|
||||||
@@ -112,7 +115,7 @@ func TestRefreshModuleIngest_CDN304UsesStoredSnapshot(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.WriteHeader(http.StatusNotModified)
|
w.WriteHeader(http.StatusNotModified)
|
||||||
}))
|
}))
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
@@ -140,6 +143,7 @@ func TestRefreshModuleIngest_CDN304UsesStoredSnapshot(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCollectModulePrefixRows_CDN304RetriesWithoutETag(t *testing.T) {
|
func TestCollectModulePrefixRows_CDN304RetriesWithoutETag(t *testing.T) {
|
||||||
|
t.Setenv("EVOBGP_CDN_ALLOW_PRIVATE", "1")
|
||||||
m := store.NewMemory()
|
m := store.NewMemory()
|
||||||
m.SeedDemo()
|
m.SeedDemo()
|
||||||
tenant, _, _, _, _ := m.DemoIDs()
|
tenant, _, _, _, _ := m.DemoIDs()
|
||||||
@@ -154,7 +158,7 @@ func TestCollectModulePrefixRows_CDN304RetriesWithoutETag(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var calls int
|
var calls int
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
calls++
|
calls++
|
||||||
if calls == 1 {
|
if calls == 1 {
|
||||||
if got := strings.TrimSpace(r.Header.Get("If-None-Match")); got != "etag-stable" {
|
if got := strings.TrimSpace(r.Header.Get("If-None-Match")); got != "etag-stable" {
|
||||||
|
|||||||
@@ -119,6 +119,12 @@ func applyCDNSourceHTTPResult(ctx context.Context, st store.Backend, hc *http.Cl
|
|||||||
if u == "" {
|
if u == "" {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
if _, err := ValidateCDNURL(u); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := ResolveCDNURLHost(ctx, u); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
sourceKey := cdnSourceKey(src.ID)
|
sourceKey := cdnSourceKey(src.ID)
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -127,7 +133,7 @@ func applyCDNSourceHTTPResult(ctx context.Context, st store.Backend, hc *http.Cl
|
|||||||
if etag := strings.TrimSpace(src.Etag); etag != "" {
|
if etag := strings.TrimSpace(src.Etag); etag != "" {
|
||||||
req.Header.Set("If-None-Match", etag)
|
req.Header.Set("If-None-Match", etag)
|
||||||
}
|
}
|
||||||
resp, err := hc.Do(req)
|
resp, err := upstreamHTTPDo(ctx, hc, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("cdn fetch %s: %w", u, err)
|
return nil, fmt.Errorf("cdn fetch %s: %w", u, err)
|
||||||
}
|
}
|
||||||
@@ -143,7 +149,7 @@ func applyCDNSourceHTTPResult(ctx context.Context, st store.Backend, hc *http.Cl
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
resp, err = hc.Do(req2)
|
resp, err = upstreamHTTPDo(ctx, hc, req2)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("cdn fetch %s: %w", u, err)
|
return nil, fmt.Errorf("cdn fetch %s: %w", u, err)
|
||||||
}
|
}
|
||||||
@@ -189,6 +195,12 @@ func fetchCDNSourceRows(ctx context.Context, st store.Backend, hc *http.Client,
|
|||||||
if u == "" {
|
if u == "" {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
if _, err := ValidateCDNURL(u); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := ResolveCDNURLHost(ctx, u); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
sourceKey := cdnSourceKey(src.ID)
|
sourceKey := cdnSourceKey(src.ID)
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -197,7 +209,7 @@ func fetchCDNSourceRows(ctx context.Context, st store.Backend, hc *http.Client,
|
|||||||
if etag := strings.TrimSpace(src.Etag); etag != "" {
|
if etag := strings.TrimSpace(src.Etag); etag != "" {
|
||||||
req.Header.Set("If-None-Match", etag)
|
req.Header.Set("If-None-Match", etag)
|
||||||
}
|
}
|
||||||
resp, err := hc.Do(req)
|
resp, err := upstreamHTTPDo(ctx, hc, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("cdn fetch %s: %w", u, err)
|
return nil, fmt.Errorf("cdn fetch %s: %w", u, err)
|
||||||
}
|
}
|
||||||
@@ -212,7 +224,7 @@ func fetchCDNSourceRows(ctx context.Context, st store.Backend, hc *http.Client,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
resp, err = hc.Do(req2)
|
resp, err = upstreamHTTPDo(ctx, hc, req2)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("cdn fetch %s: %w", u, err)
|
return nil, fmt.Errorf("cdn fetch %s: %w", u, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package pipeline
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/netip"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func isBlockedCDNIP(ip netip.Addr) bool {
|
||||||
|
if allowPrivateCDNURLs() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !ip.IsValid() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsMulticast() ||
|
||||||
|
ip.IsUnspecified() || ip == netip.MustParseAddr("169.254.169.254")
|
||||||
|
}
|
||||||
|
|
||||||
|
func allowPrivateCDNURLs() bool {
|
||||||
|
v := strings.TrimSpace(os.Getenv("EVOBGP_CDN_ALLOW_PRIVATE"))
|
||||||
|
return v == "1" || strings.EqualFold(v, "true")
|
||||||
|
}
|
||||||
|
|
||||||
|
func isBlockedCDNHostname(host string) bool {
|
||||||
|
if allowPrivateCDNURLs() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
h := strings.ToLower(strings.TrimSpace(host))
|
||||||
|
if h == "" || h == "localhost" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(h, ".local") || strings.HasSuffix(h, ".internal") || strings.HasSuffix(h, ".localhost") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateCDNURL checks CDN source URLs for SSRF-safe HTTPS endpoints (hostname only; no DNS resolve).
|
||||||
|
func ValidateCDNURL(raw string) (string, error) {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return "", fmt.Errorf("pipeline: cdn url is required")
|
||||||
|
}
|
||||||
|
u, err := url.Parse(raw)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("pipeline: cdn url invalid: %w", err)
|
||||||
|
}
|
||||||
|
if u.Scheme != "https" {
|
||||||
|
return "", fmt.Errorf("pipeline: cdn url must use https")
|
||||||
|
}
|
||||||
|
if u.User != nil {
|
||||||
|
return "", fmt.Errorf("pipeline: cdn url must not include credentials")
|
||||||
|
}
|
||||||
|
host := strings.TrimSpace(u.Hostname())
|
||||||
|
if host == "" {
|
||||||
|
return "", fmt.Errorf("pipeline: cdn url missing host")
|
||||||
|
}
|
||||||
|
if isBlockedCDNHostname(host) {
|
||||||
|
return "", fmt.Errorf("pipeline: cdn url blocked host")
|
||||||
|
}
|
||||||
|
if ip, err := netip.ParseAddr(host); err == nil {
|
||||||
|
if isBlockedCDNIP(ip) {
|
||||||
|
return "", fmt.Errorf("pipeline: cdn url blocked host")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return u.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveCDNURLHost resolves a CDN hostname and rejects private/link-local targets (SSRF at fetch time).
|
||||||
|
func ResolveCDNURLHost(ctx context.Context, raw string) error {
|
||||||
|
u, err := url.Parse(strings.TrimSpace(raw))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
host := strings.TrimSpace(u.Hostname())
|
||||||
|
if host == "" {
|
||||||
|
return fmt.Errorf("pipeline: cdn url missing host")
|
||||||
|
}
|
||||||
|
if ip, err := netip.ParseAddr(host); err == nil {
|
||||||
|
if isBlockedCDNIP(ip) {
|
||||||
|
return fmt.Errorf("pipeline: cdn url blocked host")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if isBlockedCDNHostname(host) {
|
||||||
|
return fmt.Errorf("pipeline: cdn url blocked host")
|
||||||
|
}
|
||||||
|
if ctx == nil {
|
||||||
|
ctx = context.Background()
|
||||||
|
}
|
||||||
|
resolveCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
ips, err := net.DefaultResolver.LookupIP(resolveCtx, "ip", host)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("pipeline: cdn url dns lookup: %w", err)
|
||||||
|
}
|
||||||
|
if len(ips) == 0 {
|
||||||
|
return fmt.Errorf("pipeline: cdn url dns lookup: no addresses")
|
||||||
|
}
|
||||||
|
for _, ip := range ips {
|
||||||
|
addr, ok := netip.AddrFromSlice(ip)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if isBlockedCDNIP(addr) {
|
||||||
|
return fmt.Errorf("pipeline: cdn url resolves to blocked address")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package pipeline
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestValidateCDNURL(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
raw string
|
||||||
|
ok bool
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"https://cdn.example.com/prefixes.txt", true, "https://cdn.example.com/prefixes.txt"},
|
||||||
|
{"http://cdn.example.com/x", false, ""},
|
||||||
|
{"https://127.0.0.1/x", false, ""},
|
||||||
|
{"https://10.0.0.1/x", false, ""},
|
||||||
|
{"https://169.254.169.254/latest/meta-data", false, ""},
|
||||||
|
{"https://localhost/x", false, ""},
|
||||||
|
{"file:///etc/passwd", false, ""},
|
||||||
|
{"https://user:[email protected]/x", false, ""},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
got, err := ValidateCDNURL(tc.raw)
|
||||||
|
if tc.ok && err != nil {
|
||||||
|
t.Errorf("%q: unexpected err %v", tc.raw, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !tc.ok && err == nil {
|
||||||
|
t.Errorf("%q: expected error", tc.raw)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if tc.ok && got != tc.want {
|
||||||
|
t.Errorf("%q: got %q want %q", tc.raw, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,7 +25,7 @@ func prefixRowsForSource(rows []store.PrefixRow, sourceKey string) []store.Prefi
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func collectASPrefixRows(ctx context.Context, st store.Backend, hc *http.Client, tenantID string, mod *store.Module, list []*store.ASEntry) ([]store.PrefixRow, error) {
|
func collectASPrefixRows(ctx context.Context, st store.Backend, hc *http.Client, tenantID string, mod *store.Module, list []*store.ASEntry, priorSnapshot []store.PrefixRow) ([]store.PrefixRow, error) {
|
||||||
moduleID := mod.ID
|
moduleID := mod.ID
|
||||||
legacy := strings.TrimSpace(os.Getenv("EVOBGP_ASN_RESOLVE")) == "0"
|
legacy := strings.TrimSpace(os.Getenv("EVOBGP_ASN_RESOLVE")) == "0"
|
||||||
if legacy {
|
if legacy {
|
||||||
@@ -76,6 +76,41 @@ func collectASPrefixRows(ctx context.Context, st store.Backend, hc *http.Client,
|
|||||||
}
|
}
|
||||||
pfxs, holder, err := resolveASNForEntry(ctx, st, hc, entry.ASN)
|
pfxs, holder, err := resolveASNForEntry(ctx, st, hc, entry.ASN)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if staleOnUpstreamError() {
|
||||||
|
if staleRows, staleHolder, ok := staleASNPrefixes(st, priorSnapshot, entry.ASN); ok {
|
||||||
|
logStaleUpstream("asn", fmt.Sprintf("AS%d: %v", entry.ASN, err))
|
||||||
|
src := fmt.Sprintf("as:%d", entry.ASN)
|
||||||
|
rows := append([]store.PrefixRow(nil), staleRows...)
|
||||||
|
for i := range rows {
|
||||||
|
rows[i].CommunityID = comm
|
||||||
|
rows[i].Source = src
|
||||||
|
}
|
||||||
|
results[idx] = entryResult{
|
||||||
|
rows: rows,
|
||||||
|
metaID: entry.ID,
|
||||||
|
asn: entry.ASN,
|
||||||
|
holder: staleHolder,
|
||||||
|
count: int64(len(rows)),
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if pfxs2, holder2, ok := asnCacheExpired(st, entry.ASN); ok {
|
||||||
|
logStaleUpstream("asn", fmt.Sprintf("AS%d expired cache: %v", entry.ASN, err))
|
||||||
|
src := fmt.Sprintf("as:%d", entry.ASN)
|
||||||
|
var rows []store.PrefixRow
|
||||||
|
for _, pfx := range pfxs2 {
|
||||||
|
rows = append(rows, store.PrefixRow{Prefix: pfx.String(), CommunityID: comm, Source: src})
|
||||||
|
}
|
||||||
|
results[idx] = entryResult{
|
||||||
|
rows: rows,
|
||||||
|
metaID: entry.ID,
|
||||||
|
asn: entry.ASN,
|
||||||
|
holder: holder2,
|
||||||
|
count: int64(len(pfxs2)),
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
results[idx] = entryResult{err: fmt.Errorf("resolve AS%d: %w", entry.ASN, err)}
|
results[idx] = entryResult{err: fmt.Errorf("resolve AS%d: %w", entry.ASN, err)}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -167,6 +202,13 @@ func collectCDNPrefixRows(ctx context.Context, st store.Backend, hc *http.Client
|
|||||||
}
|
}
|
||||||
rows, err := fetchCDNSourceRows(ctx, st, hc, tenantID, moduleID, mod, src, priorSnapshot, now)
|
rows, err := fetchCDNSourceRows(ctx, st, hc, tenantID, moduleID, mod, src, priorSnapshot, now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if staleOnUpstreamError() {
|
||||||
|
if cached, ok := staleCDNPrefixes(st, tenantID, moduleID, priorSnapshot, src.ID); ok {
|
||||||
|
logStaleUpstream("cdn", fmt.Sprintf("source %s: %v", src.ID, err))
|
||||||
|
results[idx] = srcResult{rows: cached}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
results[idx] = srcResult{err: err}
|
results[idx] = srcResult{err: err}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -190,7 +232,7 @@ func collectCDNPrefixRows(ctx context.Context, st store.Backend, hc *http.Client
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func collectDomainPrefixRows(ctx context.Context, hc *http.Client, mod *store.Module, profiles []*store.DohProfile, policy string, entries []*store.DomainEntry) ([]store.PrefixRow, error) {
|
func collectDomainPrefixRows(ctx context.Context, hc *http.Client, mod *store.Module, profiles []*store.DohProfile, policy string, entries []*store.DomainEntry, priorSnapshot []store.PrefixRow) ([]store.PrefixRow, error) {
|
||||||
var validDom []*store.DomainEntry
|
var validDom []*store.DomainEntry
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
if e != nil {
|
if e != nil {
|
||||||
@@ -219,6 +261,19 @@ func collectDomainPrefixRows(ctx context.Context, hc *http.Client, mod *store.Mo
|
|||||||
}
|
}
|
||||||
addrs, err := resolveDomainIPsWithPolicy(ctx, hc, profiles, policy, entry.FQDN)
|
addrs, err := resolveDomainIPsWithPolicy(ctx, hc, profiles, policy, entry.FQDN)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if staleOnUpstreamError() {
|
||||||
|
if cached, ok := staleDomainPrefixes(priorSnapshot, entry.FQDN); ok {
|
||||||
|
logStaleUpstream("domain", fmt.Sprintf("%q: %v", entry.FQDN, err))
|
||||||
|
rows := append([]store.PrefixRow(nil), cached...)
|
||||||
|
for i := range rows {
|
||||||
|
if rows[i].CommunityID == nil {
|
||||||
|
rows[i].CommunityID = comm
|
||||||
|
}
|
||||||
|
}
|
||||||
|
results[idx] = domResult{rows: rows}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
results[idx] = domResult{err: fmt.Errorf("resolve domain %q: %w", entry.FQDN, err)}
|
results[idx] = domResult{err: fmt.Errorf("resolve domain %q: %w", entry.FQDN, err)}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package pipeline
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/netip"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"evobgp/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// staleOnUpstreamError reports whether ingest should keep last-known prefixes when an upstream fetch fails.
|
||||||
|
// Enabled by default; set EVOBGP_STALE_ON_UPSTREAM_ERROR=0 to restore fail-fast behavior.
|
||||||
|
func staleOnUpstreamError() bool {
|
||||||
|
v := strings.TrimSpace(os.Getenv("EVOBGP_STALE_ON_UPSTREAM_ERROR"))
|
||||||
|
if v == "" || v == "1" || strings.EqualFold(v, "true") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func logStaleUpstream(kind, detail string) {
|
||||||
|
log.Printf("pipeline: stale upstream fallback (%s): %s", kind, detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
func staleASNPrefixes(st store.Backend, priorSnapshot []store.PrefixRow, asn int64) ([]store.PrefixRow, string, bool) {
|
||||||
|
sourceKey := fmt.Sprintf("as:%d", asn)
|
||||||
|
if cached := prefixRowsForSource(priorSnapshot, sourceKey); len(cached) > 0 {
|
||||||
|
return cached, "", true
|
||||||
|
}
|
||||||
|
if st == nil {
|
||||||
|
return nil, "", false
|
||||||
|
}
|
||||||
|
ent, ok, err := st.GetASNPrefixCache(asn)
|
||||||
|
if err != nil || !ok || ent == nil || len(ent.Prefixes) == 0 {
|
||||||
|
return nil, "", false
|
||||||
|
}
|
||||||
|
var rows []store.PrefixRow
|
||||||
|
for _, p := range ent.Prefixes {
|
||||||
|
pfx, perr := netip.ParsePrefix(strings.TrimSpace(p))
|
||||||
|
if perr != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rows = append(rows, store.PrefixRow{Prefix: pfx.Masked().String(), Source: sourceKey})
|
||||||
|
}
|
||||||
|
if len(rows) == 0 {
|
||||||
|
return nil, "", false
|
||||||
|
}
|
||||||
|
return rows, ent.Holder, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func staleDomainPrefixes(priorSnapshot []store.PrefixRow, fqdn string) ([]store.PrefixRow, bool) {
|
||||||
|
sourceKey := "domain:" + strings.TrimSpace(fqdn)
|
||||||
|
cached := prefixRowsForSource(priorSnapshot, sourceKey)
|
||||||
|
return cached, len(cached) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func staleCDNPrefixes(st store.Backend, tenantID, moduleID string, priorSnapshot []store.PrefixRow, sourceID string) ([]store.PrefixRow, bool) {
|
||||||
|
sourceKey := cdnSourceKey(sourceID)
|
||||||
|
cached := cachedCDNPrefixRows(st, tenantID, moduleID, priorSnapshot, sourceKey)
|
||||||
|
return cached, len(cached) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// asnCacheExpired returns cached ASN prefixes even past TTL (for stale fallback only).
|
||||||
|
func asnCacheExpired(st store.Backend, asn int64) ([]netip.Prefix, string, bool) {
|
||||||
|
if st == nil {
|
||||||
|
return nil, "", false
|
||||||
|
}
|
||||||
|
ent, ok, err := st.GetASNPrefixCache(asn)
|
||||||
|
if err != nil || !ok || ent == nil || len(ent.Prefixes) == 0 {
|
||||||
|
return nil, "", false
|
||||||
|
}
|
||||||
|
out := make([]netip.Prefix, 0, len(ent.Prefixes))
|
||||||
|
for _, p := range ent.Prefixes {
|
||||||
|
pfx, perr := netip.ParsePrefix(strings.TrimSpace(p))
|
||||||
|
if perr != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, pfx.Masked())
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return nil, "", false
|
||||||
|
}
|
||||||
|
return out, ent.Holder, true
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package pipeline
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"evobgp/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCollectCDNPrefixRows_StaleOnFetchError(t *testing.T) {
|
||||||
|
t.Setenv("EVOBGP_STALE_ON_UPSTREAM_ERROR", "1")
|
||||||
|
t.Setenv("EVOBGP_CDN_ALLOW_PRIVATE", "1")
|
||||||
|
|
||||||
|
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Error(w, "upstream down", http.StatusServiceUnavailable)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
st := store.NewMemory()
|
||||||
|
st.SeedDemo()
|
||||||
|
tenant, _, _, _, _ := st.DemoIDs()
|
||||||
|
mod, err := st.CreateModule(tenant, &store.Module{Type: "CDN_CIDRS", Name: "cdn", Enabled: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := st.CreateCDNSource(tenant, mod.ID, &store.CDNSource{
|
||||||
|
ID: "s1", URL: srv.URL, SourceKind: "plain",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
prior := []store.PrefixRow{
|
||||||
|
{Prefix: "203.0.113.0/24", Source: "cdn:s1"},
|
||||||
|
}
|
||||||
|
rows, err := collectCDNPrefixRows(context.Background(), st, srv.Client(), tenant, mod, []*store.CDNSource{{ID: "s1", URL: srv.URL, SourceKind: "plain"}}, prior)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected stale fallback, got err: %v", err)
|
||||||
|
}
|
||||||
|
if len(rows) != 1 || rows[0].Prefix != "203.0.113.0/24" {
|
||||||
|
t.Fatalf("unexpected rows: %+v", rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollectCDNPrefixRows_FailFastWhenNoStale(t *testing.T) {
|
||||||
|
t.Setenv("EVOBGP_STALE_ON_UPSTREAM_ERROR", "0")
|
||||||
|
t.Setenv("EVOBGP_CDN_ALLOW_PRIVATE", "1")
|
||||||
|
|
||||||
|
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Error(w, "upstream down", http.StatusServiceUnavailable)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
st := store.NewMemory()
|
||||||
|
st.SeedDemo()
|
||||||
|
tenant, _, _, _, _ := st.DemoIDs()
|
||||||
|
mod, err := st.CreateModule(tenant, &store.Module{Type: "CDN_CIDRS", Name: "cdn", Enabled: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := st.CreateCDNSource(tenant, mod.ID, &store.CDNSource{
|
||||||
|
ID: "s1", URL: srv.URL, SourceKind: "plain",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = collectCDNPrefixRows(context.Background(), st, srv.Client(), tenant, mod, []*store.CDNSource{{ID: "s1", URL: srv.URL, SourceKind: "plain"}}, nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error when stale disabled and no cache")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,86 +5,116 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"evobgp/internal/httpclient"
|
||||||
"evobgp/internal/store"
|
"evobgp/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type prefetchTask struct {
|
||||||
|
tenantID string
|
||||||
|
mod *store.Module
|
||||||
|
src *store.CDNSource
|
||||||
|
}
|
||||||
|
|
||||||
// PrefetchCDNSourceETags performs conditional GETs for CDN sources; on 200 parses CIDRs into module_prefix_snapshot.
|
// PrefetchCDNSourceETags performs conditional GETs for CDN sources; on 200 parses CIDRs into module_prefix_snapshot.
|
||||||
func PrefetchCDNSourceETags(ctx context.Context, st store.Backend, hc *http.Client) error {
|
func PrefetchCDNSourceETags(ctx context.Context, st store.Backend, hc *http.Client) error {
|
||||||
if hc == nil {
|
if hc == nil {
|
||||||
hc = http.DefaultClient
|
hc = httpclient.New(httpclient.DefaultTimeout)
|
||||||
|
}
|
||||||
|
if ctx == nil {
|
||||||
|
ctx = context.Background()
|
||||||
}
|
}
|
||||||
tenants, err := st.ListTenantIDs()
|
tenants, err := st.ListTenantIDs()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
now := time.Now().UTC()
|
var tasks []prefetchTask
|
||||||
for _, tid := range tenants {
|
for _, tid := range tenants {
|
||||||
for _, mod := range st.ListModules(tid) {
|
for _, mod := range st.ListModules(tid) {
|
||||||
if mod == nil || !mod.Enabled || mod.Type != "CDN_CIDRS" {
|
if mod == nil || !mod.Enabled || mod.Type != "CDN_CIDRS" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
omod, err := st.GetModule(tid, mod.ID)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
sources, err := st.ListCDNSources(tid, mod.ID)
|
sources, err := st.ListCDNSources(tid, mod.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
var prior []store.PrefixRow
|
|
||||||
if snap, ok, _ := st.GetModulePrefixSnapshot(tid, mod.ID); ok && snap != nil {
|
|
||||||
prior = snap.Prefixes
|
|
||||||
}
|
|
||||||
for _, src := range sources {
|
for _, src := range sources {
|
||||||
if src == nil || strings.TrimSpace(src.URL) == "" {
|
if src != nil && strings.TrimSpace(src.URL) != "" {
|
||||||
continue
|
tasks = append(tasks, prefetchTask{tenantID: tid, mod: mod, src: src})
|
||||||
}
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimSpace(src.URL), nil)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if etag := strings.TrimSpace(src.Etag); etag != "" {
|
|
||||||
req.Header.Set("If-None-Match", etag)
|
|
||||||
}
|
|
||||||
resp, err := hc.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if resp.StatusCode == http.StatusNotModified {
|
|
||||||
_ = resp.Body.Close()
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
_, _ = io.Copy(io.Discard, resp.Body)
|
|
||||||
_ = resp.Body.Close()
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
|
|
||||||
_ = resp.Body.Close()
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
prefixStrs, err := parseCDNBody(string(body), src)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
newEtag := strings.TrimSpace(resp.Header.Get("ETag"))
|
|
||||||
patch := &store.CDNSourcePatch{LastRefreshedAt: &now}
|
|
||||||
if newEtag != "" && newEtag != strings.TrimSpace(src.Etag) {
|
|
||||||
e := newEtag
|
|
||||||
patch.Etag = &e
|
|
||||||
}
|
|
||||||
_, _ = st.UpdateCDNSource(tid, mod.ID, src.ID, patch)
|
|
||||||
rows := cdnRowsFromParsed(omod, src, prefixStrs)
|
|
||||||
_ = mergeCDNSourceIntoModuleSnapshot(st, tid, omod, src.ID, rows)
|
|
||||||
_ = prior // prior may be stale after merge; refresh for next source in loop
|
|
||||||
if snap, ok, _ := st.GetModulePrefixSnapshot(tid, mod.ID); ok && snap != nil {
|
|
||||||
prior = snap.Prefixes
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if len(tasks) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
sem := make(chan struct{}, collectConcurrency())
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for _, task := range tasks {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(t prefetchTask) {
|
||||||
|
defer wg.Done()
|
||||||
|
sem <- struct{}{}
|
||||||
|
defer func() { <-sem }()
|
||||||
|
prefetchOneCDNSource(ctx, st, hc, t)
|
||||||
|
}(task)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func prefetchOneCDNSource(ctx context.Context, st store.Backend, hc *http.Client, t prefetchTask) {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
tid, mod, src := t.tenantID, t.mod, t.src
|
||||||
|
u := strings.TrimSpace(src.URL)
|
||||||
|
if _, err := ValidateCDNURL(u); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := ResolveCDNURLHost(ctx, u); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
omod, err := st.GetModule(tid, mod.ID)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if etag := strings.TrimSpace(src.Etag); etag != "" {
|
||||||
|
req.Header.Set("If-None-Match", etag)
|
||||||
|
}
|
||||||
|
resp, err := upstreamHTTPDo(ctx, hc, req)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if resp.StatusCode == http.StatusNotModified {
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
_, _ = io.Copy(io.Discard, resp.Body)
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
prefixStrs, err := parseCDNBody(string(body), src)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
newEtag := strings.TrimSpace(resp.Header.Get("ETag"))
|
||||||
|
patch := &store.CDNSourcePatch{LastRefreshedAt: &now}
|
||||||
|
if newEtag != "" && newEtag != strings.TrimSpace(src.Etag) {
|
||||||
|
e := newEtag
|
||||||
|
patch.Etag = &e
|
||||||
|
}
|
||||||
|
_, _ = st.UpdateCDNSource(tid, mod.ID, src.ID, patch)
|
||||||
|
rows := cdnRowsFromParsed(omod, src, prefixStrs)
|
||||||
|
_ = mergeCDNSourceIntoModuleSnapshot(st, tid, omod, src.ID, rows)
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"evobgp/internal/birdfmt"
|
"evobgp/internal/birdfmt"
|
||||||
|
"evobgp/internal/httpclient"
|
||||||
"evobgp/internal/observability"
|
"evobgp/internal/observability"
|
||||||
"evobgp/internal/store"
|
"evobgp/internal/store"
|
||||||
|
|
||||||
@@ -50,7 +51,7 @@ func MaterializedASPrefixKey(asn int64) string {
|
|||||||
// It does not create a new config revision.
|
// It does not create a new config revision.
|
||||||
func RefreshModuleIngest(ctx context.Context, st store.Backend, hc *http.Client, tenantID, moduleID string) error {
|
func RefreshModuleIngest(ctx context.Context, st store.Backend, hc *http.Client, tenantID, moduleID string) error {
|
||||||
if hc == nil {
|
if hc == nil {
|
||||||
hc = http.DefaultClient
|
hc = httpclient.New(httpclient.DefaultTimeout)
|
||||||
}
|
}
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
mod, err := st.GetModule(tenantID, moduleID)
|
mod, err := st.GetModule(tenantID, moduleID)
|
||||||
@@ -84,7 +85,7 @@ func RefreshModuleIngest(ctx context.Context, st store.Backend, hc *http.Client,
|
|||||||
// If materialized prefixes are unchanged, returns latest revision id without creating a duplicate.
|
// If materialized prefixes are unchanged, returns latest revision id without creating a duplicate.
|
||||||
func RenderTenantRevision(ctx context.Context, st store.Backend, hc *http.Client, tenantID, triggerModuleID string) (revisionID string, err error) {
|
func RenderTenantRevision(ctx context.Context, st store.Backend, hc *http.Client, tenantID, triggerModuleID string) (revisionID string, err error) {
|
||||||
if hc == nil {
|
if hc == nil {
|
||||||
hc = http.DefaultClient
|
hc = httpclient.New(httpclient.DefaultTimeout)
|
||||||
}
|
}
|
||||||
agg, err := aggregateTenantPrefixRowsAll(ctx, st, hc, tenantID)
|
agg, err := aggregateTenantPrefixRowsAll(ctx, st, hc, tenantID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -117,7 +118,7 @@ func RenderTenantRevision(ctx context.Context, st store.Backend, hc *http.Client
|
|||||||
func RenderTenantRevisionFromPrefixes(ctx context.Context, st store.Backend, hc *http.Client, tenantID, triggerModuleID string, rows []store.PrefixRow) (revisionID string, err error) {
|
func RenderTenantRevisionFromPrefixes(ctx context.Context, st store.Backend, hc *http.Client, tenantID, triggerModuleID string, rows []store.PrefixRow) (revisionID string, err error) {
|
||||||
_ = ctx
|
_ = ctx
|
||||||
if hc == nil {
|
if hc == nil {
|
||||||
hc = http.DefaultClient
|
hc = httpclient.New(httpclient.DefaultTimeout)
|
||||||
}
|
}
|
||||||
agg := append([]store.PrefixRow(nil), rows...)
|
agg := append([]store.PrefixRow(nil), rows...)
|
||||||
rawCount := len(agg)
|
rawCount := len(agg)
|
||||||
@@ -176,7 +177,7 @@ func collectModulePrefixRows(ctx context.Context, st store.Backend, hc *http.Cli
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
sort.Slice(list, func(i, j int) bool { return list[i].ASN < list[j].ASN })
|
sort.Slice(list, func(i, j int) bool { return list[i].ASN < list[j].ASN })
|
||||||
return collectASPrefixRows(ctx, st, hc, tenantID, mod, list)
|
return collectASPrefixRows(ctx, st, hc, tenantID, mod, list, priorSnapshot)
|
||||||
case "CDN_CIDRS":
|
case "CDN_CIDRS":
|
||||||
sources, err := st.ListCDNSources(tenantID, moduleID)
|
sources, err := st.ListCDNSources(tenantID, moduleID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -192,7 +193,7 @@ func collectModulePrefixRows(ctx context.Context, st store.Backend, hc *http.Cli
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return collectDomainPrefixRows(ctx, hc, mod, profiles, policy, entries)
|
return collectDomainPrefixRows(ctx, hc, mod, profiles, policy, entries, priorSnapshot)
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("pipeline: unknown module type %q", mod.Type)
|
return nil, fmt.Errorf("pipeline: unknown module type %q", mod.Type)
|
||||||
}
|
}
|
||||||
@@ -1029,7 +1030,7 @@ func renderPeersBirdFragment(st store.Backend, tenantID string, loc birdLocals)
|
|||||||
}
|
}
|
||||||
pol := parsePeerPolicies(p.PoliciesJSON)
|
pol := parsePeerPolicies(p.PoliciesJSON)
|
||||||
lv4, lv6, asn := effectivePeerLocals(loc, pol)
|
lv4, lv6, asn := effectivePeerLocals(loc, pol)
|
||||||
proto := peerProtocolName(p.ID)
|
proto := birdfmt.PeerProtocolName(p.ID)
|
||||||
ra := uint32(p.RemoteASN)
|
ra := uint32(p.RemoteASN)
|
||||||
if addr.Is4() {
|
if addr.Is4() {
|
||||||
opts := birdfmt.BGPPeerFromTemplateOptions{
|
opts := birdfmt.BGPPeerFromTemplateOptions{
|
||||||
@@ -1083,17 +1084,6 @@ func parsePeerPolicies(raw string) peerPolicyJSON {
|
|||||||
return pol
|
return pol
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildExpandedBirdText concatenates bird.conf and the contents of each standard include (for UI / preview).
|
// buildExpandedBirdText concatenates bird.conf and the contents of each standard include (for UI / preview).
|
||||||
func buildExpandedBirdText(main string, frags map[string]string) string {
|
func buildExpandedBirdText(main string, frags map[string]string) string {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package pipeline
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"evobgp/internal/birdfmt"
|
||||||
|
"evobgp/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BirdLocalsForSpeaker merges tenant settings with per-speaker meta_json overrides.
|
||||||
|
func BirdLocalsForSpeaker(st store.Backend, tenantID, speakerID string) birdLocals {
|
||||||
|
loc := birdLocalsFromStore(st, tenantID)
|
||||||
|
if st == nil || strings.TrimSpace(speakerID) == "" {
|
||||||
|
return loc
|
||||||
|
}
|
||||||
|
sp, err := st.GetSpeaker(tenantID, speakerID)
|
||||||
|
if err != nil || sp == nil {
|
||||||
|
return loc
|
||||||
|
}
|
||||||
|
meta := store.ParseSpeakerMeta(sp.MetaJSON)
|
||||||
|
if s := strings.TrimSpace(meta.BirdBgpSourceIPv4); s != "" {
|
||||||
|
loc.routerID = s
|
||||||
|
loc.localV4 = s
|
||||||
|
}
|
||||||
|
if s := strings.TrimSpace(meta.BirdBgpSourceIPv6); s != "" {
|
||||||
|
loc.localV6 = s
|
||||||
|
}
|
||||||
|
return loc
|
||||||
|
}
|
||||||
|
|
||||||
|
// OverlayFragmentsForSpeaker re-renders bird.conf and peers fragment with speaker-specific BIRD locals.
|
||||||
|
func OverlayFragmentsForSpeaker(st store.Backend, tenantID, speakerID, revisionID string, frags map[string]string) (map[string]string, error) {
|
||||||
|
if frags == nil {
|
||||||
|
return nil, fmt.Errorf("pipeline: overlay: nil fragments")
|
||||||
|
}
|
||||||
|
locals := BirdLocalsForSpeaker(st, tenantID, speakerID)
|
||||||
|
out := make(map[string]string, len(frags))
|
||||||
|
for k, v := range frags {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
moduleHint := "aggregate"
|
||||||
|
if main := frags["bird.conf"]; main != "" {
|
||||||
|
if idx := strings.Index(main, "trigger module "); idx >= 0 {
|
||||||
|
rest := main[idx+len("trigger module "):]
|
||||||
|
if end := strings.Index(rest, ")"); end > 0 {
|
||||||
|
moduleHint = strings.TrimSpace(rest[:end])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
main, err := birdfmt.RenderMainBirdConf(birdfmt.MainBirdConfOptions{
|
||||||
|
RouterID: locals.routerID,
|
||||||
|
Includes: birdfmt.StandardIncludeFragments(),
|
||||||
|
Preamble: fmt.Sprintf("EvoBGP tenant aggregate config (trigger module %s) revision %s speaker %s", moduleHint, revisionID, speakerID),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out["bird.conf"] = main
|
||||||
|
peersBody, err := renderPeersBirdFragment(st, tenantID, locals)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
pPeers := birdfmt.FragmentIncludePath(birdfmt.FragmentPeers)
|
||||||
|
out[pPeers] = peersBody
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package pipeline_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"evobgp/internal/pipeline"
|
||||||
|
"evobgp/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestOverlayFragmentsForSpeaker_differentRouterID(t *testing.T) {
|
||||||
|
m := store.NewMemory()
|
||||||
|
m.SeedDemo()
|
||||||
|
tenant, _, _, _, _ := m.DemoIDs()
|
||||||
|
sp1, _ := m.CreateSpeaker(tenant, &store.Speaker{
|
||||||
|
Role: "replica",
|
||||||
|
Endpoint: "https://203.0.113.1",
|
||||||
|
MetaJSON: `{"bird_bgp_source_ipv4":"203.0.113.1"}`,
|
||||||
|
})
|
||||||
|
sp2, _ := m.CreateSpeaker(tenant, &store.Speaker{
|
||||||
|
Role: "replica",
|
||||||
|
Endpoint: "https://203.0.113.2",
|
||||||
|
MetaJSON: `{"bird_bgp_source_ipv4":"203.0.113.2"}`,
|
||||||
|
})
|
||||||
|
base := map[string]string{
|
||||||
|
"bird.conf": "router id 192.0.2.1;\n# EvoBGP tenant aggregate config (trigger module mod) revision rev1",
|
||||||
|
}
|
||||||
|
out1, err := pipeline.OverlayFragmentsForSpeaker(m, tenant, sp1.ID, "rev1", base)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
out2, err := pipeline.OverlayFragmentsForSpeaker(m, tenant, sp2.ID, "rev1", base)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out1["bird.conf"], "203.0.113.1") {
|
||||||
|
t.Fatalf("sp1 router: %s", out1["bird.conf"])
|
||||||
|
}
|
||||||
|
if !strings.Contains(out2["bird.conf"], "203.0.113.2") {
|
||||||
|
t.Fatalf("sp2 router: %s", out2["bird.conf"])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,13 +7,14 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
"evobgp/internal/httpclient"
|
||||||
"evobgp/internal/store"
|
"evobgp/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RefreshTenantModules ingests all listed modules in parallel and updates per-module snapshots.
|
// RefreshTenantModules ingests all listed modules in parallel and updates per-module snapshots.
|
||||||
func RefreshTenantModules(ctx context.Context, st store.Backend, hc *http.Client, tenantID string, moduleIDs []string) error {
|
func RefreshTenantModules(ctx context.Context, st store.Backend, hc *http.Client, tenantID string, moduleIDs []string) error {
|
||||||
if hc == nil {
|
if hc == nil {
|
||||||
hc = http.DefaultClient
|
hc = httpclient.New(httpclient.DefaultTimeout)
|
||||||
}
|
}
|
||||||
var ids []string
|
var ids []string
|
||||||
seen := make(map[string]struct{})
|
seen := make(map[string]struct{})
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package pipeline
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"evobgp/internal/httpclient"
|
||||||
|
)
|
||||||
|
|
||||||
|
func upstreamHTTPDo(ctx context.Context, hc *http.Client, req *http.Request) (*http.Response, error) {
|
||||||
|
if hc == nil {
|
||||||
|
hc = httpclient.New(httpclient.DefaultTimeout)
|
||||||
|
}
|
||||||
|
resp, err := httpclient.DoWithBreaker(ctx, hc, req, 3)
|
||||||
|
if err != nil {
|
||||||
|
if req.URL != nil {
|
||||||
|
return nil, fmt.Errorf("cdn fetch %s: %w", req.URL.String(), err)
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultRepoTimeout = 60 * time.Second
|
||||||
|
|
||||||
|
// boundedRepoCtx returns a context with default repository I/O timeout.
|
||||||
|
func boundedRepoCtx(parent context.Context) (context.Context, context.CancelFunc) {
|
||||||
|
if parent == nil {
|
||||||
|
parent = context.Background()
|
||||||
|
}
|
||||||
|
if _, ok := parent.Deadline(); ok {
|
||||||
|
return parent, func() {}
|
||||||
|
}
|
||||||
|
return context.WithTimeout(parent, defaultRepoTimeout)
|
||||||
|
}
|
||||||
@@ -20,6 +20,28 @@ func NewJobAuditWriter(pool *pgxpool.Pool) *JobAuditWriter {
|
|||||||
return &JobAuditWriter{pool: pool}
|
return &JobAuditWriter{pool: pool}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpsertQueued inserts a queued job row (best-effort).
|
||||||
|
func (w *JobAuditWriter) UpsertQueued(ctx context.Context, tenantID, jobID, kind string, idempotencyKey *string, moduleID *string, meta map[string]any) {
|
||||||
|
if w == nil || w.pool == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
metaJSON, _ := json.Marshal(meta)
|
||||||
|
var idem any
|
||||||
|
if idempotencyKey != nil && *idempotencyKey != "" {
|
||||||
|
idem = *idempotencyKey
|
||||||
|
}
|
||||||
|
var mod any
|
||||||
|
if moduleID != nil && *moduleID != "" {
|
||||||
|
mod = *moduleID
|
||||||
|
}
|
||||||
|
_, _ = w.pool.Exec(ctx, `
|
||||||
|
INSERT INTO job_audit (id, tenant_id, kind, status, idempotency_key, module_id, meta_json, created_at)
|
||||||
|
VALUES ($1::uuid, $2::uuid, $3, 'queued', $4, $5::uuid, $6::jsonb, now())
|
||||||
|
ON CONFLICT (tenant_id, idempotency_key) WHERE idempotency_key IS NOT NULL
|
||||||
|
DO UPDATE SET status='queued', meta_json=EXCLUDED.meta_json, module_id=EXCLUDED.module_id`,
|
||||||
|
jobID, tenantID, kind, idem, mod, metaJSON)
|
||||||
|
}
|
||||||
|
|
||||||
// UpsertRunning inserts or updates a running job row (best-effort).
|
// UpsertRunning inserts or updates a running job row (best-effort).
|
||||||
func (w *JobAuditWriter) UpsertRunning(ctx context.Context, tenantID, jobID, kind string, idempotencyKey *string, meta map[string]any) {
|
func (w *JobAuditWriter) UpsertRunning(ctx context.Context, tenantID, jobID, kind string, idempotencyKey *string, meta map[string]any) {
|
||||||
if w == nil || w.pool == nil {
|
if w == nil || w.pool == nil {
|
||||||
@@ -33,8 +55,7 @@ func (w *JobAuditWriter) UpsertRunning(ctx context.Context, tenantID, jobID, kin
|
|||||||
_, _ = w.pool.Exec(ctx, `
|
_, _ = w.pool.Exec(ctx, `
|
||||||
INSERT INTO job_audit (id, tenant_id, kind, status, idempotency_key, meta_json, created_at, started_at)
|
INSERT INTO job_audit (id, tenant_id, kind, status, idempotency_key, meta_json, created_at, started_at)
|
||||||
VALUES ($1::uuid, $2::uuid, $3, 'running', $4, $5::jsonb, now(), now())
|
VALUES ($1::uuid, $2::uuid, $3, 'running', $4, $5::jsonb, now(), now())
|
||||||
ON CONFLICT (tenant_id, idempotency_key) WHERE idempotency_key IS NOT NULL
|
ON CONFLICT (id) DO UPDATE SET status='running', started_at=COALESCE(job_audit.started_at, now()), meta_json=EXCLUDED.meta_json`,
|
||||||
DO UPDATE SET status='running', started_at=now(), meta_json=EXCLUDED.meta_json`,
|
|
||||||
jobID, tenantID, kind, idem, metaJSON)
|
jobID, tenantID, kind, idem, metaJSON)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -646,8 +646,21 @@ func (p *Postgres) UpdateSpeaker(tenantID, id string, patch *store.SpeakerPatch)
|
|||||||
return p.GetSpeaker(tenantID, id)
|
return p.GetSpeaker(tenantID, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Postgres) GetRevision(tenantID, revisionID string) (*store.Revision, error) {
|
func (p *Postgres) DeleteSpeaker(tenantID, id string) error {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
tag, err := p.pool.Exec(ctx, `DELETE FROM bgp_speaker WHERE id=$1 AND tenant_id=$2`, id, tenantID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return store.ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Postgres) GetRevision(tenantID, revisionID string) (*store.Revision, error) {
|
||||||
|
ctx, cancel := boundedRepoCtx(context.Background())
|
||||||
|
defer cancel()
|
||||||
var r store.Revision
|
var r store.Revision
|
||||||
var mod *string
|
var mod *string
|
||||||
var parent *string
|
var parent *string
|
||||||
@@ -679,6 +692,33 @@ func (p *Postgres) GetRevision(tenantID, revisionID string) (*store.Revision, er
|
|||||||
return &r, nil
|
return &r, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p *Postgres) GetRevisionSummary(tenantID, revisionID string) (*store.Revision, error) {
|
||||||
|
ctx, cancel := boundedRepoCtx(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
var r store.Revision
|
||||||
|
var mod *string
|
||||||
|
var parent *string
|
||||||
|
var prefixCount int
|
||||||
|
err := p.pool.QueryRow(ctx, `
|
||||||
|
SELECT id::text, tenant_id::text, module_id::text, content_hash, parent_revision_id::text,
|
||||||
|
COALESCE((meta_json->>'materialized_prefix_count')::int, 0), created_at
|
||||||
|
FROM config_revision WHERE id=$1 AND tenant_id=$2`, revisionID, tenantID).Scan(
|
||||||
|
&r.ID, &r.TenantID, &mod, &r.ContentHash, &parent, &prefixCount, &r.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, store.ErrNotFound
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if mod != nil {
|
||||||
|
r.ModuleID = *mod
|
||||||
|
}
|
||||||
|
r.ParentRevisionID = strOrNil(parent)
|
||||||
|
r.MaterializedPrefixCount = prefixCount
|
||||||
|
r.PreviewFragments = map[string]string{}
|
||||||
|
return &r, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (p *Postgres) ListRevisions(tenantID, moduleID string, cursor string, limit int) ([]*store.Revision, string, bool) {
|
func (p *Postgres) ListRevisions(tenantID, moduleID string, cursor string, limit int) ([]*store.Revision, string, bool) {
|
||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
limit = 50
|
limit = 50
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
|
|
||||||
"evobgp/internal/broker"
|
"evobgp/internal/broker"
|
||||||
"evobgp/internal/config"
|
"evobgp/internal/config"
|
||||||
|
"evobgp/internal/httpclient"
|
||||||
"evobgp/internal/jobs"
|
"evobgp/internal/jobs"
|
||||||
"evobgp/internal/pipeline"
|
"evobgp/internal/pipeline"
|
||||||
"evobgp/internal/store"
|
"evobgp/internal/store"
|
||||||
@@ -108,6 +109,9 @@ func postTenantRefresh(ctx context.Context, deps *Deps, moduleIDs []string, idem
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
req.GetBody = func() (io.ReadCloser, error) {
|
||||||
|
return io.NopCloser(bytes.NewReader(body)), nil
|
||||||
|
}
|
||||||
req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(deps.APIToken))
|
req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(deps.APIToken))
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
if idempotencyKey != "" {
|
if idempotencyKey != "" {
|
||||||
@@ -115,9 +119,9 @@ func postTenantRefresh(ctx context.Context, deps *Deps, moduleIDs []string, idem
|
|||||||
}
|
}
|
||||||
hc := deps.HTTP
|
hc := deps.HTTP
|
||||||
if hc == nil {
|
if hc == nil {
|
||||||
hc = http.DefaultClient
|
hc = httpclient.New(httpclient.DefaultTimeout)
|
||||||
}
|
}
|
||||||
resp, err := hc.Do(req)
|
resp, err := httpclient.DoWithRetry(ctx, hc, req, 3)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,8 +73,11 @@ type Backend interface {
|
|||||||
GetSpeakerAnyTenant(speakerID string) (*Speaker, error)
|
GetSpeakerAnyTenant(speakerID string) (*Speaker, error)
|
||||||
CreateSpeaker(tenantID string, in *Speaker) (*Speaker, error)
|
CreateSpeaker(tenantID string, in *Speaker) (*Speaker, error)
|
||||||
UpdateSpeaker(tenantID, id string, patch *SpeakerPatch) (*Speaker, error)
|
UpdateSpeaker(tenantID, id string, patch *SpeakerPatch) (*Speaker, error)
|
||||||
|
DeleteSpeaker(tenantID, id string) error
|
||||||
|
|
||||||
GetRevision(tenantID, revisionID string) (*Revision, error)
|
GetRevision(tenantID, revisionID string) (*Revision, error)
|
||||||
|
// GetRevisionSummary returns revision metadata without preview_fragments payloads.
|
||||||
|
GetRevisionSummary(tenantID, revisionID string) (*Revision, error)
|
||||||
ListRevisions(tenantID, moduleID string, cursor string, limit int) (items []*Revision, nextCursor string, hasMore bool)
|
ListRevisions(tenantID, moduleID string, cursor string, limit int) (items []*Revision, nextCursor string, hasMore bool)
|
||||||
ListRevisionPrefixes(tenantID, revisionID string, cursor string, limit int) (prefixes []PrefixRow, next string, more bool)
|
ListRevisionPrefixes(tenantID, revisionID string, cursor string, limit int) (prefixes []PrefixRow, next string, more bool)
|
||||||
CreateRollbackRevision(tenantID, sourceRevisionID string) (newID string, err error)
|
CreateRollbackRevision(tenantID, sourceRevisionID string) (newID string, err error)
|
||||||
|
|||||||
@@ -424,14 +424,19 @@ func (m *Memory) GetModule(tenantID, moduleID string) (*Module, error) {
|
|||||||
func (m *Memory) GetRevision(tenantID, revisionID string) (*Revision, error) {
|
func (m *Memory) GetRevision(tenantID, revisionID string) (*Revision, error) {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
defer m.mu.RUnlock()
|
defer m.mu.RUnlock()
|
||||||
rev, ok := m.revisions[revisionID]
|
return m.getRevisionLocked(tenantID, revisionID)
|
||||||
if !ok {
|
}
|
||||||
return nil, ErrNotFound
|
|
||||||
|
func (m *Memory) GetRevisionSummary(tenantID, revisionID string) (*Revision, error) {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
rev, err := m.getRevisionLocked(tenantID, revisionID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
if rev.TenantID != tenantID {
|
cp := *rev
|
||||||
return nil, ErrTenantScope
|
cp.PreviewFragments = nil
|
||||||
}
|
return &cp, nil
|
||||||
return rev, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Memory) GetSpeaker(tenantID, speakerID string) (*Speaker, error) {
|
func (m *Memory) GetSpeaker(tenantID, speakerID string) (*Speaker, error) {
|
||||||
|
|||||||
@@ -779,6 +779,18 @@ func (m *Memory) UpdateSpeaker(tenantID, id string, patch *SpeakerPatch) (*Speak
|
|||||||
return sp, nil
|
return sp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Memory) DeleteSpeaker(tenantID, id string) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
sp, ok := m.speakers[id]
|
||||||
|
if !ok || sp.TenantID != tenantID {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
delete(m.speakers, id)
|
||||||
|
delete(m.publishedRevision, id)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Memory) ListRevisionPrefixes(tenantID, revisionID string, cursor string, limit int) ([]PrefixRow, string, bool) {
|
func (m *Memory) ListRevisionPrefixes(tenantID, revisionID string, cursor string, limit int) ([]PrefixRow, string, bool) {
|
||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
limit = 50
|
limit = 50
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SpeakerMeta holds well-known keys from bgp_speaker.meta_json.
|
||||||
|
type SpeakerMeta struct {
|
||||||
|
AgentDomain string `json:"agent_domain,omitempty"`
|
||||||
|
AgentSecret string `json:"agent_secret,omitempty"`
|
||||||
|
AgentPort int `json:"agent_port,omitempty"`
|
||||||
|
NodeIPv4 string `json:"node_ipv4,omitempty"`
|
||||||
|
BirdBgpSourceIPv4 string `json:"bird_bgp_source_ipv4,omitempty"`
|
||||||
|
BirdBgpSourceIPv6 string `json:"bird_bgp_source_ipv6,omitempty"`
|
||||||
|
NodeEnrolledAt string `json:"node_enrolled_at,omitempty"`
|
||||||
|
LastDispatchAt string `json:"last_dispatch_at,omitempty"`
|
||||||
|
LastDispatchError string `json:"last_dispatch_error,omitempty"`
|
||||||
|
LastDispatchStatus string `json:"last_dispatch_status,omitempty"`
|
||||||
|
SyncStatus string `json:"sync_status,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseSpeakerMeta decodes meta_json object; unknown keys are ignored.
|
||||||
|
func ParseSpeakerMeta(metaJSON string) SpeakerMeta {
|
||||||
|
raw := strings.TrimSpace(metaJSON)
|
||||||
|
if raw == "" || raw == "{}" {
|
||||||
|
return SpeakerMeta{}
|
||||||
|
}
|
||||||
|
var m SpeakerMeta
|
||||||
|
_ = json.Unmarshal([]byte(raw), &m)
|
||||||
|
if m.AgentPort == 0 {
|
||||||
|
m.AgentPort = 8443
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// SpeakerMetaJSON marshals SpeakerMeta to a JSON object string.
|
||||||
|
func SpeakerMetaJSON(m SpeakerMeta) string {
|
||||||
|
b, err := json.Marshal(m)
|
||||||
|
if err != nil {
|
||||||
|
return "{}"
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MergeSpeakerMetaJSON merges patch into existing meta_json string.
|
||||||
|
func MergeSpeakerMetaJSON(existing string, patch SpeakerMeta) string {
|
||||||
|
cur := ParseSpeakerMeta(existing)
|
||||||
|
if patch.AgentDomain != "" {
|
||||||
|
cur.AgentDomain = patch.AgentDomain
|
||||||
|
}
|
||||||
|
if patch.AgentSecret != "" {
|
||||||
|
cur.AgentSecret = patch.AgentSecret
|
||||||
|
}
|
||||||
|
if patch.AgentPort != 0 {
|
||||||
|
cur.AgentPort = patch.AgentPort
|
||||||
|
}
|
||||||
|
if patch.NodeIPv4 != "" {
|
||||||
|
cur.NodeIPv4 = patch.NodeIPv4
|
||||||
|
}
|
||||||
|
if patch.BirdBgpSourceIPv4 != "" {
|
||||||
|
cur.BirdBgpSourceIPv4 = patch.BirdBgpSourceIPv4
|
||||||
|
}
|
||||||
|
if patch.BirdBgpSourceIPv6 != "" {
|
||||||
|
cur.BirdBgpSourceIPv6 = patch.BirdBgpSourceIPv6
|
||||||
|
}
|
||||||
|
if patch.NodeEnrolledAt != "" {
|
||||||
|
cur.NodeEnrolledAt = patch.NodeEnrolledAt
|
||||||
|
}
|
||||||
|
if patch.LastDispatchAt != "" {
|
||||||
|
cur.LastDispatchAt = patch.LastDispatchAt
|
||||||
|
}
|
||||||
|
if patch.LastDispatchStatus == "ok" {
|
||||||
|
cur.LastDispatchError = ""
|
||||||
|
} else if patch.LastDispatchError != "" {
|
||||||
|
cur.LastDispatchError = patch.LastDispatchError
|
||||||
|
}
|
||||||
|
if patch.LastDispatchStatus != "" {
|
||||||
|
cur.LastDispatchStatus = patch.LastDispatchStatus
|
||||||
|
}
|
||||||
|
if patch.SyncStatus != "" {
|
||||||
|
cur.SyncStatus = patch.SyncStatus
|
||||||
|
}
|
||||||
|
return SpeakerMetaJSON(cur)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IPv4FromEndpoint extracts an IPv4 from endpoint URL host when present.
|
||||||
|
func IPv4FromEndpoint(endpoint string) string {
|
||||||
|
ep := strings.TrimSpace(endpoint)
|
||||||
|
if ep == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if !strings.Contains(ep, "://") {
|
||||||
|
ep = "https://" + ep
|
||||||
|
}
|
||||||
|
u, err := url.Parse(ep)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
host := strings.TrimSpace(u.Hostname())
|
||||||
|
if host == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if ip := net.ParseIP(host); ip != nil && ip.To4() != nil {
|
||||||
|
return ip.String()
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidIPv4 reports whether s is a dotted-quad IPv4 address.
|
||||||
|
func ValidIPv4(s string) bool {
|
||||||
|
ip := net.ParseIP(strings.TrimSpace(s))
|
||||||
|
return ip != nil && ip.To4() != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AgentSyncURL returns HTTPS sync URL for a speaker with agent_domain configured.
|
||||||
|
func AgentSyncURL(meta SpeakerMeta) string {
|
||||||
|
domain := strings.TrimSpace(meta.AgentDomain)
|
||||||
|
if domain == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "https://" + strings.TrimSuffix(domain, "/") + "/v1/agent/sync"
|
||||||
|
}
|
||||||
|
|
||||||
|
// AgentHealthURL returns HTTPS health URL for agent_domain.
|
||||||
|
func AgentHealthURL(meta SpeakerMeta) string {
|
||||||
|
return agentHTTPSURL(meta, "/v1/agent/health")
|
||||||
|
}
|
||||||
|
|
||||||
|
// AgentBirdProtocolsURL returns HTTPS bird protocols URL for agent_domain.
|
||||||
|
func AgentBirdProtocolsURL(meta SpeakerMeta) string {
|
||||||
|
return agentHTTPSURL(meta, "/v1/agent/bird/protocols")
|
||||||
|
}
|
||||||
|
|
||||||
|
func agentHTTPSURL(meta SpeakerMeta, path string) string {
|
||||||
|
domain := strings.TrimSpace(meta.AgentDomain)
|
||||||
|
if domain == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "https://" + strings.TrimSuffix(domain, "/") + path
|
||||||
|
}
|
||||||
|
|
||||||
|
// SpeakerNeedsRemoteDispatch reports whether deploy_apply should wake this speaker via agent HTTP.
|
||||||
|
func SpeakerNeedsRemoteDispatch(role string, meta SpeakerMeta) bool {
|
||||||
|
r := strings.ToLower(strings.TrimSpace(role))
|
||||||
|
if r == "master" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(meta.AgentDomain) != "" && strings.TrimSpace(meta.AgentSecret) != ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package store_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"evobgp/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseSpeakerMeta_defaults(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
m := store.ParseSpeakerMeta(`{"agent_domain":"bgp1.example.com"}`)
|
||||||
|
if m.AgentPort != 8443 {
|
||||||
|
t.Fatalf("default port: got %d", m.AgentPort)
|
||||||
|
}
|
||||||
|
if m.AgentDomain != "bgp1.example.com" {
|
||||||
|
t.Fatalf("domain: %q", m.AgentDomain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIPv4FromEndpoint(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
if got := store.IPv4FromEndpoint("https://203.0.113.10:8443"); got != "203.0.113.10" {
|
||||||
|
t.Fatalf("got %q", got)
|
||||||
|
}
|
||||||
|
if got := store.IPv4FromEndpoint("bgp-dc2.example.com"); got != "" {
|
||||||
|
t.Fatalf("hostname should be empty, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentSyncURL(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
u := store.AgentSyncURL(store.SpeakerMeta{AgentDomain: "node.example.com"})
|
||||||
|
if u != "https://node.example.com/v1/agent/sync" {
|
||||||
|
t.Fatalf("got %q", u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergeSpeakerMetaJSON_clearsDispatchErrorOnOk(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
existing := store.SpeakerMetaJSON(store.SpeakerMeta{
|
||||||
|
LastDispatchError: "HTTP 502: bundle 403",
|
||||||
|
LastDispatchStatus: "error",
|
||||||
|
SyncStatus: "error",
|
||||||
|
})
|
||||||
|
merged := store.MergeSpeakerMetaJSON(existing, store.SpeakerMeta{
|
||||||
|
LastDispatchStatus: "ok",
|
||||||
|
SyncStatus: "synced",
|
||||||
|
LastDispatchAt: "2026-05-21T15:06:43Z",
|
||||||
|
})
|
||||||
|
m := store.ParseSpeakerMeta(merged)
|
||||||
|
if m.LastDispatchError != "" {
|
||||||
|
t.Fatalf("LastDispatchError should clear on ok dispatch, got %q", m.LastDispatchError)
|
||||||
|
}
|
||||||
|
if m.LastDispatchStatus != "ok" || m.SyncStatus != "synced" {
|
||||||
|
t.Fatalf("status: dispatch=%q sync=%q", m.LastDispatchStatus, m.SyncStatus)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Warns about commits since the last tag that semantic-release cannot parse.
|
||||||
|
* Exit 0 always — semantic-release still decides release/no-op.
|
||||||
|
*/
|
||||||
|
import { execSync } from 'node:child_process';
|
||||||
|
import parser from 'conventional-commits-parser';
|
||||||
|
|
||||||
|
const RELEASABLE = new Set(['feat', 'fix', 'perf', 'ci', 'refactor']);
|
||||||
|
|
||||||
|
function lastTag() {
|
||||||
|
try {
|
||||||
|
return execSync('git describe --tags --abbrev=0', { encoding: 'utf8' }).trim();
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitsSince(ref) {
|
||||||
|
const range = ref ? `${ref}..HEAD` : 'HEAD';
|
||||||
|
const out = execSync(`git log ${range} --format=%H%x09%s`, { encoding: 'utf8' }).trim();
|
||||||
|
if (!out) return [];
|
||||||
|
return out.split('\n').map((line) => {
|
||||||
|
const [hash, subject] = line.split('\t');
|
||||||
|
return { hash: hash.trim(), subject: subject.trim() };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const tag = lastTag();
|
||||||
|
const commits = commitsSince(tag);
|
||||||
|
const unparseable = [];
|
||||||
|
const releasable = [];
|
||||||
|
|
||||||
|
for (const { hash, subject } of commits) {
|
||||||
|
const parsed = parser.sync(subject);
|
||||||
|
if (!parsed.type) {
|
||||||
|
unparseable.push({ hash: hash.slice(0, 7), subject });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (RELEASABLE.has(parsed.type)) {
|
||||||
|
releasable.push({ hash: hash.slice(0, 7), subject, type: parsed.type });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (commits.length === 0) {
|
||||||
|
console.log(`No new commits since ${tag || 'initial'}.`);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unparseable.length > 0) {
|
||||||
|
console.warn('::warning:: Commits not parseable by semantic-release (no version bump):');
|
||||||
|
for (const b of unparseable) {
|
||||||
|
console.warn(` ${b.hash} ${b.subject}`);
|
||||||
|
}
|
||||||
|
console.warn('Fix: single scope without commas, e.g. refactor(web): summary');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (releasable.length > 0) {
|
||||||
|
console.log(`Releasable since ${tag}: ${releasable.length} commit(s).`);
|
||||||
|
} else {
|
||||||
|
console.warn('::warning:: No releasable commits since last tag — release job will no-op.');
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Same gates as CI job go (subset): gofmt, vet, golangci-lint.
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
Set-Location (Join-Path $PSScriptRoot '..')
|
||||||
|
|
||||||
|
$unfmt = gofmt -l . 2>$null
|
||||||
|
if ($unfmt) {
|
||||||
|
Write-Error "gofmt: unformatted files:`n$unfmt"
|
||||||
|
}
|
||||||
|
go vet ./...
|
||||||
|
$golangci = Get-Command golangci-lint -ErrorAction SilentlyContinue
|
||||||
|
if (-not $golangci) {
|
||||||
|
$golangciPath = Join-Path $env:USERPROFILE 'go\bin\golangci-lint.exe'
|
||||||
|
if (Test-Path $golangciPath) { $golangci = @{ Source = $golangciPath } }
|
||||||
|
}
|
||||||
|
if ($golangci) {
|
||||||
|
& $golangci.Source run
|
||||||
|
} else {
|
||||||
|
Write-Warning 'lint-go: golangci-lint not found, skipping (CI will run it)'
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Same gates as CI job go (subset before full test): gofmt, vet, golangci-lint.
|
||||||
|
set -euxo pipefail
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
UNFMT="$(gofmt -l .)"
|
||||||
|
if [ -n "$UNFMT" ]; then
|
||||||
|
echo "gofmt: unformatted files:" >&2
|
||||||
|
echo "$UNFMT" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
go vet ./...
|
||||||
|
|
||||||
|
if command -v golangci-lint >/dev/null 2>&1; then
|
||||||
|
golangci-lint run
|
||||||
|
else
|
||||||
|
echo "lint-go: golangci-lint not in PATH, skipping (CI will run it)" >&2
|
||||||
|
fi
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Same gates as CI job web (.gitea/workflows/ci.yaml).
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
Set-Location (Join-Path $PSScriptRoot '..' 'web')
|
||||||
|
npm run check
|
||||||
|
npm run lint
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Same gates as CI job web (.gitea/workflows/ci.yaml).
|
||||||
|
set -euxo pipefail
|
||||||
|
cd "$(dirname "$0")/../web"
|
||||||
|
npm run check
|
||||||
|
npm run lint
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Fallback polling: pull → verify → apply signed bundle (profile fallback).
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
INTERVAL="${EVOBGP_SYNC_INTERVAL_SEC:-300}"
|
||||||
|
BASE="${EVOBGP_CONTROL_PLANE_URL:?EVOBGP_CONTROL_PLANE_URL required}"
|
||||||
|
TOKEN="${EVOBGP_NODE_TOKEN:?EVOBGP_NODE_TOKEN required}"
|
||||||
|
SPEAKER="${EVOBGP_SPEAKER_ID:?EVOBGP_SPEAKER_ID required}"
|
||||||
|
PUB="${EVOBGP_BUNDLE_PUBKEY_BASE64:?EVOBGP_BUNDLE_PUBKEY_BASE64 required}"
|
||||||
|
EXTRACT="/etc/bird"
|
||||||
|
BUNDLE="/tmp/evobgp-bundle.tar.gz"
|
||||||
|
SOCKET="${EVOBGP_BIRDC_SOCKET:-/run/bird/bird.ctl}"
|
||||||
|
|
||||||
|
sync_once() {
|
||||||
|
evobgp-node pull-bundle \
|
||||||
|
-base-url "$BASE" \
|
||||||
|
-token "$TOKEN" \
|
||||||
|
-speaker-id "$SPEAKER" \
|
||||||
|
-o "$BUNDLE" || return 1
|
||||||
|
evobgp-node apply-bundle \
|
||||||
|
-f "$BUNDLE" \
|
||||||
|
-extract-dir "$EXTRACT" \
|
||||||
|
-pubkey-base64 "$PUB" \
|
||||||
|
-socket "$SOCKET"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "sync-bundle: polling every ${INTERVAL}s speaker=${SPEAKER}"
|
||||||
|
while true; do
|
||||||
|
if sync_once; then
|
||||||
|
echo "sync-bundle: ok $(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||||
|
else
|
||||||
|
echo "sync-bundle: failed $(date -u +%Y-%m-%dT%H:%M:%SZ)" >&2
|
||||||
|
fi
|
||||||
|
sleep "$INTERVAL"
|
||||||
|
done
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Validates docker-compose.remote-speaker.yaml (same gates as CI job go).
|
||||||
|
set -eu
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
COMPOSE="$ROOT/deploy/compose/docker-compose.remote-speaker.yaml"
|
||||||
|
|
||||||
|
if ! command -v docker >/dev/null 2>&1; then
|
||||||
|
echo "validate-remote-speaker-compose: docker not found, skipping"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "$COMPOSE" ]; then
|
||||||
|
echo "validate-remote-speaker-compose: missing $COMPOSE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Required compose interpolation vars (CI mock values).
|
||||||
|
export EVOBGP_REGISTRY="${EVOBGP_REGISTRY:-git.shts.su/denozord}"
|
||||||
|
export EVOBGP_IMAGE_TAG="${EVOBGP_IMAGE_TAG:-latest}"
|
||||||
|
export EVOBGP_AGENT_SECRET="${EVOBGP_AGENT_SECRET:-ci-test-secret}"
|
||||||
|
export EVOBGP_CONTROL_PLANE_URL="${EVOBGP_CONTROL_PLANE_URL:-https://cp.example.com}"
|
||||||
|
export EVOBGP_NODE_TOKEN="${EVOBGP_NODE_TOKEN:-ci-test-token}"
|
||||||
|
export EVOBGP_SPEAKER_ID="${EVOBGP_SPEAKER_ID:-00000000-0000-0000-0000-000000000001}"
|
||||||
|
export EVOBGP_BUNDLE_PUBKEY_BASE64="${EVOBGP_BUNDLE_PUBKEY_BASE64:-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=}"
|
||||||
|
export AGENT_DOMAIN="${AGENT_DOMAIN:-agent.ci.example.com}"
|
||||||
|
export LETSENCRYPT_EMAIL="${LETSENCRYPT_EMAIL:-ci@example.com}"
|
||||||
|
export CF_DNS_API_TOKEN="${CF_DNS_API_TOKEN:-ci-token}"
|
||||||
|
export PANEL_IP_WHITELIST="${PANEL_IP_WHITELIST:-127.0.0.1/32}"
|
||||||
|
|
||||||
|
# Optional: merge example env files when present (local/docs parity).
|
||||||
|
ENV_ARGS=""
|
||||||
|
for f in \
|
||||||
|
"$ROOT/deploy/compose/.env.remote-speaker.example" \
|
||||||
|
"$ROOT/deploy/compose/.env.remote-speaker-tls.example"; do
|
||||||
|
if [ -f "$f" ]; then
|
||||||
|
ENV_ARGS="$ENV_ARGS --env-file $f"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
docker compose -f "$COMPOSE" $ENV_ARGS config >/dev/null
|
||||||
|
echo "validate-remote-speaker-compose: ok"
|
||||||
@@ -21,8 +21,21 @@ SvelteKit-приложение панели управления EvoBGP. Зап
|
|||||||
npm install
|
npm install
|
||||||
npm run dev
|
npm run dev
|
||||||
npm run check
|
npm run check
|
||||||
|
npm run lint # prettier --check; обязательно перед PR (CI job web)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Из корня репозитория (обе проверки как в CI):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
powershell -NoProfile -File scripts/lint-web.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sh scripts/lint-web.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
При падении `lint`: `npx prettier --write .` в каталоге `web/`, затем снова `check` + `lint`.
|
||||||
|
|
||||||
Добавление компонентов shadcn (из каталога `web/`):
|
Добавление компонентов shadcn (из каталога `web/`):
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
@@ -152,6 +152,13 @@ export type BgpCommunityPatch = Partial<BgpCommunityCreate>;
|
|||||||
export type CommunitiesResponse = Page<BgpCommunity>;
|
export type CommunitiesResponse = Page<BgpCommunity>;
|
||||||
|
|
||||||
// ---- Peers ----
|
// ---- Peers ----
|
||||||
|
export type PeerSessionOnSpeaker = {
|
||||||
|
speaker_id: string;
|
||||||
|
label: string;
|
||||||
|
state: string;
|
||||||
|
poll_error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type PeerRow = {
|
export type PeerRow = {
|
||||||
id: string;
|
id: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
@@ -160,8 +167,20 @@ export type PeerRow = {
|
|||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
session_state: string;
|
session_state: string;
|
||||||
bgp_speaker_id: string | null;
|
bgp_speaker_id: string | null;
|
||||||
|
connected_speaker_id?: string | null;
|
||||||
|
connected_speaker_label?: string;
|
||||||
|
session_on_speakers?: PeerSessionOnSpeaker[];
|
||||||
|
established_on_speakers?: PeerSessionOnSpeaker[];
|
||||||
|
session_mismatch?: boolean;
|
||||||
};
|
};
|
||||||
export type PeersResponse = Page<PeerRow>;
|
export type LiveSpeakerPoll = {
|
||||||
|
speaker_id: string;
|
||||||
|
label: string;
|
||||||
|
ok: boolean;
|
||||||
|
session_count: number;
|
||||||
|
poll_error?: string;
|
||||||
|
};
|
||||||
|
export type PeersResponse = Page<PeerRow> & { live_speaker_poll?: LiveSpeakerPoll[] };
|
||||||
export type BgpPeerCreate = {
|
export type BgpPeerCreate = {
|
||||||
name?: string;
|
name?: string;
|
||||||
neighbor: string;
|
neighbor: string;
|
||||||
@@ -172,19 +191,55 @@ export type BgpPeerCreate = {
|
|||||||
export type BgpPeerPatch = Partial<BgpPeerCreate>;
|
export type BgpPeerPatch = Partial<BgpPeerCreate>;
|
||||||
|
|
||||||
// ---- Speakers ----
|
// ---- Speakers ----
|
||||||
|
export type BgpSessionLive = {
|
||||||
|
name: string;
|
||||||
|
neighbor?: string;
|
||||||
|
state: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SpeakerLiveStatus = {
|
||||||
|
label?: string;
|
||||||
|
agent_ok?: boolean;
|
||||||
|
agent_error?: string;
|
||||||
|
agent_last_sync_at?: string;
|
||||||
|
agent_last_applied_revision_id?: string;
|
||||||
|
bgp_poll_ok?: boolean;
|
||||||
|
bgp_poll_error?: string;
|
||||||
|
bgp_sessions_total?: number;
|
||||||
|
bgp_established?: number;
|
||||||
|
sessions?: BgpSessionLive[];
|
||||||
|
};
|
||||||
|
|
||||||
export type SpeakerRow = {
|
export type SpeakerRow = {
|
||||||
id: string;
|
id: string;
|
||||||
role: string;
|
role: string;
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
last_applied_revision_id: string | null;
|
last_applied_revision_id: string | null;
|
||||||
|
published_revision_id?: string | null;
|
||||||
|
published_at?: string | null;
|
||||||
|
agent_domain?: string;
|
||||||
|
node_ipv4?: string;
|
||||||
|
bird_bgp_source_ipv4?: string;
|
||||||
|
dispatch_status?: string;
|
||||||
|
sync_status?: string;
|
||||||
|
last_dispatch_at?: string | null;
|
||||||
|
last_dispatch_error?: string | null;
|
||||||
|
meta_json?: Record<string, unknown>;
|
||||||
|
agent_secret?: string;
|
||||||
|
live?: SpeakerLiveStatus;
|
||||||
};
|
};
|
||||||
export type SpeakersResponse = Page<SpeakerRow>;
|
export type SpeakersResponse = Page<SpeakerRow>;
|
||||||
export type BgpSpeakerCreate = {
|
export type BgpSpeakerCreate = {
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
role?: string;
|
role?: string;
|
||||||
|
meta_json?: string;
|
||||||
};
|
};
|
||||||
export type BgpSpeakerPatch = Partial<BgpSpeakerCreate>;
|
export type BgpSpeakerPatch = Partial<BgpSpeakerCreate>;
|
||||||
|
|
||||||
|
export type BundleSigningPublicKey = {
|
||||||
|
public_key_base64: string;
|
||||||
|
};
|
||||||
|
|
||||||
// ---- Revisions ----
|
// ---- Revisions ----
|
||||||
export type RevisionRow = {
|
export type RevisionRow = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Label } from '$lib/ui/core/label/index.js';
|
||||||
|
import { Switch } from '$lib/ui/core/switch/index.js';
|
||||||
|
import { readNetworkAutoRefresh, writeNetworkAutoRefresh } from '$lib/network/network-metrics.js';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
enabled?: boolean;
|
||||||
|
onchange?: (enabled: boolean) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
let {
|
||||||
|
enabled = $bindable(readNetworkAutoRefresh()),
|
||||||
|
onchange,
|
||||||
|
disabled = false
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
function onToggle(checked: boolean) {
|
||||||
|
enabled = checked;
|
||||||
|
writeNetworkAutoRefresh(checked);
|
||||||
|
onchange?.(checked);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Switch id="network-auto-refresh" bind:checked={enabled} onCheckedChange={onToggle} {disabled} />
|
||||||
|
<Label for="network-auto-refresh" class="cursor-pointer text-sm text-muted-foreground">
|
||||||
|
Авто (~15 с)
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
import type { BirdStatus, PeerRow, SpeakerRow } from '$lib/api/types.js';
|
||||||
|
import {
|
||||||
|
aggregateNetworkMetrics,
|
||||||
|
collectNetworkIssues,
|
||||||
|
deriveNetworkOverallStatus,
|
||||||
|
networkOverallStatusHint,
|
||||||
|
networkOverallStatusLabel
|
||||||
|
} from '$lib/network/network-metrics.js';
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '$lib/ui/core/alert/index.js';
|
||||||
|
import { Button } from '$lib/ui/core/button/index.js';
|
||||||
|
import KpiMetricsGrid from '$lib/ui/patterns/kpi/kpi-metrics-grid.svelte';
|
||||||
|
import NetworkSpeakerStatusCard from '$lib/components/network/NetworkSpeakerStatusCard.svelte';
|
||||||
|
import CheckCircle from '@lucide/svelte/icons/check-circle';
|
||||||
|
import AlertTriangle from '@lucide/svelte/icons/alert-triangle';
|
||||||
|
import XCircle from '@lucide/svelte/icons/x-circle';
|
||||||
|
import Share2 from '@lucide/svelte/icons/share-2';
|
||||||
|
import CheckCircle2 from '@lucide/svelte/icons/check-circle-2';
|
||||||
|
import Server from '@lucide/svelte/icons/server';
|
||||||
|
import GitBranch from '@lucide/svelte/icons/git-branch';
|
||||||
|
import Activity from '@lucide/svelte/icons/activity';
|
||||||
|
import Bird from '@lucide/svelte/icons/bird';
|
||||||
|
import Gauge from '@lucide/svelte/icons/gauge';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
peers: PeerRow[];
|
||||||
|
speakers: SpeakerRow[];
|
||||||
|
bird: BirdStatus | null;
|
||||||
|
loading?: boolean;
|
||||||
|
initialLoading?: boolean;
|
||||||
|
onSpeakerSelect?: (speaker: SpeakerRow) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let {
|
||||||
|
peers,
|
||||||
|
speakers,
|
||||||
|
bird,
|
||||||
|
loading = false,
|
||||||
|
initialLoading = false,
|
||||||
|
onSpeakerSelect
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const statAccents = [
|
||||||
|
{
|
||||||
|
border: 'border-l-chart-3',
|
||||||
|
bg: 'bg-chart-3/5',
|
||||||
|
iconBg: 'bg-chart-3/15',
|
||||||
|
iconText: 'text-chart-3'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
border: 'border-l-chart-2',
|
||||||
|
bg: 'bg-chart-2/5',
|
||||||
|
iconBg: 'bg-chart-2/15',
|
||||||
|
iconText: 'text-chart-2'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
border: 'border-l-chart-4',
|
||||||
|
bg: 'bg-chart-4/5',
|
||||||
|
iconBg: 'bg-chart-4/15',
|
||||||
|
iconText: 'text-chart-4'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
border: 'border-l-warning',
|
||||||
|
bg: 'bg-warning/5',
|
||||||
|
iconBg: 'bg-warning/15',
|
||||||
|
iconText: 'text-warning'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
border: 'border-l-destructive',
|
||||||
|
bg: 'bg-destructive/5',
|
||||||
|
iconBg: 'bg-destructive/15',
|
||||||
|
iconText: 'text-destructive'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
border: 'border-l-info',
|
||||||
|
bg: 'bg-info/10',
|
||||||
|
iconBg: 'bg-info/15',
|
||||||
|
iconText: 'text-info'
|
||||||
|
}
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const metrics = $derived(aggregateNetworkMetrics(peers, speakers, bird));
|
||||||
|
const overallStatus = $derived(deriveNetworkOverallStatus(metrics));
|
||||||
|
const overallHint = $derived(networkOverallStatusHint(overallStatus, metrics));
|
||||||
|
const issues = $derived(collectNetworkIssues(peers, speakers, 5));
|
||||||
|
|
||||||
|
const birdText = $derived.by(() => {
|
||||||
|
if (!bird?.birdc_configured) return '—';
|
||||||
|
if (bird.error) return '—';
|
||||||
|
return `${bird.bgp_established}/${bird.bgp_sessions_total}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const kpiCards = $derived.by(() => [
|
||||||
|
{
|
||||||
|
id: 'peers',
|
||||||
|
label: 'BGP-пиры',
|
||||||
|
value: initialLoading ? '—' : String(metrics.peersTotal),
|
||||||
|
description: initialLoading
|
||||||
|
? ''
|
||||||
|
: `${metrics.peersEstablished} Established из ${metrics.peersEnabled} вкл.`,
|
||||||
|
icon: Share2,
|
||||||
|
accent: statAccents[0],
|
||||||
|
badge: metrics.peersMismatch > 0 ? `mismatch ${metrics.peersMismatch}` : 'peers',
|
||||||
|
badgeClass:
|
||||||
|
metrics.peersMismatch > 0 ? 'border-warning/30 bg-warning/15 text-warning' : undefined
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'established',
|
||||||
|
label: 'Активные сессии',
|
||||||
|
value: initialLoading ? '—' : String(metrics.peersEstablished),
|
||||||
|
description: 'Established среди включённых пиров',
|
||||||
|
icon: CheckCircle2,
|
||||||
|
accent: statAccents[1],
|
||||||
|
badge: metrics.peersEstablished > 0 ? 'Established' : 'нет сессий',
|
||||||
|
badgeClass:
|
||||||
|
metrics.peersEstablished > 0 ? 'border-success/30 bg-success/15 text-success' : undefined
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'speakers',
|
||||||
|
label: 'Спикеры online',
|
||||||
|
value: initialLoading ? '—' : `${metrics.speakersOnline}/${metrics.speakersTotal}`,
|
||||||
|
description: 'agent + BGP poll',
|
||||||
|
icon: Server,
|
||||||
|
accent: statAccents[2],
|
||||||
|
badge: metrics.speakersOnline === metrics.speakersTotal ? 'все online' : 'есть offline',
|
||||||
|
badgeClass:
|
||||||
|
metrics.speakersOnline === metrics.speakersTotal
|
||||||
|
? 'border-success/30 bg-success/15 text-success'
|
||||||
|
: 'border-warning/30 bg-warning/15 text-warning'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'drift',
|
||||||
|
label: 'Drift',
|
||||||
|
value: initialLoading ? '—' : String(metrics.speakersDrift),
|
||||||
|
description: 'applied ≠ published',
|
||||||
|
icon: GitBranch,
|
||||||
|
accent: statAccents[3],
|
||||||
|
badge: metrics.speakersDrift > 0 ? 'требует apply' : 'синхронно',
|
||||||
|
badgeVariant: metrics.speakersDrift > 0 ? ('secondary' as const) : ('outline' as const)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'poll-errors',
|
||||||
|
label: 'Ошибки опроса',
|
||||||
|
value: initialLoading ? '—' : String(metrics.pollErrors),
|
||||||
|
description: 'agent или BGP poll',
|
||||||
|
icon: Activity,
|
||||||
|
accent: statAccents[4],
|
||||||
|
badge: metrics.pollErrors > 0 ? 'ошибки' : 'ok',
|
||||||
|
badgeClass:
|
||||||
|
metrics.pollErrors === 0 ? 'border-success/30 bg-success/15 text-success' : undefined
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cp-bird',
|
||||||
|
label: 'BGP на CP',
|
||||||
|
value: initialLoading ? '—' : birdText,
|
||||||
|
description: bird?.birdc_configured
|
||||||
|
? 'Established / total на API-хосте'
|
||||||
|
: (bird?.message ?? 'birdc не настроен'),
|
||||||
|
icon: Bird,
|
||||||
|
accent: statAccents[5],
|
||||||
|
badge: !bird?.birdc_configured ? 'N/A' : bird?.healthy ? 'В норме' : 'Деградация',
|
||||||
|
href: '/monitoring' as const
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex min-w-0 flex-col gap-6">
|
||||||
|
{#if !initialLoading && !loading}
|
||||||
|
{#if overallStatus === 'ok'}
|
||||||
|
<Alert class="border-success/30 bg-success/5">
|
||||||
|
<CheckCircle class="text-success" />
|
||||||
|
<AlertTitle>{networkOverallStatusLabel(overallStatus)}</AlertTitle>
|
||||||
|
<AlertDescription>{overallHint}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
{:else if overallStatus === 'warn'}
|
||||||
|
<Alert class="border-warning/30 bg-warning/5">
|
||||||
|
<AlertTriangle class="text-warning" />
|
||||||
|
<AlertTitle>{networkOverallStatusLabel(overallStatus)}</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
{overallHint}
|
||||||
|
{#if issues.length > 0}
|
||||||
|
<ul class="mt-2 list-inside list-disc text-sm">
|
||||||
|
{#each issues as issue (issue.id)}
|
||||||
|
<li>{issue.message}</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
{:else}
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<XCircle />
|
||||||
|
<AlertTitle>{networkOverallStatusLabel(overallStatus)}</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
{overallHint}
|
||||||
|
{#if issues.length > 0}
|
||||||
|
<ul class="mt-2 list-inside list-disc text-sm">
|
||||||
|
{#each issues as issue (issue.id)}
|
||||||
|
<li>{issue.message}</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<KpiMetricsGrid
|
||||||
|
cards={kpiCards}
|
||||||
|
loading={initialLoading || loading}
|
||||||
|
skeletonCount={6}
|
||||||
|
class="sm:grid-cols-2 xl:grid-cols-3"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<section class="flex min-w-0 flex-col gap-4">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<h2 class="text-base font-semibold">Ноды</h2>
|
||||||
|
<Button variant="outline" size="sm" href={resolve('/monitoring')}>
|
||||||
|
<Gauge class="size-3.5" />
|
||||||
|
Мониторинг API
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if speakers.length === 0 && !initialLoading && !loading}
|
||||||
|
<p class="text-sm text-muted-foreground">Спикеры не зарегистрированы.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="grid auto-rows-fr gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{#each speakers as speaker (speaker.id)}
|
||||||
|
<NetworkSpeakerStatusCard
|
||||||
|
{speaker}
|
||||||
|
{peers}
|
||||||
|
class="h-full"
|
||||||
|
onclick={onSpeakerSelect ? () => onSpeakerSelect(speaker) : undefined}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { apiMutate } from '$lib/api/client.js';
|
import { apiMutate } from '$lib/api/client.js';
|
||||||
import type { PeerRow, BgpPeerCreate, SpeakerRow } from '$lib/api/types.js';
|
import type { PeerRow, BgpPeerCreate, SpeakerRow, PeerSessionOnSpeaker } from '$lib/api/types.js';
|
||||||
import { Badge } from '$lib/ui/core/badge/index.js';
|
import { Badge } from '$lib/ui/core/badge/index.js';
|
||||||
import { Button } from '$lib/ui/core/button/index.js';
|
import { Button } from '$lib/ui/core/button/index.js';
|
||||||
import { Label } from '$lib/ui/core/label/index.js';
|
import { Label } from '$lib/ui/core/label/index.js';
|
||||||
@@ -77,12 +77,51 @@
|
|||||||
{ id: 'actions', label: '', class: 'w-20' }
|
{ id: 'actions', label: '', class: 'w-20' }
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
function speakerLabelById(id: string | null | undefined) {
|
function peerNodeLine(s: PeerSessionOnSpeaker): string {
|
||||||
if (!id) return '—';
|
if (s.poll_error) return `${s.label}: опрос недоступен`;
|
||||||
return speakerById.get(id)?.endpoint ?? id;
|
if (s.state === 'Established') return `${s.label}: Established`;
|
||||||
|
if (s.state === 'absent') return `${s.label}: нет сессии`;
|
||||||
|
return `${s.label}: ${s.state || '—'}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function sessionBadge(state: string) {
|
function peerConnectedLabel(p: PeerRow): string {
|
||||||
|
const nodes = p.session_on_speakers ?? [];
|
||||||
|
if (nodes.length > 0) {
|
||||||
|
return nodes.map(peerNodeLine).join(' · ');
|
||||||
|
}
|
||||||
|
const established = p.established_on_speakers ?? [];
|
||||||
|
if (established.length > 0) {
|
||||||
|
return established.map((s) => `${s.label}: Established`).join(' · ');
|
||||||
|
}
|
||||||
|
return 'Не найден на опрошенных нодах';
|
||||||
|
}
|
||||||
|
|
||||||
|
function peerSessionHint(p: PeerRow): string | null {
|
||||||
|
if (!p.session_mismatch || !p.bgp_speaker_id) return null;
|
||||||
|
const expected = speakerLabelById(p.bgp_speaker_id);
|
||||||
|
const actual =
|
||||||
|
p.established_on_speakers?.map((s) => s.label).join(', ') ||
|
||||||
|
p.connected_speaker_label?.trim() ||
|
||||||
|
'другие ноды';
|
||||||
|
return `В конфиге: ${expected}; Established на: ${actual}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function speakerLabelById(id: string | null | undefined) {
|
||||||
|
if (!id) return 'Все спикеры';
|
||||||
|
const s = speakerById.get(id);
|
||||||
|
if (!s) return id.slice(0, 8) + '…';
|
||||||
|
if (s.role === 'master') {
|
||||||
|
const host = s.agent_domain ?? s.endpoint;
|
||||||
|
return host ? `CP · ${host}` : 'CP (master)';
|
||||||
|
}
|
||||||
|
return s.agent_domain ?? s.endpoint ?? id.slice(0, 8) + '…';
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionBadge(
|
||||||
|
state: string,
|
||||||
|
p: PeerRow
|
||||||
|
): 'default' | 'secondary' | 'destructive' | 'outline' {
|
||||||
|
if (p.session_mismatch) return 'destructive';
|
||||||
if (state === 'Established') return 'default';
|
if (state === 'Established') return 'default';
|
||||||
if (state === 'Active' || state === 'Connect') return 'secondary';
|
if (state === 'Active' || state === 'Connect') return 'secondary';
|
||||||
return 'outline';
|
return 'outline';
|
||||||
@@ -203,9 +242,22 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{:else if column.id === 'session_state'}
|
{:else if column.id === 'session_state'}
|
||||||
<Badge variant={sessionBadge(p.session_state)}>{p.session_state || '—'}</Badge>
|
<div class="flex min-w-0 flex-col gap-0.5">
|
||||||
|
<Badge variant={sessionBadge(p.session_state, p)}>{p.session_state || '—'}</Badge>
|
||||||
|
<span
|
||||||
|
class="truncate text-xs text-muted-foreground"
|
||||||
|
title={peerSessionHint(p) ?? peerConnectedLabel(p)}
|
||||||
|
>
|
||||||
|
{peerConnectedLabel(p)}
|
||||||
|
</span>
|
||||||
|
{#if peerSessionHint(p)}
|
||||||
|
<span class="truncate text-xs text-destructive">{peerSessionHint(p)}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
{:else if column.id === 'speaker'}
|
{:else if column.id === 'speaker'}
|
||||||
<span class="text-xs text-muted-foreground">{speakerLabelById(p.bgp_speaker_id)}</span>
|
<span class="text-xs text-muted-foreground" title="Привязка в конфиге CP">
|
||||||
|
{p.bgp_speaker_id ? speakerLabelById(p.bgp_speaker_id) : 'Все спикеры'}
|
||||||
|
</span>
|
||||||
{:else if column.id === 'actions'}
|
{:else if column.id === 'actions'}
|
||||||
<div class="flex gap-1">
|
<div class="flex gap-1">
|
||||||
<Button variant="ghost" size="icon-sm" onclick={() => openEdit(p)}>
|
<Button variant="ghost" size="icon-sm" onclick={() => openEdit(p)}>
|
||||||
@@ -251,7 +303,7 @@
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger id="p-speaker" class="w-full">
|
<SelectTrigger id="p-speaker" class="w-full">
|
||||||
{form.bgp_speaker_id ? speakerLabelById(form.bgp_speaker_id) : 'Не выбрано'}
|
{form.bgp_speaker_id ? speakerLabelById(form.bgp_speaker_id) : 'Все спикеры'}
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="">Не выбрано</SelectItem>
|
<SelectItem value="">Не выбрано</SelectItem>
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { PeerRow, SpeakerRow } from '$lib/api/types.js';
|
||||||
|
import {
|
||||||
|
peersForSpeaker,
|
||||||
|
speakerDisplayStatus,
|
||||||
|
speakerDispatchError,
|
||||||
|
speakerHasDrift,
|
||||||
|
speakerLabel,
|
||||||
|
speakerLiveAgentError,
|
||||||
|
speakerLiveBgpError
|
||||||
|
} from '$lib/network/network-metrics.js';
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '$lib/ui/core/alert/index.js';
|
||||||
|
import { Badge } from '$lib/ui/core/badge/index.js';
|
||||||
|
import { Button } from '$lib/ui/core/button/index.js';
|
||||||
|
import { Separator } from '$lib/ui/core/separator/index.js';
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetDescription,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle
|
||||||
|
} from '$lib/ui/core/sheet/index.js';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow
|
||||||
|
} from '$lib/ui/core/table/index.js';
|
||||||
|
import AlertTriangle from '@lucide/svelte/icons/alert-triangle';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
speaker: SpeakerRow | null;
|
||||||
|
peers: PeerRow[];
|
||||||
|
open?: boolean;
|
||||||
|
onOpenChange?: (open: boolean) => void;
|
||||||
|
onApply?: (speaker: SpeakerRow) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { speaker, peers, open = $bindable(false), onOpenChange, onApply }: Props = $props();
|
||||||
|
|
||||||
|
const status = $derived(speaker ? speakerDisplayStatus(speaker) : null);
|
||||||
|
const label = $derived(speaker ? speakerLabel(speaker) : '');
|
||||||
|
const relatedPeers = $derived(speaker ? peersForSpeaker(peers, speaker.id) : []);
|
||||||
|
const sessions = $derived(speaker?.live?.sessions ?? []);
|
||||||
|
const dispatchError = $derived(speaker ? speakerDispatchError(speaker) : null);
|
||||||
|
const agentError = $derived(speaker ? speakerLiveAgentError(speaker) : null);
|
||||||
|
const bgpError = $derived(speaker ? speakerLiveBgpError(speaker) : null);
|
||||||
|
|
||||||
|
function driftLabel(s: SpeakerRow): string {
|
||||||
|
const pub = s.published_revision_id?.slice(0, 8) ?? '—';
|
||||||
|
const app = s.last_applied_revision_id?.slice(0, 8) ?? '—';
|
||||||
|
return `${app} / ${pub}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSyncAt(iso: string | undefined): string {
|
||||||
|
if (!iso) return '—';
|
||||||
|
const d = new Date(iso);
|
||||||
|
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString('ru-RU');
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
onOpenChange?.(open);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Sheet bind:open>
|
||||||
|
<SheetContent class="flex w-full flex-col gap-0 overflow-y-auto p-0 sm:max-w-md">
|
||||||
|
{#if speaker}
|
||||||
|
<div class="flex min-w-0 flex-col gap-4 px-4 pt-4 pb-6">
|
||||||
|
<SheetHeader class="space-y-1 pr-8 text-left">
|
||||||
|
<SheetTitle class="truncate">{label}</SheetTitle>
|
||||||
|
<SheetDescription class="truncate">
|
||||||
|
{speaker.role} · {speaker.agent_domain ?? speaker.endpoint}
|
||||||
|
</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
{#if status}
|
||||||
|
<Badge variant={status.variant}>{status.label}</Badge>
|
||||||
|
{/if}
|
||||||
|
{#if speakerHasDrift(speaker)}
|
||||||
|
<Badge variant="secondary">Drift</Badge>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl class="grid grid-cols-[minmax(0,9rem)_1fr] gap-x-3 gap-y-2 text-sm">
|
||||||
|
<dt class="text-muted-foreground">BGP Established</dt>
|
||||||
|
<dd class="text-right font-medium tabular-nums">
|
||||||
|
{speaker.live?.bgp_established ?? '—'} / {speaker.live?.bgp_sessions_total ?? '—'}
|
||||||
|
</dd>
|
||||||
|
{#if speaker.live?.agent_last_sync_at}
|
||||||
|
<dt class="text-muted-foreground">Последний sync</dt>
|
||||||
|
<dd class="text-right text-xs tabular-nums">
|
||||||
|
{formatSyncAt(speaker.live.agent_last_sync_at)}
|
||||||
|
</dd>
|
||||||
|
{/if}
|
||||||
|
<dt class="text-muted-foreground">Drift (app / pub)</dt>
|
||||||
|
<dd class="truncate text-right font-mono text-xs">{driftLabel(speaker)}</dd>
|
||||||
|
{#if speaker.last_dispatch_at}
|
||||||
|
<dt class="text-muted-foreground">Dispatch</dt>
|
||||||
|
<dd class="text-right text-xs tabular-nums">
|
||||||
|
{formatSyncAt(speaker.last_dispatch_at)}
|
||||||
|
</dd>
|
||||||
|
{/if}
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
{#if dispatchError}
|
||||||
|
<Alert class="border-warning/30 bg-warning/5">
|
||||||
|
<AlertTriangle class="text-warning" />
|
||||||
|
<AlertTitle class="text-sm">{dispatchError.title}</AlertTitle>
|
||||||
|
<AlertDescription class="text-xs leading-relaxed"
|
||||||
|
>{dispatchError.detail}</AlertDescription
|
||||||
|
>
|
||||||
|
</Alert>
|
||||||
|
{/if}
|
||||||
|
{#if agentError}
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertTitle class="text-sm">{agentError.title}</AlertTitle>
|
||||||
|
<AlertDescription class="text-xs">{agentError.detail}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
{/if}
|
||||||
|
{#if bgpError}
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertTitle class="text-sm">{bgpError.title}</AlertTitle>
|
||||||
|
<AlertDescription class="text-xs">{bgpError.detail}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if onApply && speaker.published_revision_id}
|
||||||
|
<Button variant="outline" size="sm" class="w-fit" onclick={() => onApply(speaker)}>
|
||||||
|
Apply revision
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<section class="min-w-0 space-y-2">
|
||||||
|
<h3 class="text-sm font-medium">BGP-сессии (live)</h3>
|
||||||
|
{#if sessions.length === 0}
|
||||||
|
<p class="text-sm text-muted-foreground">Нет данных или сессий нет.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="rounded-md border">
|
||||||
|
<Table class="table-fixed">
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead class="w-[65%]">Имя</TableHead>
|
||||||
|
<TableHead class="w-[35%] text-right">Состояние</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{#each sessions as sess, i (sess.name + i)}
|
||||||
|
<TableRow>
|
||||||
|
<TableCell class="align-top">
|
||||||
|
<p class="truncate font-mono text-xs" title={sess.name}>{sess.name}</p>
|
||||||
|
{#if sess.neighbor}
|
||||||
|
<p class="truncate text-xs text-muted-foreground" title={sess.neighbor}>
|
||||||
|
{sess.neighbor}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell class="text-right align-top">
|
||||||
|
<Badge variant="outline" class="shrink-0">{sess.state}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
{/each}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<section class="min-w-0 space-y-2">
|
||||||
|
<h3 class="text-sm font-medium">Пиры на ноде</h3>
|
||||||
|
{#if relatedPeers.length === 0}
|
||||||
|
<p class="text-sm text-muted-foreground">Нет привязанных пиров.</p>
|
||||||
|
{:else}
|
||||||
|
<ul class="divide-y rounded-md border">
|
||||||
|
{#each relatedPeers as p (p.id)}
|
||||||
|
<li class="flex min-w-0 items-start justify-between gap-3 px-3 py-2.5 text-sm">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="truncate font-medium" title={p.name?.trim() || p.neighbor}>
|
||||||
|
{p.name?.trim() || p.neighbor}
|
||||||
|
</p>
|
||||||
|
{#if p.session_mismatch}
|
||||||
|
<p class="mt-0.5 text-xs text-warning">
|
||||||
|
Mismatch: сессия не на назначенной ноде
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline" class="shrink-0">{p.session_state || '—'}</Badge>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { PeerRow, SpeakerRow } from '$lib/api/types.js';
|
||||||
|
import {
|
||||||
|
speakerBgpText,
|
||||||
|
speakerDisplayStatus,
|
||||||
|
speakerHasDrift,
|
||||||
|
speakerLabel
|
||||||
|
} from '$lib/network/network-metrics.js';
|
||||||
|
import { Badge } from '$lib/ui/core/badge/index.js';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
CardDescription
|
||||||
|
} from '$lib/ui/core/card/index.js';
|
||||||
|
import { cn } from '$lib/utils.js';
|
||||||
|
import Server from '@lucide/svelte/icons/server';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
speaker: SpeakerRow;
|
||||||
|
peers?: PeerRow[];
|
||||||
|
onclick?: () => void;
|
||||||
|
class?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { speaker, peers = [], onclick, class: className }: Props = $props();
|
||||||
|
|
||||||
|
const status = $derived(speakerDisplayStatus(speaker));
|
||||||
|
const label = $derived(speakerLabel(speaker));
|
||||||
|
const drift = $derived(speakerHasDrift(speaker));
|
||||||
|
const peerCount = $derived(
|
||||||
|
peers.filter(
|
||||||
|
(p) =>
|
||||||
|
p.bgp_speaker_id === speaker.id ||
|
||||||
|
p.bgp_speaker_id === null ||
|
||||||
|
p.bgp_speaker_id === undefined
|
||||||
|
).length
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||||
|
<Card
|
||||||
|
class={cn(
|
||||||
|
'flex h-full flex-col transition-colors',
|
||||||
|
onclick ? 'cursor-pointer hover:border-primary/35' : '',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
role={onclick ? 'button' : undefined}
|
||||||
|
tabindex={onclick ? 0 : undefined}
|
||||||
|
{onclick}
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (onclick && (e.key === 'Enter' || e.key === ' ')) {
|
||||||
|
e.preventDefault();
|
||||||
|
onclick();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CardHeader class="pb-2">
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<CardTitle class="flex items-center gap-2 text-sm">
|
||||||
|
<Server class="size-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||||
|
<span class="truncate" title={label}>{label}</span>
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription class="truncate font-mono text-xs">{speaker.role}</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Badge variant={status.variant} class="shrink-0">{status.label}</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="mt-auto pt-0">
|
||||||
|
<dl class="grid grid-cols-[1fr_auto] gap-x-3 gap-y-2 text-sm">
|
||||||
|
<dt class="text-muted-foreground">BGP</dt>
|
||||||
|
<dd class="font-medium tabular-nums">{speakerBgpText(speaker)}</dd>
|
||||||
|
<dt class="text-muted-foreground">Пиры</dt>
|
||||||
|
<dd class="tabular-nums">{peerCount}</dd>
|
||||||
|
<dt class="text-muted-foreground">Drift</dt>
|
||||||
|
<dd>
|
||||||
|
<Badge variant={drift ? 'secondary' : 'outline'} class="text-xs">
|
||||||
|
{drift ? 'есть' : 'нет'}
|
||||||
|
</Badge>
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { apiMutate } from '$lib/api/client.js';
|
import { apiJSON, apiMutate } from '$lib/api/client.js';
|
||||||
import type { SpeakerRow, BgpSpeakerCreate } from '$lib/api/types.js';
|
import type { SpeakerRow, BgpSpeakerCreate, BundleSigningPublicKey } from '$lib/api/types.js';
|
||||||
|
import {
|
||||||
|
speakerBgpText,
|
||||||
|
speakerDisplayStatus,
|
||||||
|
speakerHasDrift
|
||||||
|
} from '$lib/network/network-metrics.js';
|
||||||
import { Badge } from '$lib/ui/core/badge/index.js';
|
import { Badge } from '$lib/ui/core/badge/index.js';
|
||||||
import { Button } from '$lib/ui/core/button/index.js';
|
import { Button } from '$lib/ui/core/button/index.js';
|
||||||
import {
|
import {
|
||||||
@@ -15,15 +20,21 @@
|
|||||||
DialogContent,
|
DialogContent,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogFooter
|
DialogFooter,
|
||||||
|
DialogDescription
|
||||||
} from '$lib/ui/core/dialog/index.js';
|
} from '$lib/ui/core/dialog/index.js';
|
||||||
|
import { Checkbox } from '$lib/ui/core/checkbox/index.js';
|
||||||
import FormField from '$lib/ui/patterns/form/form-field.svelte';
|
import FormField from '$lib/ui/patterns/form/form-field.svelte';
|
||||||
import AppInput from '$lib/ui/patterns/form/app-input.svelte';
|
import AppInput from '$lib/ui/patterns/form/app-input.svelte';
|
||||||
import AppDataTable from '$lib/ui/patterns/data-table/app-data-table.svelte';
|
import AppDataTable from '$lib/ui/patterns/data-table/app-data-table.svelte';
|
||||||
|
import { confirm } from '$lib/ui/patterns/confirm/confirm-state.svelte.js';
|
||||||
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
||||||
import Plus from '@lucide/svelte/icons/plus';
|
import Plus from '@lucide/svelte/icons/plus';
|
||||||
import Pencil from '@lucide/svelte/icons/pencil';
|
import Pencil from '@lucide/svelte/icons/pencil';
|
||||||
import Play from '@lucide/svelte/icons/play';
|
import Play from '@lucide/svelte/icons/play';
|
||||||
|
import Copy from '@lucide/svelte/icons/copy';
|
||||||
|
import Trash2 from '@lucide/svelte/icons/trash-2';
|
||||||
|
import Eye from '@lucide/svelte/icons/eye';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
items: SpeakerRow[];
|
items: SpeakerRow[];
|
||||||
@@ -31,45 +42,240 @@
|
|||||||
initialLoading?: boolean;
|
initialLoading?: boolean;
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
onRefresh: () => void | Promise<void>;
|
onRefresh: () => void | Promise<void>;
|
||||||
|
onSpeakerSelect?: (speaker: SpeakerRow) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
let { items, loading = false, initialLoading = false, error = null, onRefresh }: Props = $props();
|
let {
|
||||||
|
items,
|
||||||
|
loading = false,
|
||||||
|
initialLoading = false,
|
||||||
|
error = null,
|
||||||
|
onRefresh,
|
||||||
|
onSpeakerSelect
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
type SpeakerForm = {
|
||||||
|
endpoint: string;
|
||||||
|
role: string;
|
||||||
|
agent_domain: string;
|
||||||
|
node_ipv4: string;
|
||||||
|
bird_bgp_source_ipv4: string;
|
||||||
|
bgpSourceManual: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
let dialogOpen = $state(false);
|
let dialogOpen = $state(false);
|
||||||
|
let wizardOpen = $state(false);
|
||||||
|
let applyDialogOpen = $state(false);
|
||||||
|
let composeDialogOpen = $state(false);
|
||||||
let editTarget = $state<SpeakerRow | null>(null);
|
let editTarget = $state<SpeakerRow | null>(null);
|
||||||
let form = $state<BgpSpeakerCreate>({ endpoint: '', role: 'operator' });
|
let applyTarget = $state<SpeakerRow | null>(null);
|
||||||
|
let composeTarget = $state<SpeakerRow | null>(null);
|
||||||
|
let applyRevisionId = $state('');
|
||||||
|
let composeText = $state('');
|
||||||
|
let createdSpeaker = $state<SpeakerRow | null>(null);
|
||||||
|
let form = $state<SpeakerForm>({
|
||||||
|
endpoint: '',
|
||||||
|
role: 'replica',
|
||||||
|
agent_domain: '',
|
||||||
|
node_ipv4: '',
|
||||||
|
bird_bgp_source_ipv4: '',
|
||||||
|
bgpSourceManual: false
|
||||||
|
});
|
||||||
let saving = $state(false);
|
let saving = $state(false);
|
||||||
let applyingId = $state<string | null>(null);
|
let applyingId = $state<string | null>(null);
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
|
{ id: 'status', label: 'Статус' },
|
||||||
|
{ id: 'live_agent', label: 'Agent' },
|
||||||
|
{ id: 'bgp', label: 'BGP' },
|
||||||
{
|
{
|
||||||
id: 'endpoint',
|
id: 'agent_domain',
|
||||||
label: 'Endpoint',
|
label: 'Agent domain',
|
||||||
sortable: true,
|
sortable: true,
|
||||||
sortValue: (s: SpeakerRow) => s.endpoint
|
sortValue: (s: SpeakerRow) => s.agent_domain ?? s.endpoint
|
||||||
},
|
},
|
||||||
{ id: 'role', label: 'Роль', sortable: true, sortValue: (s: SpeakerRow) => s.role },
|
{ id: 'role', label: 'Роль', sortable: true, sortValue: (s: SpeakerRow) => s.role },
|
||||||
{ id: 'last_applied_revision_id', label: 'Последняя ревизия' },
|
{ id: 'drift', label: 'Drift' },
|
||||||
{ id: 'actions', label: '', class: 'w-32' }
|
{ id: 'actions', label: '', class: 'w-44' }
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
function parseIpv4FromEndpoint(ep: string): string {
|
||||||
|
try {
|
||||||
|
const u = ep.includes('://') ? new URL(ep) : new URL(`https://${ep}`);
|
||||||
|
const host = u.hostname;
|
||||||
|
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) return host;
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function onNodeIPv4Change(ip: string) {
|
||||||
|
form.node_ipv4 = ip;
|
||||||
|
if (!form.bgpSourceManual) {
|
||||||
|
form.bird_bgp_source_ipv4 = ip;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onEndpointChange(ep: string) {
|
||||||
|
form.endpoint = ep;
|
||||||
|
const ip = parseIpv4FromEndpoint(ep);
|
||||||
|
if (ip && !form.node_ipv4) {
|
||||||
|
onNodeIPv4Change(ip);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyForm(): SpeakerForm {
|
||||||
|
return {
|
||||||
|
endpoint: '',
|
||||||
|
role: 'replica',
|
||||||
|
agent_domain: '',
|
||||||
|
node_ipv4: '',
|
||||||
|
bird_bgp_source_ipv4: '',
|
||||||
|
bgpSourceManual: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formFromSpeaker(s: SpeakerRow): SpeakerForm {
|
||||||
|
return {
|
||||||
|
endpoint: s.endpoint,
|
||||||
|
role: s.role,
|
||||||
|
agent_domain: s.agent_domain ?? '',
|
||||||
|
node_ipv4: s.node_ipv4 ?? '',
|
||||||
|
bird_bgp_source_ipv4: s.bird_bgp_source_ipv4 ?? s.node_ipv4 ?? '',
|
||||||
|
bgpSourceManual: Boolean(
|
||||||
|
s.bird_bgp_source_ipv4 && s.node_ipv4 && s.bird_bgp_source_ipv4 !== s.node_ipv4
|
||||||
|
)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMetaJson(f: SpeakerForm): string {
|
||||||
|
const meta: Record<string, string> = {};
|
||||||
|
if (f.agent_domain.trim()) meta.agent_domain = f.agent_domain.trim();
|
||||||
|
if (f.node_ipv4.trim()) meta.node_ipv4 = f.node_ipv4.trim();
|
||||||
|
if (f.bird_bgp_source_ipv4.trim()) meta.bird_bgp_source_ipv4 = f.bird_bgp_source_ipv4.trim();
|
||||||
|
return JSON.stringify(meta);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildApiBody(f: SpeakerForm): BgpSpeakerCreate {
|
||||||
|
const ep =
|
||||||
|
f.endpoint.trim() || (f.agent_domain.trim() ? `https://${f.agent_domain.trim()}` : '');
|
||||||
|
return {
|
||||||
|
endpoint: ep,
|
||||||
|
role: f.role.trim() || 'replica',
|
||||||
|
meta_json: buildMetaJson(f)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusVariant(s: SpeakerRow): 'default' | 'secondary' | 'destructive' | 'outline' {
|
||||||
|
return speakerDisplayStatus(s).variant;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(s: SpeakerRow): string {
|
||||||
|
return speakerDisplayStatus(s).label;
|
||||||
|
}
|
||||||
|
|
||||||
|
function liveAgentLabel(s: SpeakerRow): string {
|
||||||
|
if (!s.live) return '—';
|
||||||
|
if (s.live.agent_ok === true) return 'OK';
|
||||||
|
return s.live.agent_error ? 'Error' : 'Offline';
|
||||||
|
}
|
||||||
|
|
||||||
|
function driftLabel(s: SpeakerRow): string {
|
||||||
|
const pub = s.published_revision_id?.slice(0, 8) ?? '—';
|
||||||
|
const app = s.last_applied_revision_id?.slice(0, 8) ?? '—';
|
||||||
|
return `${app} / ${pub}`;
|
||||||
|
}
|
||||||
|
|
||||||
function openCreate() {
|
function openCreate() {
|
||||||
editTarget = null;
|
editTarget = null;
|
||||||
form = { endpoint: '', role: 'operator' };
|
form = emptyForm();
|
||||||
dialogOpen = true;
|
dialogOpen = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function openEdit(s: SpeakerRow) {
|
function openEdit(s: SpeakerRow) {
|
||||||
editTarget = s;
|
editTarget = s;
|
||||||
form = { endpoint: s.endpoint, role: s.role };
|
form = formFromSpeaker(s);
|
||||||
dialogOpen = true;
|
dialogOpen = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function applySpeaker(id: string) {
|
function requestDelete(s: SpeakerRow) {
|
||||||
applyingId = id;
|
const label = s.agent_domain ?? s.endpoint ?? s.id;
|
||||||
|
void confirm({
|
||||||
|
title: 'Удалить спикера?',
|
||||||
|
description: label,
|
||||||
|
confirmLabel: 'Удалить',
|
||||||
|
destructive: true,
|
||||||
|
onConfirm: async () => {
|
||||||
|
await apiMutate(`/v1/speakers/${s.id}`, 'DELETE', undefined, { idempotent: false });
|
||||||
|
notify.success('Спикер удалён');
|
||||||
|
await onRefresh();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openApply(s: SpeakerRow) {
|
||||||
|
applyTarget = s;
|
||||||
|
applyRevisionId = s.published_revision_id ?? '';
|
||||||
|
applyDialogOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildComposeSnippet(s: SpeakerRow): Promise<string> {
|
||||||
|
let pubkey = '';
|
||||||
try {
|
try {
|
||||||
await apiMutate(`/v1/speakers/${id}/apply`, 'POST', {});
|
const pk = await apiJSON<BundleSigningPublicKey>('/v1/bundle/signing-public-key');
|
||||||
|
pubkey = pk.public_key_base64;
|
||||||
|
} catch {
|
||||||
|
pubkey = '<GET /v1/bundle/signing-public-key>';
|
||||||
|
}
|
||||||
|
const domain = s.agent_domain ?? 'bgp-dc.example.com';
|
||||||
|
return `# deploy/compose/docker-compose.remote-speaker.yaml
|
||||||
|
# cp .env.remote-speaker.example .env.remote-speaker
|
||||||
|
# cp .env.remote-speaker-tls.example .env.remote-speaker-tls
|
||||||
|
|
||||||
|
EVOBGP_SPEAKER_ID=${s.id}
|
||||||
|
EVOBGP_AGENT_SECRET=<from UI wizard>
|
||||||
|
EVOBGP_NODE_TOKEN=<node API key from /access>
|
||||||
|
EVOBGP_BUNDLE_PUBKEY_BASE64=${pubkey}
|
||||||
|
EVOBGP_CONTROL_PLANE_URL=https://<your-cp-host>:8080
|
||||||
|
|
||||||
|
AGENT_DOMAIN=${domain}
|
||||||
|
PANEL_IP_WHITELIST=<CP public IP>/32
|
||||||
|
[email protected]
|
||||||
|
CF_DNS_API_TOKEN=<cloudflare token>
|
||||||
|
|
||||||
|
# docker compose -f docker-compose.remote-speaker.yaml \\
|
||||||
|
# --env-file .env.remote-speaker --env-file .env.remote-speaker-tls \\
|
||||||
|
# --profile production up -d`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openCompose(s: SpeakerRow) {
|
||||||
|
composeTarget = s;
|
||||||
|
composeText = await buildComposeSnippet(s);
|
||||||
|
composeDialogOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyCompose() {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(composeText);
|
||||||
|
notify.success('Скопировано');
|
||||||
|
} catch {
|
||||||
|
notify.error('Не удалось скопировать');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applySpeaker() {
|
||||||
|
if (!applyTarget || !applyRevisionId.trim()) {
|
||||||
|
notify.error('Укажите revision_id');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
applyingId = applyTarget.id;
|
||||||
|
try {
|
||||||
|
await apiMutate(`/v1/speakers/${applyTarget.id}/apply`, 'POST', {
|
||||||
|
revision_id: applyRevisionId.trim()
|
||||||
|
});
|
||||||
notify.success('Apply запущен');
|
notify.success('Apply запущен');
|
||||||
|
applyDialogOpen = false;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
notifyApiError(e);
|
notifyApiError(e);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -78,20 +284,25 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
if (!form.endpoint.trim()) {
|
const body = buildApiBody(form);
|
||||||
notify.error('Укажите endpoint');
|
if (!body.endpoint.trim()) {
|
||||||
|
notify.error('Укажите endpoint или agent domain');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
saving = true;
|
saving = true;
|
||||||
try {
|
try {
|
||||||
if (editTarget) {
|
if (editTarget) {
|
||||||
await apiMutate(`/v1/speakers/${editTarget.id}`, 'PATCH', form);
|
await apiMutate(`/v1/speakers/${editTarget.id}`, 'PATCH', body);
|
||||||
notify.success('Спикер обновлён');
|
notify.success('Спикер обновлён');
|
||||||
|
dialogOpen = false;
|
||||||
} else {
|
} else {
|
||||||
await apiMutate('/v1/speakers', 'POST', form);
|
const created = await apiMutate<SpeakerRow>('/v1/speakers', 'POST', body);
|
||||||
notify.success('Спикер создан');
|
notify.success('Спикер создан');
|
||||||
|
dialogOpen = false;
|
||||||
|
createdSpeaker = created;
|
||||||
|
composeText = await buildComposeSnippet(created);
|
||||||
|
wizardOpen = true;
|
||||||
}
|
}
|
||||||
dialogOpen = false;
|
|
||||||
await onRefresh();
|
await onRefresh();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
notifyApiError(e);
|
notifyApiError(e);
|
||||||
@@ -99,6 +310,17 @@
|
|||||||
saving = false;
|
saving = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function copyAgentSecret() {
|
||||||
|
const secret = createdSpeaker?.agent_secret;
|
||||||
|
if (!secret) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(secret);
|
||||||
|
notify.success('agent_secret скопирован');
|
||||||
|
} catch {
|
||||||
|
notify.error('Не удалось скопировать');
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
@@ -107,7 +329,9 @@
|
|||||||
>
|
>
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<CardTitle class="text-base">Спикеры</CardTitle>
|
<CardTitle class="text-base">Спикеры</CardTitle>
|
||||||
<CardDescription>BIRD-агенты, применяющие конфигурацию на нодах</CardDescription>
|
<CardDescription
|
||||||
|
>Удалённые BIRD-ноды (Remnawave-style Panel→Node + signed bundle)</CardDescription
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||||||
<Button size="sm" onclick={openCreate}><Plus />Добавить</Button>
|
<Button size="sm" onclick={openCreate}><Plus />Добавить</Button>
|
||||||
@@ -121,32 +345,58 @@
|
|||||||
loading={initialLoading || loading}
|
loading={initialLoading || loading}
|
||||||
{error}
|
{error}
|
||||||
emptyTitle="Нет спикеров"
|
emptyTitle="Нет спикеров"
|
||||||
emptyDescription="Добавьте BIRD-агент для применения конфигурации."
|
emptyDescription="Добавьте реплику для применения signed bundle."
|
||||||
>
|
>
|
||||||
{#snippet cell({ row: s, column })}
|
{#snippet cell({ row: s, column })}
|
||||||
{#if column.id === 'endpoint'}
|
{#if column.id === 'status'}
|
||||||
<span class="font-mono text-sm">{s.endpoint}</span>
|
<Badge variant={statusVariant(s)}>{statusLabel(s)}</Badge>
|
||||||
|
{:else if column.id === 'live_agent'}
|
||||||
|
<Badge variant={s.live?.agent_ok ? 'outline' : 'destructive'}>{liveAgentLabel(s)}</Badge>
|
||||||
|
{:else if column.id === 'bgp'}
|
||||||
|
<span class="font-mono text-xs tabular-nums">{speakerBgpText(s)}</span>
|
||||||
|
{:else if column.id === 'agent_domain'}
|
||||||
|
<span class="font-mono text-sm">{s.agent_domain ?? s.endpoint}</span>
|
||||||
{:else if column.id === 'role'}
|
{:else if column.id === 'role'}
|
||||||
<Badge variant="outline">{s.role}</Badge>
|
<Badge variant="outline">{s.role}</Badge>
|
||||||
{:else if column.id === 'last_applied_revision_id'}
|
{:else if column.id === 'drift'}
|
||||||
<span class="font-mono text-xs text-muted-foreground">
|
<span
|
||||||
{s.last_applied_revision_id ? s.last_applied_revision_id.slice(0, 8) + '…' : '—'}
|
class="font-mono text-xs text-muted-foreground"
|
||||||
|
title="applied / published"
|
||||||
|
class:text-warning={speakerHasDrift(s)}
|
||||||
|
>
|
||||||
|
{driftLabel(s)}
|
||||||
</span>
|
</span>
|
||||||
{:else if column.id === 'actions'}
|
{:else if column.id === 'actions'}
|
||||||
<div class="flex gap-1">
|
<div class="flex flex-wrap gap-1">
|
||||||
|
{#if onSpeakerSelect}
|
||||||
|
<Button variant="outline" size="xs" title="Детали" onclick={() => onSpeakerSelect(s)}>
|
||||||
|
<Eye class="size-3" />
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
<Button variant="outline" size="xs" title="Copy compose" onclick={() => openCompose(s)}>
|
||||||
|
<Copy class="size-3" />
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="xs"
|
size="xs"
|
||||||
title="Запустить применение ревизии на спикере"
|
title="Apply revision (canary)"
|
||||||
onclick={() => applySpeaker(s.id)}
|
onclick={() => openApply(s)}
|
||||||
disabled={applyingId === s.id}
|
disabled={applyingId === s.id}
|
||||||
>
|
>
|
||||||
<Play class="size-3" />
|
<Play class="size-3" />
|
||||||
{applyingId === s.id ? 'Apply…' : 'Apply'}
|
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="icon-sm" onclick={() => openEdit(s)}>
|
<Button variant="ghost" size="icon-sm" onclick={() => openEdit(s)}>
|
||||||
<Pencil class="size-3.5" />
|
<Pencil class="size-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
class="text-destructive"
|
||||||
|
title="Удалить спикера"
|
||||||
|
onclick={() => requestDelete(s)}
|
||||||
|
>
|
||||||
|
<Trash2 class="size-3.5" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
@@ -155,16 +405,44 @@
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Dialog bind:open={dialogOpen}>
|
<Dialog bind:open={dialogOpen}>
|
||||||
<DialogContent class="sm:max-w-sm">
|
<DialogContent class="sm:max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{editTarget ? 'Редактировать спикера' : 'Новый спикер'}</DialogTitle>
|
<DialogTitle>{editTarget ? 'Редактировать спикера' : 'Новый спикер'}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div class="space-y-4 py-2">
|
<div class="space-y-4 py-2">
|
||||||
<FormField label="Endpoint" id="s-endpoint" required>
|
<FormField label="Agent domain (FQDN)" id="s-domain">
|
||||||
<AppInput id="s-endpoint" placeholder="http://bird-agent:8081" bind:value={form.endpoint} />
|
<AppInput id="s-domain" placeholder="bgp-dc2.example.com" bind:value={form.agent_domain} />
|
||||||
</FormField>
|
</FormField>
|
||||||
|
<FormField label="Endpoint" id="s-endpoint">
|
||||||
|
<AppInput
|
||||||
|
id="s-endpoint"
|
||||||
|
placeholder="https://bgp-dc2.example.com"
|
||||||
|
value={form.endpoint}
|
||||||
|
oninput={(e) => onEndpointChange((e.currentTarget as HTMLInputElement).value)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="IP ноды (IPv4)" id="s-node-ip">
|
||||||
|
<AppInput
|
||||||
|
id="s-node-ip"
|
||||||
|
placeholder="203.0.113.10"
|
||||||
|
value={form.node_ipv4}
|
||||||
|
oninput={(e) => onNodeIPv4Change((e.currentTarget as HTMLInputElement).value)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="BGP source IPv4" id="s-bgp-src">
|
||||||
|
<AppInput
|
||||||
|
id="s-bgp-src"
|
||||||
|
placeholder="= IP ноды"
|
||||||
|
bind:value={form.bird_bgp_source_ipv4}
|
||||||
|
disabled={!form.bgpSourceManual}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<label class="flex items-center gap-2 text-sm">
|
||||||
|
<Checkbox bind:checked={form.bgpSourceManual} />
|
||||||
|
Задать BGP source вручную
|
||||||
|
</label>
|
||||||
<FormField label="Роль" id="s-role">
|
<FormField label="Роль" id="s-role">
|
||||||
<AppInput id="s-role" placeholder="operator" bind:value={form.role} />
|
<AppInput id="s-role" placeholder="replica" bind:value={form.role} />
|
||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
@@ -175,3 +453,74 @@
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog bind:open={wizardOpen}>
|
||||||
|
<DialogContent class="sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Спикер создан</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Сохраните agent_secret — он больше не отображается. Скопируйте compose на VPS реплики.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{#if createdSpeaker?.agent_secret}
|
||||||
|
<FormField label="agent_secret (один раз)" id="w-secret">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<AppInput
|
||||||
|
id="w-secret"
|
||||||
|
readonly
|
||||||
|
value={createdSpeaker.agent_secret}
|
||||||
|
class="font-mono text-xs"
|
||||||
|
/>
|
||||||
|
<Button variant="outline" size="icon-sm" onclick={copyAgentSecret}><Copy /></Button>
|
||||||
|
</div>
|
||||||
|
</FormField>
|
||||||
|
{/if}
|
||||||
|
<FormField label="docker-compose env" id="w-compose">
|
||||||
|
<textarea
|
||||||
|
id="w-compose"
|
||||||
|
class="min-h-[200px] w-full rounded-md border bg-muted/30 p-2 font-mono text-xs"
|
||||||
|
readonly
|
||||||
|
value={composeText}
|
||||||
|
></textarea>
|
||||||
|
</FormField>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onclick={copyCompose}><Copy />Copy compose</Button>
|
||||||
|
<Button onclick={() => (wizardOpen = false)}>Готово</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog bind:open={applyDialogOpen}>
|
||||||
|
<DialogContent class="sm:max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Apply на спикер</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<FormField label="revision_id" id="a-rev" required>
|
||||||
|
<AppInput id="a-rev" bind:value={applyRevisionId} class="font-mono text-xs" />
|
||||||
|
</FormField>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onclick={() => (applyDialogOpen = false)}>Отмена</Button>
|
||||||
|
<Button onclick={applySpeaker} disabled={applyingId != null}>Apply</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog bind:open={composeDialogOpen}>
|
||||||
|
<DialogContent class="sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Copy docker-compose</DialogTitle>
|
||||||
|
<DialogDescription
|
||||||
|
>Спикер {composeTarget?.agent_domain ?? composeTarget?.id}</DialogDescription
|
||||||
|
>
|
||||||
|
</DialogHeader>
|
||||||
|
<textarea
|
||||||
|
class="min-h-[240px] w-full rounded-md border bg-muted/30 p-2 font-mono text-xs"
|
||||||
|
readonly
|
||||||
|
value={composeText}
|
||||||
|
></textarea>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onclick={copyCompose}><Copy />Копировать</Button>
|
||||||
|
<Button onclick={() => (composeDialogOpen = false)}>Закрыть</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
import type { PeerRow, SpeakerRow } from '$lib/api/types.js';
|
||||||
|
import {
|
||||||
|
aggregateNetworkMetrics,
|
||||||
|
collectNetworkIssues,
|
||||||
|
deriveNetworkOverallStatus,
|
||||||
|
networkOverallStatusHint,
|
||||||
|
networkOverallStatusLabel
|
||||||
|
} from '$lib/network/network-metrics.js';
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '$lib/ui/core/alert/index.js';
|
||||||
|
import { Button } from '$lib/ui/core/button/index.js';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
CardDescription
|
||||||
|
} from '$lib/ui/core/card/index.js';
|
||||||
|
import CheckCircle from '@lucide/svelte/icons/check-circle';
|
||||||
|
import AlertTriangle from '@lucide/svelte/icons/alert-triangle';
|
||||||
|
import XCircle from '@lucide/svelte/icons/x-circle';
|
||||||
|
import ArrowRight from '@lucide/svelte/icons/arrow-right';
|
||||||
|
import NetworkIcon from '@lucide/svelte/icons/network';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
peers: PeerRow[];
|
||||||
|
speakers: SpeakerRow[];
|
||||||
|
loading?: boolean;
|
||||||
|
initialLoading?: boolean;
|
||||||
|
error?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { peers, speakers, loading = false, initialLoading = false, error = null }: Props = $props();
|
||||||
|
|
||||||
|
const metrics = $derived(aggregateNetworkMetrics(peers, speakers));
|
||||||
|
const overallStatus = $derived(deriveNetworkOverallStatus(metrics));
|
||||||
|
const overallHint = $derived(networkOverallStatusHint(overallStatus, metrics));
|
||||||
|
const issues = $derived(collectNetworkIssues(peers, speakers, 3));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader
|
||||||
|
class="flex flex-col gap-3 border-b py-3 sm:flex-row sm:items-center sm:justify-between"
|
||||||
|
>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<CardTitle class="flex items-center gap-2 text-base">
|
||||||
|
<NetworkIcon class="size-4" />
|
||||||
|
Сеть (BGP)
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Live-статус пиров и спикеров</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" href={resolve('/network?tab=overview')}>
|
||||||
|
Подробнее
|
||||||
|
<ArrowRight class="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="space-y-3 p-4 pt-4">
|
||||||
|
{#if error}
|
||||||
|
<p class="text-sm text-destructive">{error}</p>
|
||||||
|
{:else if initialLoading || loading}
|
||||||
|
<p class="text-sm text-muted-foreground">Загрузка live-метрик…</p>
|
||||||
|
{:else if overallStatus === 'ok'}
|
||||||
|
<Alert class="border-success/30 bg-success/5 py-3">
|
||||||
|
<CheckCircle class="text-success" />
|
||||||
|
<AlertTitle class="text-sm">{networkOverallStatusLabel(overallStatus)}</AlertTitle>
|
||||||
|
<AlertDescription class="text-xs">{overallHint}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
{:else if overallStatus === 'warn'}
|
||||||
|
<Alert class="border-warning/30 bg-warning/5 py-3">
|
||||||
|
<AlertTriangle class="text-warning" />
|
||||||
|
<AlertTitle class="text-sm">{networkOverallStatusLabel(overallStatus)}</AlertTitle>
|
||||||
|
<AlertDescription class="text-xs">
|
||||||
|
{overallHint}
|
||||||
|
{#if issues.length > 0}
|
||||||
|
<ul class="mt-2 list-inside list-disc">
|
||||||
|
{#each issues as issue (issue.id)}
|
||||||
|
<li>{issue.message}</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
{:else}
|
||||||
|
<Alert variant="destructive" class="py-3">
|
||||||
|
<XCircle />
|
||||||
|
<AlertTitle class="text-sm">{networkOverallStatusLabel(overallStatus)}</AlertTitle>
|
||||||
|
<AlertDescription class="text-xs">
|
||||||
|
{overallHint}
|
||||||
|
{#if issues.length > 0}
|
||||||
|
<ul class="mt-2 list-inside list-disc">
|
||||||
|
{#each issues as issue (issue.id)}
|
||||||
|
<li>{issue.message}</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<p class="text-muted-foreground">Пиры Established</p>
|
||||||
|
<p class="text-xl font-bold tabular-nums">
|
||||||
|
{initialLoading ? '—' : `${metrics.peersEstablished}/${metrics.peersEnabled}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-muted-foreground">Спикеры online</p>
|
||||||
|
<p class="text-xl font-bold tabular-nums">
|
||||||
|
{initialLoading ? '—' : `${metrics.speakersOnline}/${metrics.speakersTotal}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-muted-foreground">Drift</p>
|
||||||
|
<p class="text-xl font-bold tabular-nums">{initialLoading ? '—' : metrics.speakersDrift}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
import type { BirdStatus, PeerRow, SpeakerRow } from '$lib/api/types.js';
|
||||||
|
|
||||||
|
export type NetworkOverallStatus = 'ok' | 'warn' | 'error';
|
||||||
|
|
||||||
|
export type NetworkMetrics = {
|
||||||
|
peersTotal: number;
|
||||||
|
peersEnabled: number;
|
||||||
|
peersEstablished: number;
|
||||||
|
peersMismatch: number;
|
||||||
|
speakersTotal: number;
|
||||||
|
speakersOnline: number;
|
||||||
|
speakersRemote: number;
|
||||||
|
speakersRemoteOnline: number;
|
||||||
|
speakersDrift: number;
|
||||||
|
pollErrors: number;
|
||||||
|
hasLiveData: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SpeakerStatusBadge = {
|
||||||
|
label: string;
|
||||||
|
variant: 'default' | 'secondary' | 'destructive' | 'outline';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NetworkIssue = {
|
||||||
|
id: string;
|
||||||
|
message: string;
|
||||||
|
severity: 'warn' | 'error';
|
||||||
|
};
|
||||||
|
|
||||||
|
function isRemoteSpeaker(s: SpeakerRow): boolean {
|
||||||
|
const role = (s.role ?? '').toLowerCase();
|
||||||
|
return role !== 'master' && Boolean(s.agent_domain?.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function speakerHasDrift(s: SpeakerRow): boolean {
|
||||||
|
const pub = s.published_revision_id?.trim();
|
||||||
|
if (!pub) return false;
|
||||||
|
return (s.last_applied_revision_id ?? '') !== pub;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function speakerIsOnline(s: SpeakerRow): boolean {
|
||||||
|
if (s.live) {
|
||||||
|
return s.live.agent_ok === true && s.live.bgp_poll_ok !== false;
|
||||||
|
}
|
||||||
|
if (s.sync_status === 'synced') return true;
|
||||||
|
if (s.sync_status === 'error' || s.last_dispatch_error) return false;
|
||||||
|
return s.dispatch_status === 'ok';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function speakerDisplayStatus(s: SpeakerRow): SpeakerStatusBadge {
|
||||||
|
if (s.live) {
|
||||||
|
if (s.live.agent_ok === true && s.live.bgp_poll_ok !== false) {
|
||||||
|
return { label: 'Online', variant: 'default' };
|
||||||
|
}
|
||||||
|
if (s.live.bgp_poll_error || s.live.agent_error) {
|
||||||
|
return { label: 'Offline', variant: 'destructive' };
|
||||||
|
}
|
||||||
|
return { label: 'Degraded', variant: 'secondary' };
|
||||||
|
}
|
||||||
|
if (s.sync_status === 'synced') return { label: 'Connected', variant: 'default' };
|
||||||
|
if (s.sync_status === 'error' || s.last_dispatch_error) {
|
||||||
|
return { label: 'Offline', variant: 'destructive' };
|
||||||
|
}
|
||||||
|
if (s.dispatch_status === 'ok') return { label: 'Synced', variant: 'outline' };
|
||||||
|
return { label: 'Unknown', variant: 'outline' };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function speakerLabel(s: SpeakerRow): string {
|
||||||
|
return s.live?.label ?? s.agent_domain ?? s.endpoint ?? s.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function speakerBgpText(s: SpeakerRow): string {
|
||||||
|
if (s.live) {
|
||||||
|
return `${s.live.bgp_established ?? 0}/${s.live.bgp_sessions_total ?? 0}`;
|
||||||
|
}
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function peersForSpeaker(peers: PeerRow[], speakerId: string): PeerRow[] {
|
||||||
|
return peers.filter(
|
||||||
|
(p) =>
|
||||||
|
p.bgp_speaker_id === speakerId || p.bgp_speaker_id === null || p.bgp_speaker_id === undefined
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function aggregateNetworkMetrics(
|
||||||
|
peers: PeerRow[],
|
||||||
|
speakers: SpeakerRow[],
|
||||||
|
bird?: BirdStatus | null
|
||||||
|
): NetworkMetrics {
|
||||||
|
const enabledPeers = peers.filter((p) => p.enabled !== false);
|
||||||
|
const established = enabledPeers.filter((p) => p.session_state === 'Established').length;
|
||||||
|
const mismatch = peers.filter((p) => p.session_mismatch).length;
|
||||||
|
const remoteSpeakers = speakers.filter(isRemoteSpeaker);
|
||||||
|
const online = speakers.filter(speakerIsOnline).length;
|
||||||
|
const remoteOnline = remoteSpeakers.filter(speakerIsOnline).length;
|
||||||
|
const drift = speakers.filter(speakerHasDrift).length;
|
||||||
|
const pollErrors = speakers.filter(
|
||||||
|
(s) => s.live?.bgp_poll_error || (s.live && s.live.agent_ok === false)
|
||||||
|
).length;
|
||||||
|
const hasLiveData =
|
||||||
|
speakers.some((s) => s.live != null) || peers.some((p) => p.session_on_speakers);
|
||||||
|
|
||||||
|
void bird;
|
||||||
|
|
||||||
|
return {
|
||||||
|
peersTotal: peers.length,
|
||||||
|
peersEnabled: enabledPeers.length,
|
||||||
|
peersEstablished: established,
|
||||||
|
peersMismatch: mismatch,
|
||||||
|
speakersTotal: speakers.length,
|
||||||
|
speakersOnline: online,
|
||||||
|
speakersRemote: remoteSpeakers.length,
|
||||||
|
speakersRemoteOnline: remoteOnline,
|
||||||
|
speakersDrift: drift,
|
||||||
|
pollErrors,
|
||||||
|
hasLiveData
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deriveNetworkOverallStatus(metrics: NetworkMetrics): NetworkOverallStatus {
|
||||||
|
if (!metrics.hasLiveData && metrics.speakersTotal === 0 && metrics.peersTotal === 0) {
|
||||||
|
return 'ok';
|
||||||
|
}
|
||||||
|
|
||||||
|
const enabledNotEstablished =
|
||||||
|
metrics.peersEnabled > 0 ? metrics.peersEnabled - metrics.peersEstablished : 0;
|
||||||
|
const majorityPeersDown =
|
||||||
|
metrics.peersEnabled > 0 && enabledNotEstablished / metrics.peersEnabled > 0.5;
|
||||||
|
|
||||||
|
if ((metrics.speakersRemote > 0 && metrics.speakersRemoteOnline === 0) || majorityPeersDown) {
|
||||||
|
return 'error';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
metrics.pollErrors > 0 ||
|
||||||
|
metrics.peersMismatch > 0 ||
|
||||||
|
metrics.speakersDrift > 0 ||
|
||||||
|
metrics.speakersOnline < metrics.speakersTotal
|
||||||
|
) {
|
||||||
|
return 'warn';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'ok';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function networkOverallStatusLabel(status: NetworkOverallStatus): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'ok':
|
||||||
|
return 'В норме';
|
||||||
|
case 'warn':
|
||||||
|
return 'Требует внимания';
|
||||||
|
case 'error':
|
||||||
|
return 'Проблема';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function networkOverallStatusHint(
|
||||||
|
status: NetworkOverallStatus,
|
||||||
|
metrics: NetworkMetrics
|
||||||
|
): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'ok':
|
||||||
|
return metrics.hasLiveData
|
||||||
|
? `${metrics.peersEstablished} Established, ${metrics.speakersOnline}/${metrics.speakersTotal} спикеров online`
|
||||||
|
: 'Сеть настроена; обновите для live-статуса';
|
||||||
|
case 'warn':
|
||||||
|
return 'Есть drift, mismatch или недоступные ноды — проверьте детали';
|
||||||
|
case 'error':
|
||||||
|
return 'Критичная деградация BGP или все remote-ноды недоступны';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectNetworkIssues(
|
||||||
|
peers: PeerRow[],
|
||||||
|
speakers: SpeakerRow[],
|
||||||
|
limit = 3
|
||||||
|
): NetworkIssue[] {
|
||||||
|
const issues: NetworkIssue[] = [];
|
||||||
|
|
||||||
|
for (const s of speakers) {
|
||||||
|
if (!speakerIsOnline(s)) {
|
||||||
|
issues.push({
|
||||||
|
id: `speaker-offline-${s.id}`,
|
||||||
|
message: `Нода offline: ${speakerLabel(s)}`,
|
||||||
|
severity: 'error'
|
||||||
|
});
|
||||||
|
} else if (speakerHasDrift(s)) {
|
||||||
|
issues.push({
|
||||||
|
id: `speaker-drift-${s.id}`,
|
||||||
|
message: `Drift ревизии: ${speakerLabel(s)}`,
|
||||||
|
severity: 'warn'
|
||||||
|
});
|
||||||
|
} else if (s.live?.bgp_poll_error) {
|
||||||
|
issues.push({
|
||||||
|
id: `speaker-poll-${s.id}`,
|
||||||
|
message: `Ошибка BGP-опроса: ${speakerLabel(s)}`,
|
||||||
|
severity: 'warn'
|
||||||
|
});
|
||||||
|
} else if (s.last_dispatch_error) {
|
||||||
|
const err = formatSpeakerError(s.last_dispatch_error);
|
||||||
|
issues.push({
|
||||||
|
id: `speaker-dispatch-${s.id}`,
|
||||||
|
message: `Dispatch: ${speakerLabel(s)}${err ? ` — ${err.detail.slice(0, 80)}` : ''}`,
|
||||||
|
severity: 'warn'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const p of peers) {
|
||||||
|
if (p.session_mismatch) {
|
||||||
|
const name = p.name?.trim() || p.neighbor;
|
||||||
|
issues.push({
|
||||||
|
id: `peer-mismatch-${p.id}`,
|
||||||
|
message: `Mismatch сессии: ${name}`,
|
||||||
|
severity: 'warn'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return issues.slice(0, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NETWORK_AUTO_REFRESH_KEY = 'evobgp.network.autoRefresh';
|
||||||
|
export const NETWORK_AUTO_REFRESH_MS = 15_000;
|
||||||
|
|
||||||
|
export function readNetworkAutoRefresh(): boolean {
|
||||||
|
if (typeof localStorage === 'undefined') return false;
|
||||||
|
return localStorage.getItem(NETWORK_AUTO_REFRESH_KEY) === '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeNetworkAutoRefresh(enabled: boolean): void {
|
||||||
|
if (typeof localStorage === 'undefined') return;
|
||||||
|
localStorage.setItem(NETWORK_AUTO_REFRESH_KEY, enabled ? '1' : '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FormattedSpeakerError = {
|
||||||
|
title: string;
|
||||||
|
detail: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Humanize stored dispatch/agent errors (avoid raw JSON in UI). */
|
||||||
|
export function formatSpeakerError(raw: string | null | undefined): FormattedSpeakerError | null {
|
||||||
|
if (!raw?.trim()) return null;
|
||||||
|
const text = raw.trim();
|
||||||
|
|
||||||
|
const jsonMatch = text.match(/\{[\s\S]*\}/);
|
||||||
|
if (jsonMatch) {
|
||||||
|
try {
|
||||||
|
const obj = JSON.parse(jsonMatch[0]) as {
|
||||||
|
detail?: string;
|
||||||
|
title?: string;
|
||||||
|
status?: number;
|
||||||
|
};
|
||||||
|
const detail = String(obj.detail ?? text);
|
||||||
|
if (/403/.test(detail) && /bundle/i.test(detail)) {
|
||||||
|
return {
|
||||||
|
title: 'Dispatch: доступ к бандлу',
|
||||||
|
detail:
|
||||||
|
'Нода не смогла скачать бандл с CP (403). Проверьте node API-ключ (роль node) и EVOBGP_NODE_TOKEN на реплике — см. docs/access.md.'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const httpPrefix = text.match(/^HTTP \d+:\s*/)?.[0] ?? '';
|
||||||
|
return {
|
||||||
|
title: obj.title && obj.title !== 'Bad Gateway' ? obj.title : 'Ошибка dispatch',
|
||||||
|
detail: httpPrefix ? `${httpPrefix.trim()} ${detail}`.trim() : detail
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
/* fall through */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^HTTP \d+:/.test(text)) {
|
||||||
|
return { title: 'Ошибка HTTP', detail: text };
|
||||||
|
}
|
||||||
|
return { title: 'Ошибка', detail: text };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function speakerDispatchError(s: SpeakerRow): FormattedSpeakerError | null {
|
||||||
|
const liveRev = s.live?.agent_last_applied_revision_id?.trim();
|
||||||
|
const pub = s.published_revision_id?.trim();
|
||||||
|
// CP meta can keep a stale dispatch error after a later successful agent sync.
|
||||||
|
if (s.live?.agent_ok && liveRev && pub && liveRev === pub) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return formatSpeakerError(s.last_dispatch_error);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function speakerLiveAgentError(s: SpeakerRow): FormattedSpeakerError | null {
|
||||||
|
return formatSpeakerError(s.live?.agent_error);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function speakerLiveBgpError(s: SpeakerRow): FormattedSpeakerError | null {
|
||||||
|
return formatSpeakerError(s.live?.bgp_poll_error);
|
||||||
|
}
|
||||||
@@ -45,7 +45,7 @@
|
|||||||
let { cards, loading = false, skeletonCount = 3, class: className }: Props = $props();
|
let { cards, loading = false, skeletonCount = 3, class: className }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class={cn('grid gap-4', className)}>
|
<div class={cn('grid auto-rows-fr gap-4', className)}>
|
||||||
{#if loading}
|
{#if loading}
|
||||||
{#each Array(skeletonCount) as _, i (i)}
|
{#each Array(skeletonCount) as _, i (i)}
|
||||||
<CardSkeleton />
|
<CardSkeleton />
|
||||||
@@ -56,7 +56,7 @@
|
|||||||
{@const a = card.accent}
|
{@const a = card.accent}
|
||||||
<Card
|
<Card
|
||||||
class={cn(
|
class={cn(
|
||||||
'overflow-hidden border-l-4 shadow-sm',
|
'flex h-full flex-col overflow-hidden border-l-4 shadow-sm',
|
||||||
card.href ? 'transition-colors hover:border-primary/35' : '',
|
card.href ? 'transition-colors hover:border-primary/35' : '',
|
||||||
a.border,
|
a.border,
|
||||||
a.bg
|
a.bg
|
||||||
@@ -96,7 +96,7 @@
|
|||||||
>{card.value}</CardTitle
|
>{card.value}</CardTitle
|
||||||
>
|
>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent class="space-y-2">
|
<CardContent class="mt-auto space-y-2">
|
||||||
<Badge variant={card.badgeVariant ?? 'outline'} class={card.badgeClass}
|
<Badge variant={card.badgeVariant ?? 'outline'} class={card.badgeClass}
|
||||||
>{card.badge}</Badge
|
>{card.badge}</Badge
|
||||||
>
|
>
|
||||||
|
|||||||
+51
-16
@@ -31,6 +31,8 @@
|
|||||||
import { notifyApiError } from '$lib/ui/app/toast.js';
|
import { notifyApiError } from '$lib/ui/app/toast.js';
|
||||||
import OverviewRecentJobsCard from '$lib/components/overview/OverviewRecentJobsCard.svelte';
|
import OverviewRecentJobsCard from '$lib/components/overview/OverviewRecentJobsCard.svelte';
|
||||||
import OverviewRecentRevisionsCard from '$lib/components/overview/OverviewRecentRevisionsCard.svelte';
|
import OverviewRecentRevisionsCard from '$lib/components/overview/OverviewRecentRevisionsCard.svelte';
|
||||||
|
import OverviewNetworkStatusCard from '$lib/components/overview/OverviewNetworkStatusCard.svelte';
|
||||||
|
import { aggregateNetworkMetrics } from '$lib/network/network-metrics.js';
|
||||||
import { cn } from '$lib/utils.js';
|
import { cn } from '$lib/utils.js';
|
||||||
import CheckCircle from '@lucide/svelte/icons/check-circle';
|
import CheckCircle from '@lucide/svelte/icons/check-circle';
|
||||||
import XCircle from '@lucide/svelte/icons/x-circle';
|
import XCircle from '@lucide/svelte/icons/x-circle';
|
||||||
@@ -47,6 +49,7 @@
|
|||||||
import Share2 from '@lucide/svelte/icons/share-2';
|
import Share2 from '@lucide/svelte/icons/share-2';
|
||||||
import Play from '@lucide/svelte/icons/play';
|
import Play from '@lucide/svelte/icons/play';
|
||||||
import Gauge from '@lucide/svelte/icons/gauge';
|
import Gauge from '@lucide/svelte/icons/gauge';
|
||||||
|
import NetworkIcon from '@lucide/svelte/icons/network';
|
||||||
|
|
||||||
let healthy = $state<boolean | null>(null);
|
let healthy = $state<boolean | null>(null);
|
||||||
let moduleItems = $state<ModuleRow[]>([]);
|
let moduleItems = $state<ModuleRow[]>([]);
|
||||||
@@ -106,6 +109,8 @@
|
|||||||
return suffix;
|
return suffix;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const networkMetrics = $derived(aggregateNetworkMetrics(peerItems, speakerItems));
|
||||||
|
|
||||||
const kpiCards = $derived.by(() => [
|
const kpiCards = $derived.by(() => [
|
||||||
{
|
{
|
||||||
id: 'modules',
|
id: 'modules',
|
||||||
@@ -120,22 +125,40 @@
|
|||||||
{
|
{
|
||||||
id: 'peers',
|
id: 'peers',
|
||||||
label: 'Пиры',
|
label: 'Пиры',
|
||||||
value: initialLoading ? '—' : String(peerItems.length),
|
value: initialLoading
|
||||||
href: '/network' as const,
|
? '—'
|
||||||
|
: `${networkMetrics.peersEstablished}/${networkMetrics.peersEnabled}`,
|
||||||
|
href: '/network?tab=peers' as const,
|
||||||
icon: GitBranch,
|
icon: GitBranch,
|
||||||
description: 'BGP-соседи',
|
description: 'Established / включённых',
|
||||||
accent: statAccents[1],
|
accent: statAccents[1],
|
||||||
badge: countBadge(peerItems.length, peersHasMore, 'peers')
|
badge:
|
||||||
|
networkMetrics.peersMismatch > 0
|
||||||
|
? `mismatch ${networkMetrics.peersMismatch}`
|
||||||
|
: countBadge(peerItems.length, peersHasMore, 'peers'),
|
||||||
|
badgeClass:
|
||||||
|
networkMetrics.peersMismatch > 0
|
||||||
|
? 'border-warning/30 bg-warning/15 text-warning'
|
||||||
|
: undefined
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'speakers',
|
id: 'speakers',
|
||||||
label: 'Спикеры',
|
label: 'Спикеры',
|
||||||
value: initialLoading ? '—' : String(speakerItems.length),
|
value: initialLoading
|
||||||
href: '/network' as const,
|
? '—'
|
||||||
|
: `${networkMetrics.speakersOnline}/${networkMetrics.speakersTotal}`,
|
||||||
|
href: '/network?tab=overview' as const,
|
||||||
icon: Radio,
|
icon: Radio,
|
||||||
description: 'BIRD-агенты',
|
description: 'online / всего',
|
||||||
accent: statAccents[2],
|
accent: statAccents[2],
|
||||||
badge: countBadge(speakerItems.length, speakersHasMore, 'agents')
|
badge:
|
||||||
|
networkMetrics.speakersOnline < networkMetrics.speakersTotal
|
||||||
|
? 'есть offline'
|
||||||
|
: countBadge(speakerItems.length, speakersHasMore, 'agents'),
|
||||||
|
badgeClass:
|
||||||
|
networkMetrics.speakersOnline === networkMetrics.speakersTotal
|
||||||
|
? 'border-success/30 bg-success/15 text-success'
|
||||||
|
: 'border-warning/30 bg-warning/15 text-warning'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'revisions',
|
id: 'revisions',
|
||||||
@@ -183,8 +206,8 @@
|
|||||||
|
|
||||||
const [m, p, s, r, j] = await Promise.allSettled([
|
const [m, p, s, r, j] = await Promise.allSettled([
|
||||||
apiJSON<ModulesResponse>('/v1/modules?limit=200'),
|
apiJSON<ModulesResponse>('/v1/modules?limit=200'),
|
||||||
apiJSON<PeersResponse>('/v1/peers?limit=200'),
|
apiJSON<PeersResponse>('/v1/peers?limit=200&live=1'),
|
||||||
apiJSON<SpeakersResponse>('/v1/speakers?limit=200'),
|
apiJSON<SpeakersResponse>('/v1/speakers?limit=200&live=1'),
|
||||||
apiJSON<RevisionsResponse>('/v1/revisions?limit=10'),
|
apiJSON<RevisionsResponse>('/v1/revisions?limit=10'),
|
||||||
apiJSON<JobsResponse>('/v1/jobs?limit=10')
|
apiJSON<JobsResponse>('/v1/jobs?limit=10')
|
||||||
]);
|
]);
|
||||||
@@ -251,11 +274,12 @@
|
|||||||
<Info class="text-info" />
|
<Info class="text-info" />
|
||||||
<AlertTitle>Панель управления EvoBGP</AlertTitle>
|
<AlertTitle>Панель управления EvoBGP</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
Сводка по модулям, сети и фоновым задачам. Настройка префиксов — на странице
|
Сводка по модулям, сети и фоновым задачам. BGP и ноды —
|
||||||
<Button variant="link" class="h-auto p-0" href={resolve('/modules')}>Модули</Button>, деплой и
|
<Button variant="link" class="h-auto p-0" href={resolve('/network?tab=overview')}>Сеть</Button
|
||||||
ревизии —
|
>, префиксы —
|
||||||
|
<Button variant="link" class="h-auto p-0" href={resolve('/modules')}>Модули</Button>, деплой —
|
||||||
<Button variant="link" class="h-auto p-0" href={resolve('/operations')}>Операции</Button>,
|
<Button variant="link" class="h-auto p-0" href={resolve('/operations')}>Операции</Button>,
|
||||||
здоровье системы —
|
здоровье API —
|
||||||
<Button variant="link" class="h-auto p-0" href={resolve('/monitoring')}>Мониторинг</Button>.
|
<Button variant="link" class="h-auto p-0" href={resolve('/monitoring')}>Мониторинг</Button>.
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
@@ -301,7 +325,7 @@
|
|||||||
class="sm:grid-cols-2 lg:grid-cols-3"
|
class="sm:grid-cols-2 lg:grid-cols-3"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="grid gap-4 lg:grid-cols-2">
|
<div class="grid gap-4 lg:grid-cols-3">
|
||||||
<OverviewRecentJobsCard
|
<OverviewRecentJobsCard
|
||||||
items={recentJobs}
|
items={recentJobs}
|
||||||
{moduleNameById}
|
{moduleNameById}
|
||||||
@@ -315,6 +339,13 @@
|
|||||||
{initialLoading}
|
{initialLoading}
|
||||||
error={loadError}
|
error={loadError}
|
||||||
/>
|
/>
|
||||||
|
<OverviewNetworkStatusCard
|
||||||
|
peers={peerItems}
|
||||||
|
speakers={speakerItems}
|
||||||
|
loading={refreshing}
|
||||||
|
{initialLoading}
|
||||||
|
error={loadError}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
@@ -331,7 +362,11 @@
|
|||||||
<Tags class="size-4" />
|
<Tags class="size-4" />
|
||||||
Добавить community
|
Добавить community
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" size="sm" href={resolve('/network')}>
|
<Button variant="outline" size="sm" href={resolve('/network?tab=overview')}>
|
||||||
|
<NetworkIcon class="size-4" />
|
||||||
|
Сеть
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" href={resolve('/network?tab=peers')}>
|
||||||
<Share2 class="size-4" />
|
<Share2 class="size-4" />
|
||||||
Добавить пира
|
Добавить пира
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -198,7 +198,7 @@
|
|||||||
? 'border-success/30 bg-success/15 text-success'
|
? 'border-success/30 bg-success/15 text-success'
|
||||||
: undefined,
|
: undefined,
|
||||||
error: birdError ?? bird?.error ?? null,
|
error: birdError ?? bird?.error ?? null,
|
||||||
href: '/network' as const
|
href: '/network?tab=overview' as const
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'jobs',
|
id: 'jobs',
|
||||||
@@ -468,7 +468,9 @@
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>GET /v1/bird/status</CardDescription>
|
<CardDescription>GET /v1/bird/status</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" size="sm" href={resolve('/network')}>Пиры и спикеры</Button>
|
<Button variant="outline" size="sm" href={resolve('/network?tab=overview')}
|
||||||
|
>Пиры и спикеры</Button
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent class="space-y-4">
|
<CardContent class="space-y-4">
|
||||||
@@ -633,7 +635,9 @@
|
|||||||
<AlertTitle>Низкий ratio BGP</AlertTitle>
|
<AlertTitle>Низкий ratio BGP</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
Проверьте <code class="text-xs">/v1/bird/status</code>, затем состояние пиров в
|
Проверьте <code class="text-xs">/v1/bird/status</code>, затем состояние пиров в
|
||||||
<Button variant="link" class="h-auto p-0" href={resolve('/network')}>Сети</Button>.
|
<Button variant="link" class="h-auto p-0" href={resolve('/network?tab=overview')}
|
||||||
|
>Сети</Button
|
||||||
|
>.
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
<Alert>
|
<Alert>
|
||||||
|
|||||||
@@ -4,101 +4,57 @@
|
|||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
import { apiJSON } from '$lib/api/client.js';
|
import { apiJSON } from '$lib/api/client.js';
|
||||||
import type { PeerRow, PeersResponse, SpeakerRow, SpeakersResponse } from '$lib/api/types.js';
|
import type {
|
||||||
|
BirdStatus,
|
||||||
|
PeerRow,
|
||||||
|
PeersResponse,
|
||||||
|
SpeakerRow,
|
||||||
|
SpeakersResponse
|
||||||
|
} from '$lib/api/types.js';
|
||||||
|
import { NETWORK_AUTO_REFRESH_MS } from '$lib/network/network-metrics.js';
|
||||||
import { Button } from '$lib/ui/core/button/index.js';
|
import { Button } from '$lib/ui/core/button/index.js';
|
||||||
import { Alert, AlertDescription, AlertTitle } from '$lib/ui/core/alert/index.js';
|
import { Alert, AlertDescription, AlertTitle } from '$lib/ui/core/alert/index.js';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/ui/core/tabs/index.js';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/ui/core/tabs/index.js';
|
||||||
import KpiMetricsGrid from '$lib/ui/patterns/kpi/kpi-metrics-grid.svelte';
|
|
||||||
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
|
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
|
||||||
import { notifyApiError } from '$lib/ui/app/toast.js';
|
import { notifyApiError } from '$lib/ui/app/toast.js';
|
||||||
import NetworkPeersCard from '$lib/components/network/NetworkPeersCard.svelte';
|
import NetworkPeersCard from '$lib/components/network/NetworkPeersCard.svelte';
|
||||||
import NetworkSpeakersCard from '$lib/components/network/NetworkSpeakersCard.svelte';
|
import NetworkSpeakersCard from '$lib/components/network/NetworkSpeakersCard.svelte';
|
||||||
import BirdSettingsForm from '$lib/components/network/BirdSettingsForm.svelte';
|
import BirdSettingsForm from '$lib/components/network/BirdSettingsForm.svelte';
|
||||||
|
import NetworkOverviewTab from '$lib/components/network/NetworkOverviewTab.svelte';
|
||||||
|
import NetworkSpeakerDetailSheet from '$lib/components/network/NetworkSpeakerDetailSheet.svelte';
|
||||||
|
import NetworkAutoRefreshToggle from '$lib/components/network/NetworkAutoRefreshToggle.svelte';
|
||||||
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
||||||
import NetworkIcon from '@lucide/svelte/icons/network';
|
import NetworkIcon from '@lucide/svelte/icons/network';
|
||||||
import Info from '@lucide/svelte/icons/info';
|
import Info from '@lucide/svelte/icons/info';
|
||||||
import Share2 from '@lucide/svelte/icons/share-2';
|
import LayoutDashboard from '@lucide/svelte/icons/layout-dashboard';
|
||||||
import CheckCircle2 from '@lucide/svelte/icons/check-circle-2';
|
|
||||||
import Server from '@lucide/svelte/icons/server';
|
|
||||||
|
|
||||||
type NetworkTab = 'peers' | 'speakers' | 'control-plane';
|
type NetworkTab = 'overview' | 'peers' | 'speakers' | 'control-plane';
|
||||||
|
|
||||||
function parseNetworkTab(value: string | null): NetworkTab {
|
function parseNetworkTab(value: string | null): NetworkTab {
|
||||||
if (value === 'speakers' || value === 'control-plane') return value;
|
if (value === 'peers' || value === 'speakers' || value === 'control-plane') return value;
|
||||||
return 'peers';
|
return 'overview';
|
||||||
}
|
}
|
||||||
|
|
||||||
let peers = $state<PeerRow[]>([]);
|
let peers = $state<PeerRow[]>([]);
|
||||||
let speakers = $state<SpeakerRow[]>([]);
|
let speakers = $state<SpeakerRow[]>([]);
|
||||||
|
let birdStatus = $state<BirdStatus | null>(null);
|
||||||
let peersLoading = $state(false);
|
let peersLoading = $state(false);
|
||||||
let speakersLoading = $state(false);
|
let speakersLoading = $state(false);
|
||||||
let initialLoading = $state(true);
|
let initialLoading = $state(true);
|
||||||
let loadError = $state<string | null>(null);
|
let loadError = $state<string | null>(null);
|
||||||
let lastUpdated = $state<Date | null>(null);
|
let lastUpdated = $state<Date | null>(null);
|
||||||
let activeTab = $state<NetworkTab>('peers');
|
let activeTab = $state<NetworkTab>('overview');
|
||||||
let tabSyncReady = $state(false);
|
let tabSyncReady = $state(false);
|
||||||
|
let autoRefresh = $state(false);
|
||||||
const establishedCount = $derived(peers.filter((p) => p.session_state === 'Established').length);
|
let detailSpeaker = $state<SpeakerRow | null>(null);
|
||||||
|
let detailOpen = $state(false);
|
||||||
const statAccents = [
|
|
||||||
{
|
|
||||||
border: 'border-l-chart-3',
|
|
||||||
bg: 'bg-chart-3/5',
|
|
||||||
iconBg: 'bg-chart-3/15',
|
|
||||||
iconText: 'text-chart-3'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
border: 'border-l-chart-2',
|
|
||||||
bg: 'bg-chart-2/5',
|
|
||||||
iconBg: 'bg-chart-2/15',
|
|
||||||
iconText: 'text-chart-2'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
border: 'border-l-chart-4',
|
|
||||||
bg: 'bg-chart-4/5',
|
|
||||||
iconBg: 'bg-chart-4/15',
|
|
||||||
iconText: 'text-chart-4'
|
|
||||||
}
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const kpiCards = $derived.by(() => [
|
|
||||||
{
|
|
||||||
id: 'peers',
|
|
||||||
label: 'BGP-пиры',
|
|
||||||
value: initialLoading ? '—' : String(peers.length),
|
|
||||||
description: 'настроенные BGP-соседи',
|
|
||||||
icon: Share2,
|
|
||||||
accent: statAccents[0],
|
|
||||||
badge: 'peers'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'established',
|
|
||||||
label: 'Активные сессии',
|
|
||||||
value: initialLoading ? '—' : String(establishedCount),
|
|
||||||
description:
|
|
||||||
establishedCount > 0 ? 'Established из текущей выборки' : 'нет установленных сессий',
|
|
||||||
icon: CheckCircle2,
|
|
||||||
accent: statAccents[1],
|
|
||||||
badge: establishedCount > 0 ? 'Established' : 'нет сессий',
|
|
||||||
badgeClass: establishedCount > 0 ? 'border-success/30 bg-success/15 text-success' : undefined
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'speakers',
|
|
||||||
label: 'Спикеры',
|
|
||||||
value: initialLoading ? '—' : String(speakers.length),
|
|
||||||
description: 'BIRD-агенты на нодах',
|
|
||||||
icon: Server,
|
|
||||||
accent: statAccents[2],
|
|
||||||
badge: 'agents'
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
|
|
||||||
const refreshing = $derived(peersLoading || speakersLoading);
|
const refreshing = $derived(peersLoading || speakersLoading);
|
||||||
|
|
||||||
async function loadPeers() {
|
async function loadPeers() {
|
||||||
peersLoading = true;
|
peersLoading = true;
|
||||||
try {
|
try {
|
||||||
const pr = await apiJSON<PeersResponse>('/v1/peers?limit=200');
|
const pr = await apiJSON<PeersResponse>('/v1/peers?limit=200&live=1');
|
||||||
peers = pr.items;
|
peers = pr.items;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
loadError = e instanceof Error ? e.message : String(e);
|
loadError = e instanceof Error ? e.message : String(e);
|
||||||
@@ -112,7 +68,7 @@
|
|||||||
async function loadSpeakers() {
|
async function loadSpeakers() {
|
||||||
speakersLoading = true;
|
speakersLoading = true;
|
||||||
try {
|
try {
|
||||||
const sr = await apiJSON<SpeakersResponse>('/v1/speakers?limit=200');
|
const sr = await apiJSON<SpeakersResponse>('/v1/speakers?limit=200&live=1');
|
||||||
speakers = sr.items;
|
speakers = sr.items;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
loadError = e instanceof Error ? e.message : String(e);
|
loadError = e instanceof Error ? e.message : String(e);
|
||||||
@@ -123,10 +79,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadBirdStatus() {
|
||||||
|
try {
|
||||||
|
birdStatus = await apiJSON<BirdStatus>('/v1/bird/status');
|
||||||
|
} catch {
|
||||||
|
birdStatus = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loadError = null;
|
loadError = null;
|
||||||
try {
|
try {
|
||||||
await Promise.all([loadPeers(), loadSpeakers()]);
|
await Promise.all([loadPeers(), loadSpeakers(), loadBirdStatus()]);
|
||||||
lastUpdated = new Date();
|
lastUpdated = new Date();
|
||||||
} catch {
|
} catch {
|
||||||
// errors handled in loaders
|
// errors handled in loaders
|
||||||
@@ -153,6 +117,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openSpeakerDetail(speaker: SpeakerRow) {
|
||||||
|
detailSpeaker = speaker;
|
||||||
|
detailOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleApplyFromDetail(_speaker: SpeakerRow) {
|
||||||
|
detailOpen = false;
|
||||||
|
activeTab = 'speakers';
|
||||||
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
activeTab = parseNetworkTab(page.url.searchParams.get('tab'));
|
activeTab = parseNetworkTab(page.url.searchParams.get('tab'));
|
||||||
tabSyncReady = true;
|
tabSyncReady = true;
|
||||||
@@ -162,7 +136,7 @@
|
|||||||
function syncTabToUrl(tab: NetworkTab) {
|
function syncTabToUrl(tab: NetworkTab) {
|
||||||
if (!tabSyncReady) return;
|
if (!tabSyncReady) return;
|
||||||
const url = new URL(page.url);
|
const url = new URL(page.url);
|
||||||
if (tab === 'peers') url.searchParams.delete('tab');
|
if (tab === 'overview') url.searchParams.delete('tab');
|
||||||
else url.searchParams.set('tab', tab);
|
else url.searchParams.set('tab', tab);
|
||||||
const next = `${url.pathname}${url.search}${url.hash}`;
|
const next = `${url.pathname}${url.search}${url.hash}`;
|
||||||
if (next !== `${page.url.pathname}${page.url.search}${page.url.hash}`) {
|
if (next !== `${page.url.pathname}${page.url.search}${page.url.hash}`) {
|
||||||
@@ -174,18 +148,38 @@
|
|||||||
if (!tabSyncReady) return;
|
if (!tabSyncReady) return;
|
||||||
syncTabToUrl(activeTab);
|
syncTabToUrl(activeTab);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!autoRefresh) return;
|
||||||
|
const id = setInterval(() => {
|
||||||
|
void load();
|
||||||
|
}, NETWORK_AUTO_REFRESH_MS);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (detailSpeaker) {
|
||||||
|
const updated = speakers.find((s) => s.id === detailSpeaker!.id);
|
||||||
|
if (updated) detailSpeaker = updated;
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-col gap-6">
|
<div class="flex flex-col gap-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Сеть"
|
title="Сеть"
|
||||||
description={lastUpdated
|
description={lastUpdated
|
||||||
? `BGP-пиры и спикеры (BIRD-агенты). Обновлено: ${lastUpdated.toLocaleTimeString('ru-RU')}`
|
? `BGP-топология CP и нод. Обновлено: ${lastUpdated.toLocaleTimeString('ru-RU')}`
|
||||||
: 'BGP-пиры и спикеры (BIRD-агенты).'}
|
: 'BGP-пиры, спикеры и live-метрики нод.'}
|
||||||
icon={NetworkIcon}
|
icon={NetworkIcon}
|
||||||
iconClass="bg-chart-3/15 text-chart-3"
|
iconClass="bg-chart-3/15 text-chart-3"
|
||||||
>
|
>
|
||||||
{#snippet actions()}
|
{#snippet actions()}
|
||||||
|
<Button variant="ghost" size="sm" href={resolve('/')}>
|
||||||
|
<LayoutDashboard class="size-3.5" />
|
||||||
|
Обзор
|
||||||
|
</Button>
|
||||||
|
<NetworkAutoRefreshToggle bind:enabled={autoRefresh} disabled={refreshing} />
|
||||||
<Button variant="outline" size="sm" onclick={load} disabled={refreshing}>
|
<Button variant="outline" size="sm" onclick={load} disabled={refreshing}>
|
||||||
<RefreshCw class={refreshing ? 'animate-spin' : ''} />
|
<RefreshCw class={refreshing ? 'animate-spin' : ''} />
|
||||||
Обновить
|
Обновить
|
||||||
@@ -197,26 +191,30 @@
|
|||||||
<Info class="text-info" />
|
<Info class="text-info" />
|
||||||
<AlertTitle>О сетевой конфигурации</AlertTitle>
|
<AlertTitle>О сетевой конфигурации</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
Пиры привязаны к спикерам (BIRD-агентам). Apply запускает применение ревизии на ноде. Полный
|
Вкладка «Обзор» — live-статус agent и BGP на CP и репликах. Apply и ревизии — на
|
||||||
список ревизий и задач — на странице
|
|
||||||
<Button variant="link" class="h-auto p-0" href={resolve('/operations')}>Операции</Button>.
|
<Button variant="link" class="h-auto p-0" href={resolve('/operations')}>Операции</Button>.
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
<KpiMetricsGrid
|
|
||||||
cards={kpiCards}
|
|
||||||
loading={initialLoading}
|
|
||||||
skeletonCount={3}
|
|
||||||
class="sm:grid-cols-3"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Tabs bind:value={activeTab}>
|
<Tabs bind:value={activeTab}>
|
||||||
<TabsList>
|
<TabsList>
|
||||||
|
<TabsTrigger value="overview">Обзор</TabsTrigger>
|
||||||
<TabsTrigger value="peers">Пиры</TabsTrigger>
|
<TabsTrigger value="peers">Пиры</TabsTrigger>
|
||||||
<TabsTrigger value="speakers">Спикеры</TabsTrigger>
|
<TabsTrigger value="speakers">Спикеры</TabsTrigger>
|
||||||
<TabsTrigger value="control-plane">Control plane</TabsTrigger>
|
<TabsTrigger value="control-plane">Control plane</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="overview" class="mt-4 min-w-0">
|
||||||
|
<NetworkOverviewTab
|
||||||
|
{peers}
|
||||||
|
{speakers}
|
||||||
|
bird={birdStatus}
|
||||||
|
loading={refreshing}
|
||||||
|
{initialLoading}
|
||||||
|
onSpeakerSelect={openSpeakerDetail}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="peers" class="mt-4">
|
<TabsContent value="peers" class="mt-4">
|
||||||
<NetworkPeersCard
|
<NetworkPeersCard
|
||||||
items={peers}
|
items={peers}
|
||||||
@@ -235,6 +233,7 @@
|
|||||||
{initialLoading}
|
{initialLoading}
|
||||||
error={loadError}
|
error={loadError}
|
||||||
onRefresh={refreshSpeakers}
|
onRefresh={refreshSpeakers}
|
||||||
|
onSpeakerSelect={openSpeakerDetail}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
@@ -243,3 +242,10 @@
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<NetworkSpeakerDetailSheet
|
||||||
|
speaker={detailSpeaker}
|
||||||
|
{peers}
|
||||||
|
bind:open={detailOpen}
|
||||||
|
onApply={handleApplyFromDetail}
|
||||||
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user