refactor: update worker processes in EvoBGP to accept dependencies for shared store and job registry. Enhance scheduler, ingest, render, and deploy components to utilize a unified context and improve logging for drift detection. Update architecture documentation to reflect changes in process interactions and worker functionalities.
CI / changes (push) Successful in 5s
CI / go (push) Failing after 9s
CI / openapi (push) Has been skipped
CI / bird2 (push) Has been skipped

This commit is contained in:
Denozordec
2026-04-05 17:42:07 +07:00
parent 5d21f013cf
commit b7968db4e0
22 changed files with 936 additions and 77 deletions
+9 -5
View File
@@ -21,7 +21,7 @@ import (
"evobgp/internal/scheduler" "evobgp/internal/scheduler"
) )
// microVPS entrypoint: one process — HTTP API (same as evobgp-api) plus in-process stubs for scheduler/ingest/render/deploy. // microVPS entrypoint: one process — HTTP API plus in-process scheduler, ingest, render, deploy workers (shared store + job registry).
func main() { func main() {
cfg := config.Load() cfg := config.Load()
opts := httpapi.Options{ opts := httpapi.Options{
@@ -42,10 +42,14 @@ func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop() defer stop()
go scheduler.Run(ctx) schedDeps := &scheduler.Deps{Store: srv.Store(), Jobs: srv.Jobs()}
go ingest.Run(ctx) ingestDeps := &ingest.Deps{Store: srv.Store()}
go render.Run(ctx) renderDeps := &render.Deps{Store: srv.Store()}
go deploy.Run(ctx) deployDeps := &deploy.Deps{Store: srv.Store()}
go scheduler.Run(ctx, schedDeps)
go ingest.Run(ctx, ingestDeps)
go render.Run(ctx, renderDeps)
go deploy.Run(ctx, deployDeps)
startBirdMetricsPoller() startBirdMetricsPoller()
+19 -1
View File
@@ -5,10 +5,13 @@ import (
"log" "log"
"os" "os"
"os/signal" "os/signal"
"strings"
"syscall" "syscall"
"time"
"evobgp/internal/config" "evobgp/internal/config"
"evobgp/internal/deploy" "evobgp/internal/deploy"
"evobgp/internal/httpapi"
) )
func main() { func main() {
@@ -20,5 +23,20 @@ func main() {
log.Printf("%s starting (reference profile worker)", name) log.Printf("%s starting (reference profile worker)", name)
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop() defer stop()
deploy.Run(ctx)
bctx, bcancel := context.WithTimeout(context.Background(), 90*time.Second)
defer bcancel()
opts := httpapi.Options{
DatabaseURL: strings.TrimSpace(os.Getenv("EVOBGP_DATABASE_URL")),
SeedDemo: os.Getenv("EVOBGP_SEED_DEMO") != "0",
}
st, _, pool, err := httpapi.BootstrapWorkers(bctx, opts)
if err != nil {
log.Fatal(err)
}
if pool != nil {
defer pool.Close()
}
deploy.Run(ctx, &deploy.Deps{Store: st})
} }
+19 -1
View File
@@ -5,9 +5,12 @@ import (
"log" "log"
"os" "os"
"os/signal" "os/signal"
"strings"
"syscall" "syscall"
"time"
"evobgp/internal/config" "evobgp/internal/config"
"evobgp/internal/httpapi"
"evobgp/internal/ingest" "evobgp/internal/ingest"
) )
@@ -20,5 +23,20 @@ func main() {
log.Printf("%s starting (reference profile worker)", name) log.Printf("%s starting (reference profile worker)", name)
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop() defer stop()
ingest.Run(ctx)
bctx, bcancel := context.WithTimeout(context.Background(), 90*time.Second)
defer bcancel()
opts := httpapi.Options{
DatabaseURL: strings.TrimSpace(os.Getenv("EVOBGP_DATABASE_URL")),
SeedDemo: os.Getenv("EVOBGP_SEED_DEMO") != "0",
}
st, _, pool, err := httpapi.BootstrapWorkers(bctx, opts)
if err != nil {
log.Fatal(err)
}
if pool != nil {
defer pool.Close()
}
ingest.Run(ctx, &ingest.Deps{Store: st})
} }
+19 -1
View File
@@ -5,9 +5,12 @@ import (
"log" "log"
"os" "os"
"os/signal" "os/signal"
"strings"
"syscall" "syscall"
"time"
"evobgp/internal/config" "evobgp/internal/config"
"evobgp/internal/httpapi"
"evobgp/internal/render" "evobgp/internal/render"
) )
@@ -20,5 +23,20 @@ func main() {
log.Printf("%s starting (reference profile worker)", name) log.Printf("%s starting (reference profile worker)", name)
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop() defer stop()
render.Run(ctx)
bctx, bcancel := context.WithTimeout(context.Background(), 90*time.Second)
defer bcancel()
opts := httpapi.Options{
DatabaseURL: strings.TrimSpace(os.Getenv("EVOBGP_DATABASE_URL")),
SeedDemo: os.Getenv("EVOBGP_SEED_DEMO") != "0",
}
st, _, pool, err := httpapi.BootstrapWorkers(bctx, opts)
if err != nil {
log.Fatal(err)
}
if pool != nil {
defer pool.Close()
}
render.Run(ctx, &render.Deps{Store: st})
} }
+34 -1
View File
@@ -3,11 +3,15 @@ package main
import ( import (
"context" "context"
"log" "log"
"net/http"
"os" "os"
"os/signal" "os/signal"
"strings"
"syscall" "syscall"
"time"
"evobgp/internal/config" "evobgp/internal/config"
"evobgp/internal/httpapi"
"evobgp/internal/scheduler" "evobgp/internal/scheduler"
) )
@@ -20,5 +24,34 @@ func main() {
log.Printf("%s starting (reference profile worker)", name) log.Printf("%s starting (reference profile worker)", name)
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop() defer stop()
scheduler.Run(ctx)
bctx, bcancel := context.WithTimeout(context.Background(), 90*time.Second)
defer bcancel()
opts := httpapi.Options{
DatabaseURL: strings.TrimSpace(os.Getenv("EVOBGP_DATABASE_URL")),
SeedDemo: os.Getenv("EVOBGP_SEED_DEMO") != "0",
}
st, reg, pool, err := httpapi.BootstrapWorkers(bctx, opts)
if err != nil {
log.Fatal(err)
}
if pool != nil {
defer pool.Close()
}
apiBase := strings.TrimSpace(os.Getenv("EVOBGP_CONTROL_PLANE_URL"))
apiTok := strings.TrimSpace(os.Getenv("EVOBGP_SCHEDULER_BEARER"))
deps := &scheduler.Deps{
Store: st,
HTTP: &http.Client{Timeout: 45 * time.Second},
}
if apiBase != "" && apiTok != "" {
deps.APIBase = apiBase
deps.APIToken = apiTok
log.Printf("evobgp-scheduler: control plane HTTP mode (%s)", apiBase)
} else {
deps.Jobs = reg
log.Printf("evobgp-scheduler: in-process job registry mode (use EVOBGP_CONTROL_PLANE_URL + EVOBGP_SCHEDULER_BEARER for split containers)")
}
scheduler.Run(ctx, deps)
} }
+6
View File
@@ -91,6 +91,8 @@ services:
<<: *env-ref <<: *env-ref
EVOBGP_HTTP_ADDR: ":8080" EVOBGP_HTTP_ADDR: ":8080"
EVOBGP_SEED_DEMO: "1" EVOBGP_SEED_DEMO: "1"
# Local reference only: allows Bearer dev for scheduler HTTP client (EVOBGP_SCHEDULER_BEARER).
EVOBGP_DEV_INSECURE: "1"
EVOBGP_BIRDC_SOCKET: /run/bird/bird.ctl EVOBGP_BIRDC_SOCKET: /run/bird/bird.ctl
EVOBGP_BIRDC_INTERVAL: 30s EVOBGP_BIRDC_INTERVAL: 30s
EVOBGP_BIRD_ACTIVE_DIR: /etc/bird EVOBGP_BIRD_ACTIVE_DIR: /etc/bird
@@ -120,8 +122,12 @@ services:
condition: service_healthy condition: service_healthy
nats: nats:
condition: service_started condition: service_started
evobgp-api:
condition: service_started
environment: environment:
<<: *env-ref <<: *env-ref
EVOBGP_CONTROL_PLANE_URL: http://evobgp-api:8080
EVOBGP_SCHEDULER_BEARER: dev
logging: *default-logging logging: *default-logging
deploy: deploy:
resources: resources:
+13 -16
View File
@@ -13,11 +13,11 @@
| Бинарник | Роль | | Бинарник | Роль |
|----------|------| |----------|------|
| `evobgp-api` | Только HTTP API и связанная логика в одном процессе. | | `evobgp-api` | Только HTTP API и связанная логика в одном процессе. |
| `evobgp-all` | Режим одной VPS: тот же API + in-process запуск заглушек scheduler, ingest, render, deploy. | | `evobgp-all` | Тот же API + in-process **scheduler** (очередь `module_refresh` в общем Registry), **ingest** (prefetch ETag CDN), **render** (опционально auto-publish), **deploy** (лог расхождений published/applied). |
| `evobgp-scheduler` | Планировщик cron/интервалов модулей (в коде сейчас **stub**). | | `evobgp-scheduler` | По `refresh_interval_sec` ставит refresh: в одном процессе с API — через `jobs.Registry`; в reference Compose — **HTTP** `POST /v1/modules/{id}/refresh` (`EVOBGP_CONTROL_PLANE_URL`, `EVOBGP_SCHEDULER_BEARER`). |
| `evobgp-ingest` | Воркеры загрузки внешних источников (CDN и т.д.) (**stub**). | | `evobgp-ingest` | Периодический conditional GET по URL CDN-источников и обновление `etag` в БД. |
| `evobgp-render` | Генерация артефактов BIRD из ревизий (**stub**). | | `evobgp-render` | По умолчанию только heartbeat; при `EVOBGP_RENDER_AUTOPUBLISH=1` выставляет всем спикерам tenant последнюю ревизию (упрощение для демо). |
| `evobgp-deploy` | Выкладка на спикеры / взаимодействие с BIRD на стороне деплоя (**stub**). | | `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` по сокету). |
@@ -39,6 +39,7 @@
| `config` | Переменные окружения `EVOBGP_*`. | | `config` | Переменные окружения `EVOBGP_*`. |
| `observability` | Метрики Prometheus, HTTP middleware. | | `observability` | Метрики Prometheus, HTTP middleware. |
| `broker` | Заготовка под NATS/Redis (логирование подключения в воркерах). | | `broker` | Заготовка под NATS/Redis (логирование подключения в воркерах). |
| `pipeline` | Ingest+render в одном шаге для `module_refresh`: выборка префиксов (CDN/AS/IP/пустые DOMAINS), `CreateRenderRevision`, превью BIRD через `birdfmt`. |
## Диаграмма: эталонный Compose (reference) ## Диаграмма: эталонный Compose (reference)
@@ -51,12 +52,11 @@ flowchart LR
end end
subgraph control [Control_plane] subgraph control [Control_plane]
API[evobgp_api] API[evobgp_api]
Sched[evobgp_scheduler_stub] Sched[evobgp_scheduler]
Ingest[evobgp_ingest_stub] Ingest[evobgp_ingest]
Render[evobgp_render_stub] Render[evobgp_render]
Deploy[evobgp_deploy_stub] Deploy[evobgp_deploy]
PG[(PostgreSQL)] PG[(PostgreSQL)]
NATS[NATS_JetStream]
end end
subgraph data [Data_plane] subgraph data [Data_plane]
BIRD[BIRD2] BIRD[BIRD2]
@@ -66,10 +66,7 @@ flowchart LR
Operator --> API Operator --> API
NodeCLI --> API NodeCLI --> API
API --> PG API --> PG
Sched --> NATS Sched -->|HTTP_or_DB| API
Ingest --> NATS
Render --> NATS
Deploy --> NATS
Sched --> PG Sched --> PG
Ingest --> PG Ingest --> PG
Render --> PG Render --> PG
@@ -77,7 +74,7 @@ flowchart LR
Agent --> BIRD Agent --> BIRD
``` ```
На практике воркеры **пока не выполняют** полноценную работу с очередью — они резервируют место в топологии и пишут в лог. API и БД уже обеспечивают основной сценарий разработки и тестов. Очередь задач по-прежнему **in-memory в процессе API** (`jobs.Registry`); отдельный контейнер `evobgp-scheduler` не разделяет память с API и дергает refresh по HTTP. Полноценный брокер (NATS) и общая очередь `job_audit` между процессами — в следующих итерациях.
## Диаграмма: microvps (`evobgp-all`) ## Диаграмма: microvps (`evobgp-all`)
@@ -92,7 +89,7 @@ flowchart LR
All --> BIRD All --> BIRD
``` ```
Внутри процесса `evobgp-all` горутины scheduler/ingest/render/deploy — те же **stub**, что и отдельные бинарники. Внутри `evobgp-all` все воркеры используют **тот же** `store` и `jobs.Registry`, что и HTTP handlers, поэтому `module_refresh` выполняется в том же процессе без HTTP.
## Поток: ревизия и бандл для ноды ## Поток: ревизия и бандл для ноды
+3 -1
View File
@@ -60,7 +60,9 @@ docker compose --profile reference up -d --build
| 9090 | Prometheus (в compose) | | 9090 | Prometheus (в compose) |
| 179 | BGP (BIRD2) | | 179 | BGP (BIRD2) |
**Важно:** процессы `evobgp-scheduler`, `evobgp-ingest`, `evobgp-render`, `evobgp-deploy` в текущей версии кода — **заглушки** (логирование и периодический тик). Реальная очередь задач и брокер подключаются в будущих итерациях; API и БД при этом уже работают. **Воркеры reference:** `evobgp-scheduler` ходит в API по HTTP (`EVOBGP_CONTROL_PLANE_URL`, `EVOBGP_SCHEDULER_BEARER`); в [docker-compose.yaml](../deploy/compose/docker-compose.yaml) для локального запуска включены `EVOBGP_DEV_INSECURE=1` на API и токен `dev` у планировщика. `evobgp-ingest` обновляет ETag CDN-источников; `evobgp-render` по умолчанию не трогает `published_revision` (включите `EVOBGP_RENDER_AUTOPUBLISH=1` осознанно); `evobgp-deploy` пишет в лог расхождение applied vs published. Очередь `jobs` остаётся in-process у **evobgp-api**; общий брокер — в планах.
В **evobgp-all** (microvps) те же пакеты крутятся в одном процессе и используют общий `jobs.Registry` без HTTP.
## Вариант 3: Локально без Docker (только API) ## Вариант 3: Локально без Docker (только API)
+53 -5
View File
@@ -1,4 +1,3 @@
// Package deploy delivers generated BIRD fragments to evobgp-agent / publishes signed bundles for evobgp-node.
package deploy package deploy
import ( import (
@@ -10,18 +9,67 @@ import (
"evobgp/internal/broker" "evobgp/internal/broker"
"evobgp/internal/config" "evobgp/internal/config"
"evobgp/internal/store"
) )
// Run blocks until ctx is cancelled. Reference deployment: worker after render, talks to agent API or shared volume. // Deps enables deploy-side drift logging between published and last-applied revision per speaker.
func Run(ctx context.Context) { type Deps struct {
Store store.Backend
}
// Run blocks until ctx is cancelled.
func Run(ctx context.Context, deps *Deps) {
cfg := config.Load() cfg := config.Load()
broker.LogConnect(ctx, cfg.BrokerURL) broker.LogConnect(ctx, cfg.BrokerURL)
if d := strings.TrimSpace(os.Getenv("EVOBGP_BIRD_ACTIVE_DIR")); d != "" { if d := strings.TrimSpace(os.Getenv("EVOBGP_BIRD_ACTIVE_DIR")); d != "" {
log.Printf("evobgp-deploy: EVOBGP_BIRD_ACTIVE_DIR=%q (apply handled by API jobs + birddeploy when set on API)", d) log.Printf("evobgp-deploy: EVOBGP_BIRD_ACTIVE_DIR=%q (apply via API jobs when API has same env)", d)
} }
if deps == nil || deps.Store == nil {
runStub(ctx)
return
}
t := time.NewTicker(90 * time.Second)
defer t.Stop()
log.Printf("evobgp-deploy: active (speaker published vs applied drift log)")
for {
select {
case <-ctx.Done():
log.Printf("evobgp-deploy: stopped")
return
case <-t.C:
logDrift(context.Background(), deps.Store)
}
}
}
func logDrift(ctx context.Context, st store.Backend) {
_ = ctx
tenants, err := st.ListTenantIDs()
if err != nil {
log.Printf("evobgp-deploy: list tenants: %v", err)
return
}
for _, tid := range tenants {
for _, sp := range st.ListSpeakersForTenant(tid) {
pub, _, err := st.LatestPublishedRevision(sp.ID)
if err != nil {
continue
}
applied := ""
if sp.LastAppliedRevisionID != nil {
applied = *sp.LastAppliedRevisionID
}
if applied != "" && applied != pub {
log.Printf("evobgp-deploy: drift speaker=%s applied=%s published=%s", sp.ID, applied, pub)
}
}
}
}
func runStub(ctx context.Context) {
t := time.NewTicker(60 * time.Second) t := time.NewTicker(60 * time.Second)
defer t.Stop() defer t.Stop()
log.Printf("evobgp-deploy: started (orchestration stub; two-phase apply runs in evobgp-api/evobgp-all job worker when EVOBGP_BIRD_ACTIVE_DIR is set)") log.Printf("evobgp-deploy: idle stub (no store in Deps)")
for { for {
select { select {
case <-ctx.Done(): case <-ctx.Done():
+52
View File
@@ -0,0 +1,52 @@
package httpapi
import (
"context"
"net/http"
"strings"
"time"
"evobgp/internal/db"
"evobgp/internal/jobs"
"evobgp/internal/observability"
"evobgp/internal/repository"
"evobgp/internal/store"
"github.com/jackc/pgx/v5/pgxpool"
)
// BootstrapWorkers opens the same store.Backend and jobs.Registry as New (without HTTP or bundle keys).
// Used by standalone worker binaries (scheduler, ingest, …) that share PostgreSQL with the API.
func BootstrapWorkers(ctx context.Context, opts Options) (store.Backend, *jobs.Registry, *pgxpool.Pool, error) {
ctx, cancel := context.WithTimeout(ctx, 60*time.Second)
defer cancel()
var backend store.Backend
var pool *pgxpool.Pool
if u := strings.TrimSpace(opts.DatabaseURL); u != "" {
p, err := db.OpenPostgresPool(ctx, u)
if err != nil {
return nil, nil, nil, err
}
pool = p
pgbe, err := repository.NewPostgres(ctx, p, opts.SeedDemo)
if err != nil {
pool.Close()
return nil, nil, nil, err
}
backend = pgbe
} else {
mem := store.NewMemory()
if opts.SeedDemo {
mem.SeedDemo()
}
backend = mem
}
cdnHTTP := &http.Client{Timeout: 45 * time.Second}
wk := &jobs.Worker{Store: backend, HTTPClient: cdnHTTP}
reg := jobs.NewRegistry(wk.Process)
observability.RegisterStoreBackend(backend)
return backend, reg, pool, nil
}
+6 -30
View File
@@ -10,10 +10,7 @@ import (
"strings" "strings"
"time" "time"
"evobgp/internal/db"
"evobgp/internal/jobs" "evobgp/internal/jobs"
"evobgp/internal/observability"
"evobgp/internal/repository"
"evobgp/internal/store" "evobgp/internal/store"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
@@ -44,34 +41,11 @@ type Options struct {
// New constructs Server and wiring for async jobs. // New constructs Server and wiring for async jobs.
func New(opts Options) (*Server, error) { func New(opts Options) (*Server, error) {
var backend store.Backend backend, pool, reg, err := BootstrapWorkers(context.Background(), opts)
var pool *pgxpool.Pool if err != nil {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) return nil, err
defer cancel()
if u := strings.TrimSpace(opts.DatabaseURL); u != "" {
p, err := db.OpenPostgresPool(ctx, u)
if err != nil {
return nil, err
}
pool = p
pgbe, err := repository.NewPostgres(ctx, p, opts.SeedDemo)
if err != nil {
pool.Close()
return nil, err
}
backend = pgbe
} else {
mem := store.NewMemory()
if opts.SeedDemo {
mem.SeedDemo()
}
backend = mem
} }
wk := &jobs.Worker{Store: backend}
reg := jobs.NewRegistry(wk.Process)
var priv ed25519.PrivateKey var priv ed25519.PrivateKey
if strings.TrimSpace(opts.BundleSeedHex) != "" { if strings.TrimSpace(opts.BundleSeedHex) != "" {
seed, err := hex.DecodeString(strings.TrimSpace(opts.BundleSeedHex)) seed, err := hex.DecodeString(strings.TrimSpace(opts.BundleSeedHex))
@@ -95,7 +69,6 @@ func New(opts Options) (*Server, error) {
insecureDev: opts.InsecureDev && opts.SeedDemo, insecureDev: opts.InsecureDev && opts.SeedDemo,
corsOrigins: parseCORSOrigins(opts.CORSAllowedOrigins), corsOrigins: parseCORSOrigins(opts.CORSAllowedOrigins),
} }
observability.RegisterStoreBackend(backend)
s.mux = http.NewServeMux() s.mux = http.NewServeMux()
s.registerRoutes() s.registerRoutes()
return s, nil return s, nil
@@ -110,3 +83,6 @@ func (s *Server) Close() {
// Store exposes the backing store (for operators / tests). // Store exposes the backing store (for operators / tests).
func (s *Server) Store() store.Backend { return s.store } func (s *Server) Store() store.Backend { return s.store }
// Jobs exposes the in-process async job registry (for scheduler / evobgp-all).
func (s *Server) Jobs() *jobs.Registry { return s.jobs }
+33 -4
View File
@@ -1,22 +1,51 @@
// Package ingest fetches CDN lists, resolves DoH domains, and writes normalized prefixes into the database.
package ingest 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/pipeline"
"evobgp/internal/store"
) )
// Run blocks until ctx is cancelled. Reference deployment: separate process consuming the job queue. // Deps runs lightweight CDN ETag prefetch against the shared store.
func Run(ctx context.Context) { type Deps struct {
Store store.Backend
}
// Run blocks until ctx is cancelled.
func Run(ctx context.Context, deps *Deps) {
cfg := config.Load() cfg := config.Load()
broker.LogConnect(ctx, cfg.BrokerURL) broker.LogConnect(ctx, cfg.BrokerURL)
if deps == nil || deps.Store == nil {
runStub(ctx)
return
}
hc := &http.Client{Timeout: 45 * time.Second}
t := time.NewTicker(60 * time.Second) t := time.NewTicker(60 * time.Second)
defer t.Stop() defer t.Stop()
log.Printf("evobgp-ingest: started (stub; ingest workers dequeue from broker or job_audit)") log.Printf("evobgp-ingest: active (CDN conditional GET / ETag prefetch)")
for {
select {
case <-ctx.Done():
log.Printf("evobgp-ingest: stopped")
return
case <-t.C:
if err := pipeline.PrefetchCDNSourceETags(context.Background(), deps.Store, hc); err != nil {
log.Printf("evobgp-ingest: prefetch: %v", err)
}
}
}
}
func runStub(ctx context.Context) {
t := time.NewTicker(60 * time.Second)
defer t.Stop()
log.Printf("evobgp-ingest: idle stub (no store in Deps)")
for { for {
select { select {
case <-ctx.Done(): case <-ctx.Done():
+25 -1
View File
@@ -2,12 +2,15 @@ package jobs
import ( import (
"context" "context"
"net/http"
"os" "os"
"strings" "strings"
"time"
"evobgp/internal/birddeploy" "evobgp/internal/birddeploy"
"evobgp/internal/birdfmt" "evobgp/internal/birdfmt"
"evobgp/internal/observability" "evobgp/internal/observability"
"evobgp/internal/pipeline"
"evobgp/internal/store" "evobgp/internal/store"
) )
@@ -20,7 +23,17 @@ const (
// Worker executes queued jobs against store.Backend (memory or SQL). // Worker executes queued jobs against store.Backend (memory or SQL).
type Worker struct { type Worker struct {
Store store.Backend Store store.Backend
HTTPClient *http.Client // optional; CDN refresh uses this (default 45s timeout).
}
var defaultWorkerHTTP = &http.Client{Timeout: 45 * time.Second}
func (w *Worker) httpClient() *http.Client {
if w != nil && w.HTTPClient != nil {
return w.HTTPClient
}
return defaultWorkerHTTP
} }
// Process is registered as Registry.workerStart. // Process is registered as Registry.workerStart.
@@ -42,6 +55,17 @@ func (w *Worker) Process(j *Job) {
switch j.Kind { switch j.Kind {
case KindModuleRefresh: case KindModuleRefresh:
mid, _ := j.Meta["module_id"].(string)
if strings.TrimSpace(mid) == "" {
j.Fail("missing module_id in job meta")
return
}
rev, err := pipeline.RefreshModule(context.Background(), w.Store, w.httpClient(), j.TenantID, mid)
if err != nil {
j.Fail(err.Error())
return
}
j.mergeMeta(map[string]any{"revision_id": rev})
j.Succeed() j.Succeed()
case KindDeployApply: case KindDeployApply:
w.runDeployApply(j) w.runDeployApply(j)
+47
View File
@@ -0,0 +1,47 @@
package pipeline
import (
"bufio"
"net/netip"
"strings"
)
// ParseCIDRLines extracts unique IPv4/IPv6 CIDRs from plain text (one per line, # comments, empty lines skipped).
func ParseCIDRLines(body string) []netip.Prefix {
seen := make(map[string]struct{})
var out []netip.Prefix
sc := bufio.NewScanner(strings.NewReader(body))
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
pfx := parseOneCIDR(line)
if !pfx.IsValid() {
continue
}
m := pfx.Masked()
s := m.String()
if _, ok := seen[s]; ok {
continue
}
seen[s] = struct{}{}
out = append(out, m)
}
return out
}
func parseOneCIDR(s string) netip.Prefix {
if p, err := netip.ParsePrefix(s); err == nil {
return p
}
if addr, err := netip.ParseAddr(s); err == nil {
if addr.Is4() {
p, _ := addr.Prefix(32)
return p
}
p, _ := addr.Prefix(128)
return p
}
return netip.Prefix{}
}
+61
View File
@@ -0,0 +1,61 @@
package pipeline
import (
"context"
"io"
"net/http"
"strings"
"evobgp/internal/store"
)
// PrefetchCDNSourceETags performs conditional GETs for CDN module sources and updates stored ETags when the origin responds 200.
func PrefetchCDNSourceETags(ctx context.Context, st store.Backend, hc *http.Client) error {
if hc == nil {
hc = http.DefaultClient
}
tenants, err := st.ListTenantIDs()
if err != nil {
return err
}
for _, tid := range tenants {
for _, mod := range st.ListModules(tid) {
if !mod.Enabled || mod.Type != "CDN_CIDRS" {
continue
}
sources, err := st.ListCDNSources(tid, mod.ID)
if err != nil {
continue
}
for _, src := range sources {
u := strings.TrimSpace(src.URL)
if u == "" {
continue
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
continue
}
if strings.TrimSpace(src.Etag) != "" {
req.Header.Set("If-None-Match", strings.TrimSpace(src.Etag))
}
resp, err := hc.Do(req)
if err != nil {
continue
}
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
if resp.StatusCode != http.StatusOK {
continue
}
etag := strings.TrimSpace(resp.Header.Get("ETag"))
if etag == "" || etag == strings.TrimSpace(src.Etag) {
continue
}
e := etag
_, _ = st.UpdateCDNSource(tid, mod.ID, src.ID, &store.CDNSourcePatch{Etag: &e})
}
}
}
return nil
}
+214
View File
@@ -0,0 +1,214 @@
package pipeline
import (
"context"
"crypto/sha256"
"fmt"
"io"
"net/http"
"net/netip"
"sort"
"strings"
"evobgp/internal/birdfmt"
"evobgp/internal/store"
"github.com/google/uuid"
)
// RefreshModule runs ingest (where applicable) and creates a new rendered revision for the module.
func RefreshModule(ctx context.Context, st store.Backend, hc *http.Client, tenantID, moduleID string) (revisionID string, err error) {
if hc == nil {
hc = http.DefaultClient
}
mod, err := st.GetModule(tenantID, moduleID)
if err != nil {
return "", err
}
if !mod.Enabled {
return "", fmt.Errorf("module disabled")
}
var rows []store.PrefixRow
switch mod.Type {
case "IP_RANGES":
list, err := st.ListIPRangeEntries(tenantID, moduleID)
if err != nil {
return "", err
}
for _, e := range list {
comm := e.CommunityID
if comm == nil && mod.DefaultCommunityID != nil {
c := *mod.DefaultCommunityID
comm = &c
}
rows = append(rows, store.PrefixRow{Prefix: e.Prefix, CommunityID: comm, Source: "ip_range"})
}
case "AS_PREFIXES":
list, err := st.ListASEntries(tenantID, moduleID)
if err != nil {
return "", err
}
for _, e := range list {
if e.Prefix == nil || strings.TrimSpace(*e.Prefix) == "" {
continue
}
comm := e.CommunityID
if comm == nil && mod.DefaultCommunityID != nil {
c := *mod.DefaultCommunityID
comm = &c
}
rows = append(rows, store.PrefixRow{Prefix: strings.TrimSpace(*e.Prefix), CommunityID: comm, Source: "as_entry"})
}
case "CDN_CIDRS":
sources, err := st.ListCDNSources(tenantID, moduleID)
if err != nil {
return "", err
}
for _, src := range sources {
u := strings.TrimSpace(src.URL)
if u == "" {
continue
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return "", err
}
if strings.TrimSpace(src.Etag) != "" {
req.Header.Set("If-None-Match", strings.TrimSpace(src.Etag))
}
resp, err := hc.Do(req)
if err != nil {
return "", fmt.Errorf("cdn fetch %s: %w", u, err)
}
if resp.StatusCode == http.StatusNotModified {
_ = resp.Body.Close()
continue
}
if resp.StatusCode != http.StatusOK {
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
return "", fmt.Errorf("cdn url %s: %s", u, resp.Status)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
_ = resp.Body.Close()
if err != nil {
return "", err
}
etag := strings.TrimSpace(resp.Header.Get("ETag"))
if etag != "" && etag != strings.TrimSpace(src.Etag) {
e := etag
_, _ = st.UpdateCDNSource(tenantID, moduleID, src.ID, &store.CDNSourcePatch{Etag: &e})
}
for _, pfx := range ParseCIDRLines(string(body)) {
comm := src.CommunityID
if comm == nil && mod.DefaultCommunityID != nil {
c := *mod.DefaultCommunityID
comm = &c
}
rows = append(rows, store.PrefixRow{Prefix: pfx.String(), CommunityID: comm, Source: "cdn:" + src.ID})
}
}
case "DOMAINS":
if _, err := st.ListDomainEntries(tenantID, moduleID); err != nil {
return "", err
}
// DNS/DoH resolution not wired yet; emit empty prefix set (valid revision).
default:
return "", fmt.Errorf("unknown module type %q", mod.Type)
}
revisionID = uuid.NewString()
parent := parentRevision(st, tenantID, moduleID)
hash := hashMaterialization(moduleID, rows)
preview, err := buildPreviewFragments(revisionID, rows)
if err != nil {
return "", err
}
if err := st.CreateRenderRevision(revisionID, tenantID, moduleID, parent, hash, preview, rows); err != nil {
return "", err
}
return revisionID, nil
}
func parentRevision(st store.Backend, tenantID, moduleID string) *string {
items, _, _ := st.ListRevisions(tenantID, moduleID, "", 1)
if len(items) == 0 {
return nil
}
id := items[0].ID
return &id
}
func hashMaterialization(moduleID string, rows []store.PrefixRow) string {
type line struct{ p, c, s string }
var lines []line
for _, r := range rows {
c := ""
if r.CommunityID != nil {
c = *r.CommunityID
}
lines = append(lines, line{r.Prefix, c, r.Source})
}
sort.Slice(lines, func(i, j int) bool {
if lines[i].p != lines[j].p {
return lines[i].p < lines[j].p
}
if lines[i].c != lines[j].c {
return lines[i].c < lines[j].c
}
return lines[i].s < lines[j].s
})
h := sha256.New()
h.Write([]byte(moduleID))
h.Write([]byte{0})
for _, l := range lines {
h.Write([]byte(l.p))
h.Write([]byte{1})
h.Write([]byte(l.c))
h.Write([]byte{1})
h.Write([]byte(l.s))
h.Write([]byte{0})
}
return fmt.Sprintf("sha256:%x", h.Sum(nil))
}
func buildPreviewFragments(revisionID string, rows []store.PrefixRow) (map[string]string, error) {
var v4, v6 []netip.Prefix
for _, pr := range rows {
pfx, err := netip.ParsePrefix(strings.TrimSpace(pr.Prefix))
if err != nil {
continue
}
if pfx.Addr().Is4() {
v4 = append(v4, pfx.Masked())
} else {
v6 = append(v6, pfx.Masked())
}
}
f4, err := birdfmt.RenderExportFilterIPv4("evobgp_export_v4", v4)
if err != nil {
return nil, err
}
f6, err := birdfmt.RenderExportFilterIPv6("evobgp_export_v6", v6)
if err != nil {
return nil, err
}
birdD := birdfmt.JoinFragments(birdfmt.ManagedBanner(revisionID), f4, f6)
main := `# EvoBGP generated (pipeline refresh)
router id 192.0.2.1;
include "bird.d/evobgp_generated.conf";
protocol device {
}
protocol direct {
ipv4;
ipv6;
}
`
return map[string]string{
"bird.conf": main,
"bird.d/evobgp_generated.conf": birdD,
}, nil
}
+31
View File
@@ -0,0 +1,31 @@
package render
import (
"context"
"log"
"evobgp/internal/store"
)
// PublishLatestTenantRevision sets each speaker's published_revision_id to the newest tenant revision (by created_at).
// Simplification for single-tenant / demo stacks; multi-module tenants may prefer explicit publish policies later.
func PublishLatestTenantRevision(ctx context.Context, st store.Backend) {
_ = ctx
tenants, err := st.ListTenantIDs()
if err != nil {
log.Printf("evobgp-render: list tenants: %v", err)
return
}
for _, tid := range tenants {
revs, _, _ := st.ListRevisions(tid, "", "", 1)
if len(revs) == 0 {
continue
}
rid := revs[0].ID
for _, sp := range st.ListSpeakersForTenant(tid) {
if err := st.PublishRevisionForSpeaker(sp.ID, rid); err != nil {
log.Printf("evobgp-render: publish speaker %s: %v", sp.ID, err)
}
}
}
}
+37 -4
View File
@@ -1,22 +1,55 @@
// Package render materializes prefix sets, creates config_revision rows, and builds BIRD text via internal/birdfmt.
package render package render
import ( import (
"context" "context"
"log" "log"
"os"
"strings"
"time" "time"
"evobgp/internal/broker" "evobgp/internal/broker"
"evobgp/internal/config" "evobgp/internal/config"
"evobgp/internal/store"
) )
// Run blocks until ctx is cancelled. Reference deployment: worker process after ingest completes. // Deps enables auto-publish of the latest rendered revision to all speakers in each tenant.
func Run(ctx context.Context) { type Deps struct {
Store store.Backend
}
// Run blocks until ctx is cancelled. With Store set, periodically publishes the head revision for each tenant.
func Run(ctx context.Context, deps *Deps) {
cfg := config.Load() cfg := config.Load()
broker.LogConnect(ctx, cfg.BrokerURL) broker.LogConnect(ctx, cfg.BrokerURL)
if deps == nil || deps.Store == nil {
runStub(ctx)
return
}
t := time.NewTicker(45 * time.Second)
defer t.Stop()
auto := strings.TrimSpace(os.Getenv("EVOBGP_RENDER_AUTOPUBLISH")) == "1"
if auto {
log.Printf("evobgp-render: active (EVOBGP_RENDER_AUTOPUBLISH=1: publish head revision per tenant)")
} else {
log.Printf("evobgp-render: active (idle publish; set EVOBGP_RENDER_AUTOPUBLISH=1 to auto-publish head revision)")
}
for {
select {
case <-ctx.Done():
log.Printf("evobgp-render: stopped")
return
case <-t.C:
if auto {
PublishLatestTenantRevision(context.Background(), deps.Store)
}
}
}
}
func runStub(ctx context.Context) {
t := time.NewTicker(60 * time.Second) t := time.NewTicker(60 * time.Second)
defer t.Stop() defer t.Stop()
log.Printf("evobgp-render: started (stub; render jobs produce revisions and artifacts)") log.Printf("evobgp-render: idle stub (no store in Deps)")
for { for {
select { select {
case <-ctx.Done(): case <-ctx.Done():
+76
View File
@@ -760,6 +760,82 @@ func (p *Postgres) LatestPublishedRevision(speakerID string) (string, time.Time,
return rid, at, nil return rid, at, nil
} }
func (p *Postgres) ListTenantIDs() ([]string, error) {
ctx := context.Background()
rows, err := p.pool.Query(ctx, `SELECT id::text FROM tenant ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
continue
}
out = append(out, id)
}
return out, nil
}
func (p *Postgres) CreateRenderRevision(revisionID, tenantID, moduleID string, parentRevisionID *string, contentHash string, previewFragments map[string]string, prefixes []store.PrefixRow) error {
if strings.TrimSpace(revisionID) == "" {
return store.ErrInvalidInput
}
if _, err := p.GetModule(tenantID, moduleID); err != nil {
return err
}
ctx := context.Background()
if previewFragments == nil {
previewFragments = map[string]string{}
}
meta, err := json.Marshal(map[string]any{
"preview_fragments": previewFragments,
"materialized_prefix_count": len(prefixes),
})
if err != nil {
return err
}
tx, err := p.pool.Begin(ctx)
if err != nil {
return err
}
defer func() { _ = tx.Rollback(ctx) }()
var parent any
if parentRevisionID != nil && strings.TrimSpace(*parentRevisionID) != "" {
parent = strings.TrimSpace(*parentRevisionID)
}
_, err = tx.Exec(ctx, `
INSERT INTO config_revision (id, tenant_id, module_id, content_hash, parent_revision_id, meta_json)
VALUES ($1::uuid, $2::uuid, $3::uuid, $4, $5::uuid, $6::jsonb)`,
strings.TrimSpace(revisionID), tenantID, moduleID, strings.TrimSpace(contentHash), parent, string(meta))
if err != nil {
return err
}
for _, pr := range prefixes {
var comm any
if pr.CommunityID != nil && strings.TrimSpace(*pr.CommunityID) != "" {
comm = strings.TrimSpace(*pr.CommunityID)
}
src := pr.Source
if strings.TrimSpace(src) == "" {
src = "render"
}
_, err = tx.Exec(ctx, `
INSERT INTO revision_materialized_prefix (revision_id, prefix, community_id, source)
VALUES ($1::uuid, $2::cidr, $3::uuid, $4)`,
strings.TrimSpace(revisionID), strings.TrimSpace(pr.Prefix), comm, src)
if err != nil {
return err
}
}
if err := tx.Commit(ctx); err != nil {
return err
}
return nil
}
func (p *Postgres) ListDohProfiles(tenantID string) ([]*store.DohProfile, error) { func (p *Postgres) ListDohProfiles(tenantID string) ([]*store.DohProfile, error) {
ctx := context.Background() ctx := context.Background()
rows, err := p.pool.Query(ctx, `SELECT id::text, name, url, timeout_ms, secret_ref FROM doh_profile WHERE tenant_id=$1`, tenantID) rows, err := p.pool.Query(ctx, `SELECT id::text, name, url, timeout_ms, secret_ref FROM doh_profile WHERE tenant_id=$1`, tenantID)
+118 -7
View File
@@ -1,30 +1,141 @@
// Package scheduler drives module/CDN refresh intervals and enqueues work (broker or job_audit). // Package scheduler drives module refresh intervals and enqueues module_refresh jobs on the shared Registry.
// This process entrypoint is a stub until PostgreSQL and NATS/Redis are wired.
package scheduler package scheduler
import ( import (
"context" "context"
"fmt"
"io"
"log" "log"
"net/http"
"strings"
"time" "time"
"evobgp/internal/broker" "evobgp/internal/broker"
"evobgp/internal/config" "evobgp/internal/config"
"evobgp/internal/jobs"
"evobgp/internal/store"
) )
// Run blocks until ctx is cancelled. In production it connects to DB + broker and schedules refresh events. // Deps wires the scheduler to the store. Use either Jobs (same process as API, e.g. evobgp-all) or
func Run(ctx context.Context) { // APIBase+APIToken to call the control plane over HTTP (separate containers in reference Compose).
type Deps struct {
Store store.Backend
Jobs *jobs.Registry
APIBase string // e.g. http://evobgp-api:8080
APIToken string // Bearer token (operator/editor)
HTTP *http.Client
}
// Run blocks until ctx is cancelled. When deps is nil or incomplete, falls back to connect-only stub logging.
func Run(ctx context.Context, deps *Deps) {
cfg := config.Load() cfg := config.Load()
broker.LogConnect(ctx, cfg.BrokerURL) broker.LogConnect(ctx, cfg.BrokerURL)
t := time.NewTicker(60 * time.Second) if deps == nil || deps.Store == nil {
runStub(ctx)
return
}
if deps.Jobs == nil && (strings.TrimSpace(deps.APIBase) == "" || strings.TrimSpace(deps.APIToken) == "") {
runStub(ctx)
return
}
t := time.NewTicker(30 * time.Second)
defer t.Stop() defer t.Stop()
log.Printf("evobgp-scheduler: started (stub; connect EVOBGP_DATABASE_URL / EVOBGP_BROKER_URL for full stack)") if deps.Jobs != nil {
log.Printf("evobgp-scheduler: active (in-process enqueue module_refresh)")
} else {
log.Printf("evobgp-scheduler: active (HTTP POST .../modules/{id}/refresh → %s)", strings.TrimSpace(deps.APIBase))
}
for {
select {
case <-ctx.Done():
log.Printf("evobgp-scheduler: stopped")
return
case <-t.C:
tick(context.Background(), deps)
}
}
}
func tick(ctx context.Context, deps *Deps) {
tenants, err := deps.Store.ListTenantIDs()
if err != nil {
log.Printf("evobgp-scheduler: list tenants: %v", err)
return
}
for _, tid := range tenants {
for _, mod := range deps.Store.ListModules(tid) {
if !mod.Enabled || mod.Type == "IP_RANGES" {
continue
}
interval := mod.RefreshIntervalSec
if interval <= 0 {
continue
}
win := interval
if win < 60 {
win = 60
}
bucket := time.Now().Unix() / int64(win)
key := fmt.Sprintf("sched-%s-%d", mod.ID, bucket)
if deps.Jobs != nil {
mid := mod.ID
_, created, err := deps.Jobs.Enqueue(tid, jobs.KindModuleRefresh, &key, &mid, map[string]any{
"module_id": mod.ID,
"trigger": "scheduler",
})
if err != nil {
log.Printf("evobgp-scheduler: enqueue module %s: %v", mod.ID, err)
continue
}
if created {
log.Printf("evobgp-scheduler: queued refresh for module %s (%s)", mod.ID, mod.Type)
}
continue
}
if err := postModuleRefresh(ctx, deps, mod.ID, key); err != nil {
log.Printf("evobgp-scheduler: http refresh module %s: %v", mod.ID, err)
}
}
}
}
func postModuleRefresh(ctx context.Context, deps *Deps, moduleID, idempotencyKey string) error {
base := strings.TrimRight(strings.TrimSpace(deps.APIBase), "/")
u := base + "/v1/modules/" + moduleID + "/refresh"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(deps.APIToken))
if idempotencyKey != "" {
req.Header.Set("Idempotency-Key", idempotencyKey)
}
hc := deps.HTTP
if hc == nil {
hc = http.DefaultClient
}
resp, err := hc.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusAccepted {
return nil
}
b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return fmt.Errorf("%s: %s", resp.Status, strings.TrimSpace(string(b)))
}
func runStub(ctx context.Context) {
t := time.NewTicker(60 * time.Second)
defer t.Stop()
log.Printf("evobgp-scheduler: idle stub (no store/jobs: pass scheduler.Deps from evobgp-all or bootstrap worker)")
for { for {
select { select {
case <-ctx.Done(): case <-ctx.Done():
log.Printf("evobgp-scheduler: stopped") log.Printf("evobgp-scheduler: stopped")
return return
case <-t.C: case <-t.C:
// Placeholder: evaluate module cron / refresh_interval and publish jobs.
} }
} }
} }
+5
View File
@@ -10,6 +10,9 @@ type Backend interface {
// DemoIDs is non-empty only after SeedDemo (memory or seeded SQL). // DemoIDs is non-empty only after SeedDemo (memory or seeded SQL).
DemoIDs() (tenant, moduleCDN, moduleIP, revision, speaker string) DemoIDs() (tenant, moduleCDN, moduleIP, revision, speaker string)
// ListTenantIDs returns distinct tenant identifiers (for background workers).
ListTenantIDs() ([]string, error)
ListModules(tenantID string) []*Module ListModules(tenantID string) []*Module
GetModule(tenantID, moduleID string) (*Module, error) GetModule(tenantID, moduleID string) (*Module, error)
CreateModule(tenantID string, in *Module) (*Module, error) CreateModule(tenantID string, in *Module) (*Module, error)
@@ -64,6 +67,8 @@ type Backend interface {
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)
// CreateRenderRevision inserts a new config_revision (revID must be unique) with materialized prefixes and preview fragments.
CreateRenderRevision(revisionID, tenantID, moduleID string, parentRevisionID *string, contentHash string, previewFragments map[string]string, prefixes []PrefixRow) error
RevisionDiff(tenantID, aID, bID string) (map[string]any, error) RevisionDiff(tenantID, aID, bID string) (map[string]any, error)
SetLastAppliedRevision(tenantID, speakerID, revisionID string) error SetLastAppliedRevision(tenantID, speakerID, revisionID string) error
PublishRevisionForSpeaker(speakerID, revisionID string) error PublishRevisionForSpeaker(speakerID, revisionID string) error
+56
View File
@@ -284,6 +284,62 @@ func (m *Memory) DemoIDs() (tenant, moduleCDN, moduleIP, revision, speaker strin
return m.demoTenantID, m.demoModuleCDN, m.demoModuleIP, m.demoRevisionID, m.demoSpeakerID return m.demoTenantID, m.demoModuleCDN, m.demoModuleIP, m.demoRevisionID, m.demoSpeakerID
} }
// ListTenantIDs returns tenant ids sorted lexicographically.
func (m *Memory) ListTenantIDs() ([]string, error) {
m.mu.RLock()
defer m.mu.RUnlock()
out := make([]string, 0, len(m.tenants))
for id := range m.tenants {
out = append(out, id)
}
sort.Strings(out)
return out, nil
}
// CreateRenderRevision stores a new revision and its materialized prefixes.
func (m *Memory) CreateRenderRevision(revisionID, tenantID, moduleID string, parentRevisionID *string, contentHash string, previewFragments map[string]string, prefixes []PrefixRow) error {
if strings.TrimSpace(revisionID) == "" || strings.TrimSpace(moduleID) == "" || strings.TrimSpace(contentHash) == "" {
return ErrInvalidInput
}
m.mu.Lock()
defer m.mu.Unlock()
if _, exists := m.revisions[revisionID]; exists {
return ErrInvalidInput
}
mod, ok := m.modules[moduleID]
if !ok || mod.DeletedAt != nil || mod.TenantID != tenantID {
return ErrNotFound
}
if parentRevisionID != nil && *parentRevisionID != "" {
if pr, ok := m.revisions[*parentRevisionID]; !ok || pr.TenantID != tenantID {
return ErrNotFound
}
}
frag := make(map[string]string, len(previewFragments))
for k, v := range previewFragments {
frag[k] = v
}
parent := parentRevisionID
m.revisions[revisionID] = &Revision{
ID: revisionID,
TenantID: tenantID,
ModuleID: moduleID,
ContentHash: contentHash,
ParentRevisionID: parent,
CreatedAt: time.Now().UTC(),
MaterializedPrefixCount: len(prefixes),
PreviewFragments: frag,
}
if len(prefixes) > 0 {
cp := make([]PrefixRow, len(prefixes))
copy(cp, prefixes)
m.revPrefixes[revisionID] = cp
} else {
m.revPrefixes[revisionID] = nil
}
return nil
}
// ListModules returns modules for a tenant (sorted by priority, then name). // ListModules returns modules for a tenant (sorted by priority, then name).
func (m *Memory) ListModules(tenantID string) []*Module { func (m *Memory) ListModules(tenantID string) []*Module {
m.mu.RLock() m.mu.RLock()