docs: enhance EvoBGP architecture plan with new risk mitigation strategies, testing matrix for BIRD2, and CI/CD workflow details. Updated section titles for clarity and added completed todos for deployment practices.
CI / openapi (push) Successful in 1m35s
CI / go (push) Failing after 16s
CI / bird2 (push) Has been skipped

This commit is contained in:
Denozordec
2026-04-04 01:27:17 +07:00
parent 4480d64a7f
commit 3992d01c3e
30 changed files with 621 additions and 2 deletions
@@ -26,6 +26,15 @@ todos:
- id: replica-bundle - id: replica-bundle
content: Подписанный бандл ревизии, API, evobgp-node content: Подписанный бандл ревизии, API, evobgp-node
status: pending status: pending
- id: risk-hardening
content: Двухфазный deploy, LKG, pin BIRD, политика миграций, обязательная подпись бандла на ноде
status: completed
- id: test-bird-matrix
content: Матрица сценариев BIRD + golden-тесты birdfmt + bird -p в CI
status: completed
- id: ci-gitea
content: .gitea/workflows/ci.yaml, документация для act runner
status: completed
isProject: false isProject: false
--- ---
@@ -50,7 +59,10 @@ Control-plane на **Go**, анонс префиксов через **BIRD**, п
10. [Реплика evobgp-node](#10-реплика-evobgp-node) 10. [Реплика evobgp-node](#10-реплика-evobgp-node)
11. [Выбор СУБД](#11-выбор-субд) 11. [Выбор СУБД](#11-выбор-субд)
12. [Профиль microVPS — детализация](#12-профиль-microvps-детализация) 12. [Профиль microVPS — детализация](#12-профиль-microvps-детализация)
13. [Риски и этапы](#13-риски-и-этапы-внедрения) 13. [Снижение рисков (меры и процессы)](#13-снижение-рисков-меры-и-процессы)
14. [Тестирование BIRD2 и матрица сценариев](#14-тестирование-bird2-и-матрица-сценариев)
15. [CI/CD (Gitea Actions)](#15-cicd-gitea-actions)
16. [Риски и этапы внедрения](#16-риски-и-этапы-внедрения)
--- ---
@@ -576,7 +588,75 @@ Raft, HTTP/`gorqlite`; для HA control-plane; не замена дефолтн
--- ---
## 13. Риски и этапы внедрения ## 13. Снижение рисков (меры и процессы)
Дополняет [§9](#9-эксплуатация-и-масштаб) и [§10](#10-реплика-evobgp-node) конкретными обязательными практиками.
### Конфигурация BIRD: безопасное применение
- **Двухфазный deploy:** (1) запись новой ревизии во **временный** каталог на общем volume и проверка `**bird -c <path> -p`** (парсинг без запуска демона; см. `bird(8)`); (2) только при нулевом коде выхода — **атомарная** подмена активных файлов (rename) и `**birdc configure`**. Опционально перед фазой (2) — `preview`/diff в control-plane (REST).
- **Last-known-good (LKG):** хранить на volume **предыдущую** применённую ревизию; при неуспехе `configure` или ненулевом exit **автоматически** восстановить файлы LKG, зафиксировать событие в логах/метриках и **не** оставлять BIRD в полусобранном состоянии.
- **Canary в production:** перед полным apply — отдельный `bgp_speaker` или подмножество `bgp_peer` (см. [§9](#9-эксплуатация-и-масштаб)); полный выкат только после проверки сессий/префиксов на канареечном спикере.
### Данные, очередь, microVPS
- **Миграции:** в основной ветке — только **вперёд**; откат схемы — явные down-миграции (если приняты в процессе) или восстановление БД из бэкапа; политика фиксируется в операторской документации.
- **Jobs:** обязательные `**idempotency_key`** и уникальность в `job_audit` там, где это предотвращает двойной apply/reload.
- **microVPS:** пороги мониторинга на рост таблиц ревизий/артефактов, **retention** старых ревизий, лимиты ротации логов Docker (см. [§12](#12-профиль-microvps-детализация)) — с **алертами** при приближении к лимиту диска и OOM.
### Безопасность
- **evobgp-node:** в production **обязательна** проверка **подписи** бандла (отдельно от TLS транспорта); ключ подписи не смешивать с другими ролями.
- Секреты BGP (пароли, ключи) — только secret store / Docker secrets; **не** логировать полные конфиги с секретами.
### Поставка и совместимость
- В **Dockerfile** / Compose зафиксировать **версию образа BIRD 2** (тег minor или digest), совпадающую с образом, в котором выполняется `**bird -p`** в CI ([§15](#15-cicd-gitea-actions)).
- **Статический каркас** (router id, локальные интерфейсы, операторские правки) — в файлах **вне** автогенерируемых фрагментов; сгенерированное — только в согласованных путях `bird.d/` (см. [§8](#8-генерация-bird-и-ревизии), [§10](#10-реплика-evobgp-node)).
### Порядок внедрения (уточнение)
Перед полным набором микросервисов целесообразен параллельный этап: **контракт OpenAPI + каркас `internal/birdfmt` + golden-тесты + проверка `bird -p` в CI** ([§14](#14-тестирование-bird2-и-матрица-сценариев), [§15](#15-cicd-gitea-actions)), затем миграции PG и один вертикальный сценарий (например `IP_RANGES`).
---
## 14. Тестирование BIRD2 и матрица сценариев
Цель — покрыть **поверхность генератора** EvoBGP, а не весь язык BIRD. Комбинации фиксируются **матрицей** и каталогом сценариев в репозитории (`internal/birdfmt/testdata/scenarios/`).
### Матрица покрытия (ориентир)
| Измерение | Варианты для покрытия |
| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| Роль спикера | master (полный набор фрагментов); реплика / **бандл** для `evobgp-node` (manifest + подмножество includes + локальный `local.conf`) |
| Типы модулей (выход render) | `AS_PREFIXES`, `CDN_CIDRS`, `DOMAINS` (материализованные префиксы), `IP_RANGES` — минимум по одному сценарию; комбинация **2+ типов** в одной ревизии |
| Пиры | при поддержке каркасом — только static; **1× IPv4**, **1× IPv6**, несколько пиров, смешанный v4/v6 |
| Community | каждый поддерживаемый `kind` в справочнике + вариант без community (default) |
| Фильтры | экспорт «разрешить анонс»; при генерации — отрицательные кейсы (reject) |
| Граничные данные | пустой префикс-лист; один префикс; большой список (нагрузка на размер файла) |
| Шаблоны include | каждый именуемый фрагмент из `internal/birdfmt` участвует хотя бы в одном интеграционном сценарии |
По мере расширения генератора матрица **дополняется**; регрессия — новыми строками в таблице тестов и при необходимости новыми подкаталогами сценариев.
### Виды тестов
1. **Unit / snapshot (Go):** пакет `internal/birdfmt` — вход из фикстур, выход сравнивается с `*.golden` или `txtar`.
2. **Синтаксис BIRD в CI:** для каждого сценария с `bird.conf` выполняется `**bird -c … -p`** в контейнере с **той же major/minor версией BIRD 2**, что в production ([§13](#13-снижение-рисков-меры-и-процессы)).
3. **Позже:** контрактные тесты HTTP по `docs/openapi.yaml` после реализации `internal/httpapi`.
---
## 15. CI/CD (Gitea Actions)
- Workflows: каталог `**.gitea/workflows/`** в корне репозитория; синтаксис совместим с GitHub Actions ([документация Gitea Actions](https://docs.gitea.com/usage/actions/quickstart/)).
- Нужен зарегистрированный **act runner** с меткой `ubuntu-latest` (или согласованной с инсталляцией) и при job с Docker — доступ **Docker** на runner.
- Рекомендуемый pipeline: **lint OpenAPI** (`npx @redocly/cli lint docs/openapi.yaml`), `**go vet` / `go test` / `go build ./...`**, **проверка всех `testdata/scenarios/*/bird.conf` через `bird -p`** (см. workflow в репозитории).
---
## 16. Риски и этапы внедрения
### Риски ### Риски
+10
View File
@@ -0,0 +1,10 @@
# Gitea Actions
В репозитории включён workflow [workflows/ci.yaml](workflows/ci.yaml).
Требования к runner:
- Метка `ubuntu-latest` (или измените `runs-on` под свои labels).
- Для job `bird2` на runner должен быть доступен **Docker** (проверка `bird -c … -p` в контейнере `debian:bookworm-slim` с пакетом `bird2`).
Секреты не обязательны для базового CI; для публикации образов добавьте свои шаги и credentials в отдельный workflow.
+56
View File
@@ -0,0 +1,56 @@
name: CI
on:
push:
branches: [main, master]
pull_request:
branches: [main, master]
jobs:
openapi:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
- name: Lint OpenAPI (Redocly)
run: npx --yes @redocly/cli@1 lint docs/openapi.yaml
go:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.22"
- name: Vet
run: go vet ./...
- name: Test
run: go test ./... -race -count=1
- name: Build all commands
run: |
set -euxo pipefail
out="${RUNNER_TEMP}/evobgp-bin"
mkdir -p "$out"
for d in cmd/*/; do
name="$(basename "$d")"
go build -o "$out/$name" "./$d"
done
bird2:
runs-on: ubuntu-latest
needs: [go]
steps:
- uses: actions/checkout@v4
- name: bird -p on all scenario bird.conf files
run: |
set -euxo pipefail
for conf in internal/birdfmt/testdata/scenarios/*/bird.conf; do
echo "==> $conf"
docker run --rm \
-e "BIRD_CONF=/work/${conf}" \
-v "$PWD:/work:ro" \
debian:bookworm-slim \
bash -ceu 'apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq bird2 >/dev/null && bird -c "$BIRD_CONF" -p'
done
+3
View File
@@ -0,0 +1,3 @@
package main
func main() {}
+3
View File
@@ -0,0 +1,3 @@
package main
func main() {}
+3
View File
@@ -0,0 +1,3 @@
package main
func main() {}
+3
View File
@@ -0,0 +1,3 @@
package main
func main() {}
+3
View File
@@ -0,0 +1,3 @@
package main
func main() {}
+3
View File
@@ -0,0 +1,3 @@
package main
func main() {}
+3
View File
@@ -0,0 +1,3 @@
package main
func main() {}
+3
View File
@@ -0,0 +1,3 @@
package main
func main() {}
+3
View File
@@ -0,0 +1,3 @@
module evobgp
go 1.22
+64
View File
@@ -0,0 +1,64 @@
package birdfmt
import (
"embed"
"io/fs"
"path"
"sort"
)
//go:embed testdata/scenarios
var scenariosFS embed.FS
// BirdScenarioPaths returns POSIX-style directory paths (under testdata/scenarios)
// that contain a bird.conf file, sorted for stable iteration.
func BirdScenarioPaths() ([]string, error) {
var dirs []string
err := fs.WalkDir(scenariosFS, "testdata/scenarios", func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() || d.Name() != "bird.conf" {
return nil
}
dir := path.Dir(p)
if dir == "testdata/scenarios" {
return nil
}
dirs = append(dirs, dir)
return nil
})
if err != nil {
return nil, err
}
sort.Strings(dirs)
return dirs, nil
}
// ScenarioBirdConfRel returns the path relative to module root internal/birdfmt/ for bird.conf.
func ScenarioBirdConfRel(scenarioDir string) string {
return path.Join(scenarioDir, "bird.conf")
}
// ReadScenarioBirdConf returns the contents of bird.conf for a scenario directory from embed.
func ReadScenarioBirdConf(scenarioDir string) ([]byte, error) {
return scenariosFS.ReadFile(path.Join(scenarioDir, "bird.conf"))
}
// RepoRelativeBirdConf is the path from the repository root (for CI / docker bind mounts).
func RepoRelativeBirdConf(scenarioDir string) string {
return path.Join("internal/birdfmt", scenarioDir, "bird.conf")
}
// AllScenarioNames returns leaf scenario names (last path segment) for tables / docs.
func AllScenarioNames() ([]string, error) {
paths, err := BirdScenarioPaths()
if err != nil {
return nil, err
}
names := make([]string, len(paths))
for i, p := range paths {
names[i] = path.Base(p)
}
return names, nil
}
+68
View File
@@ -0,0 +1,68 @@
package birdfmt
import (
"path"
"slices"
"strings"
"testing"
)
func TestBirdScenarioPaths_Table(t *testing.T) {
paths, err := BirdScenarioPaths()
if err != nil {
t.Fatal(err)
}
// Expected scenario leaf names — extend when adding directories under testdata/scenarios.
wantNames := []string{
"bgp_dual_peer",
"bgp_ipv4_peer",
"bgp_ipv6_peer",
"domains_resolved",
"empty_static",
"filter_export",
"large_prefix_list",
"mixed_static_bgp",
"minimal",
"static_ipv4",
"with_include",
}
var gotNames []string
for _, p := range paths {
gotNames = append(gotNames, path.Base(p))
}
slices.Sort(gotNames)
if !slices.Equal(gotNames, wantNames) {
t.Fatalf("scenario set drift: got %v want %v", gotNames, wantNames)
}
for _, p := range paths {
t.Run(path.Base(p), func(t *testing.T) {
b, err := ReadScenarioBirdConf(p)
if err != nil {
t.Fatal(err)
}
if len(b) == 0 {
t.Fatal("empty bird.conf")
}
if !strings.Contains(string(b), "router id") {
t.Fatal("expected router id in scenario")
}
})
}
}
func TestRepoRelativeBirdConf_Table(t *testing.T) {
paths, err := BirdScenarioPaths()
if err != nil {
t.Fatal(err)
}
for _, p := range paths {
rel := RepoRelativeBirdConf(p)
if !strings.HasPrefix(rel, "internal/birdfmt/") {
t.Fatalf("%s: bad prefix %q", p, rel)
}
if !strings.HasSuffix(rel, "/bird.conf") {
t.Fatalf("%s: expected .../bird.conf, got %q", p, rel)
}
}
}
+39
View File
@@ -0,0 +1,39 @@
package birdfmt
import (
"net/netip"
"sort"
"strings"
)
// RenderStaticIPv4Protocol renders a BIRD 2 `protocol static` block for IPv4 prefixes.
// Prefixes are sorted for stable output (golden tests, reproducible rev).
func RenderStaticIPv4Protocol(protocolName string, prefixes []netip.Prefix) string {
if protocolName == "" {
protocolName = "evobgp_static_v4"
}
uniq := make(map[string]netip.Prefix)
for _, p := range prefixes {
if !p.Addr().Is4() {
continue
}
uniq[p.String()] = p.Masked()
}
keys := make([]string, 0, len(uniq))
for k := range uniq {
keys = append(keys, k)
}
sort.Strings(keys)
var b strings.Builder
b.WriteString("protocol static ")
b.WriteString(protocolName)
b.WriteString(" {\n ipv4;\n")
for _, k := range keys {
b.WriteString(" route ")
b.WriteString(k)
b.WriteString(" unreachable;\n")
}
b.WriteString("}\n")
return b.String()
}
+34
View File
@@ -0,0 +1,34 @@
package birdfmt
import (
_ "embed"
"net/netip"
"strings"
"testing"
)
//go:embed testdata/golden/static_ipv4_small.golden
var goldenStaticIPv4Small string
func TestRenderStaticIPv4Protocol_Golden(t *testing.T) {
p1 := netip.MustParsePrefix("203.0.113.0/24")
p2 := netip.MustParsePrefix("198.51.100.0/24")
// duplicate to assert dedup
got := RenderStaticIPv4Protocol("evobgp_test", []netip.Prefix{p1, p2, p1})
want := strings.TrimSuffix(goldenStaticIPv4Small, "\n")
got = strings.TrimSuffix(got, "\n")
if got != want {
t.Fatalf("golden mismatch\n--- got ---\n%s\n--- want ---\n%s", got, want)
}
}
func TestRenderStaticIPv4Protocol_Empty(t *testing.T) {
got := RenderStaticIPv4Protocol("evobgp_empty", nil)
if !strings.Contains(got, "protocol static evobgp_empty") {
t.Fatal(got)
}
if !strings.Contains(got, "ipv4") {
t.Fatal(got)
}
}
@@ -0,0 +1,5 @@
protocol static evobgp_test {
ipv4;
route 198.51.100.0/24 unreachable;
route 203.0.113.0/24 unreachable;
}
@@ -0,0 +1,28 @@
# tags: peer, ipv4, ipv6, mixed
router id 192.0.2.1;
protocol device {
}
protocol direct {
ipv4;
ipv6;
}
protocol bgp peer_a {
local 192.0.2.1 as 65001;
neighbor 192.0.2.2 as 65002;
ipv4 {
import all;
export all;
};
}
protocol bgp peer_b {
local fd00:ebgp::1 as 65001;
neighbor fd00:ebgp::3 as 65003;
ipv6 {
import all;
export all;
};
}
@@ -0,0 +1,19 @@
# tags: peer, ipv4, AS_PREFIXES
router id 192.0.2.1;
protocol device {
}
protocol direct {
ipv4;
ipv6;
}
protocol bgp peer_v4 {
local 192.0.2.1 as 65001;
neighbor 192.0.2.2 as 65002;
ipv4 {
import all;
export all;
};
}
@@ -0,0 +1,19 @@
# tags: peer, ipv6, AS_PREFIXES
router id 192.0.2.1;
protocol device {
}
protocol direct {
ipv4;
ipv6;
}
protocol bgp peer_v6 {
local fd00:ebgp::1 as 65001;
neighbor fd00:ebgp::2 as 65002;
ipv6 {
import all;
export all;
};
}
@@ -0,0 +1,18 @@
# tags: DOMAINS, module_output
# DOMAINS modules materialize to prefixes before render; same shape as static IPv4.
router id 192.0.2.1;
protocol device {
}
protocol direct {
ipv4;
ipv6;
}
protocol static evobgp_from_domains {
ipv4;
route 192.0.2.10/32 unreachable;
route 192.0.2.11/32 unreachable;
}
@@ -0,0 +1,16 @@
# tags: edge, IP_RANGES
# Empty static IPv4 table (generator must emit valid empty protocol).
router id 192.0.2.1;
protocol device {
}
protocol direct {
ipv4;
ipv6;
}
protocol static evobgp_empty {
ipv4;
}
@@ -0,0 +1,26 @@
# tags: community, filter, peer
# Export filter (placeholder for community-aware export).
router id 192.0.2.1;
protocol device {
}
protocol direct {
ipv4;
ipv6;
}
filter evobgp_export_v4 {
if net ~ [ 203.0.113.0/24 ] then accept;
reject;
}
protocol bgp peer_filtered {
local 192.0.2.1 as 65001;
neighbor 192.0.2.2 as 65002;
ipv4 {
import all;
export filter evobgp_export_v4;
};
}
@@ -0,0 +1,12 @@
# tags: stress, CDN_CIDRS
router id 192.0.2.1;
include "bird.d/bulk_static.conf";
protocol device {
}
protocol direct {
ipv4;
ipv6;
}
@@ -0,0 +1,23 @@
protocol static evobgp_bulk {
ipv4;
route 10.200.0.0/24 unreachable;
route 10.200.1.0/24 unreachable;
route 10.200.2.0/24 unreachable;
route 10.200.3.0/24 unreachable;
route 10.200.4.0/24 unreachable;
route 10.200.5.0/24 unreachable;
route 10.200.6.0/24 unreachable;
route 10.200.7.0/24 unreachable;
route 10.200.8.0/24 unreachable;
route 10.200.9.0/24 unreachable;
route 10.200.10.0/24 unreachable;
route 10.200.11.0/24 unreachable;
route 10.200.12.0/24 unreachable;
route 10.200.13.0/24 unreachable;
route 10.200.14.0/24 unreachable;
route 10.200.15.0/24 unreachable;
route 10.200.16.0/24 unreachable;
route 10.200.17.0/24 unreachable;
route 10.200.18.0/24 unreachable;
route 10.200.19.0/24 unreachable;
}
+12
View File
@@ -0,0 +1,12 @@
# tags: baseline, master
# Minimal BIRD 2 skeleton (device + direct), no EvoBGP-generated fragments yet.
router id 192.0.2.1;
protocol device {
}
protocol direct {
ipv4;
ipv6;
}
@@ -0,0 +1,27 @@
# tags: IP_RANGES, CDN_CIDRS, peer, combined
# Combined static materialization + BGP session (multi-module revision sketch).
router id 192.0.2.1;
protocol device {
}
protocol direct {
ipv4;
ipv6;
}
protocol static evobgp_prefixes {
ipv4;
route 203.0.113.0/24 unreachable;
route 203.0.114.0/24 unreachable;
}
protocol bgp uplink {
local 192.0.2.1 as 65001;
neighbor 192.0.2.2 as 65002;
ipv4 {
import all;
export all;
};
}
@@ -0,0 +1,18 @@
# tags: IP_RANGES, module_output
# Static IPv4 routes only (materialized prefix list style).
router id 192.0.2.1;
protocol device {
}
protocol direct {
ipv4;
ipv6;
}
protocol static evobgp_v4 {
ipv4;
route 198.51.100.0/24 unreachable;
route 203.0.113.0/24 unreachable;
}
@@ -0,0 +1,14 @@
# tags: baseline, include
# Operator shell includes generated fragments from bird.d/
router id 192.0.2.1;
include "bird.d/static.conf";
protocol device {
}
protocol direct {
ipv4;
ipv6;
}
@@ -0,0 +1,4 @@
protocol static evobgp_inc {
ipv4;
route 203.0.113.0/24 unreachable;
}