Files
EvoBGP/internal/httpapi/server.go
T
DenozordecandCursor f8e0520817 feat(httpapi): harden control plane and speed up prefix refresh
Очередь job получила lease и fencing, телеметрия и прогресс deploy сохраняются, ревизия пишет manifest входов. Обновление префиксов не переписывает неизменённый снимок, большие наборы идут через COPY, а устаревшая ревизия не попадает в deploy. Бандл ноды кэшируется, prune и постановка job больше не держат горячий путь.

Co-authored-by: Cursor <[email protected]>
2026-11-02 17:23:59 +07:00

236 lines
7.5 KiB
Go

package httpapi
import (
"context"
"crypto/ed25519"
"crypto/rand"
"encoding/hex"
"errors"
"net/http"
"strings"
"sync"
"time"
"evobgp/internal/audit"
"evobgp/internal/jobs"
"evobgp/internal/logging"
"evobgp/internal/maintenance"
"evobgp/internal/pgmonitor"
"evobgp/internal/runtimelogs"
"evobgp/internal/store"
"evobgp/internal/telemetry"
"github.com/jackc/pgx/v5/pgxpool"
)
// Server implements EvoBGP control-plane HTTP API.
type Server struct {
store store.Backend
pgPool *pgxpool.Pool
pgMonitor *pgmonitor.Service
maintConfig *maintenance.ConfigProvider
maintStats *maintenance.DBStatsProvider
jobs *jobs.Registry
authLimiter *authRateLimiter
operationOnce sync.Once
operationLimiter *operationLimiter
bundlePriv ed25519.PrivateKey
bundleCache *nodeBundleCache
nodeBundleBuild nodeBundleBuilder
keyResolver *apiKeyResolver
firewallResolver *firewallTokenResolver
bundleSeedHex string
corsOrigins []string
cdnHTTP *http.Client
runtimeLogs *runtimelogs.Service
runtimeLogsPolicyTenant string
mux *http.ServeMux
backgroundMu sync.Mutex
backgroundStarted bool
backgroundClosed bool
backgroundCancel context.CancelFunc
telemetryCollector *telemetry.Collector
// Portal / dual-auth (JWT) configuration.
jwtSecret string
authIssuer string
authPortalURL string
portalTenantID string
authRequired bool
auditIngestSecret string
auditPusher *audit.PortalPusher
}
// Options configures the API server.
type Options struct {
APIKeys string
// DatabaseURL enables PostgreSQL-backed store (migrations applied on connect).
DatabaseURL string
InsecureDev bool
SeedDemo bool
BundleSeedHex string
CORSAllowedOrigins string
// RuntimeLogsPolicyTenant overrides tenant for auto-cleanup scheduler settings (optional).
RuntimeLogsPolicyTenant string
// Portal / dual-auth (JWT) — leave empty to disable JWT path.
JWTSecret string // AUTH_JWT_SECRET / EVOBGP_AUTH_JWT_SECRET (HS256 shared secret)
AuthIssuer string // AUTH_ISSUER (expected iss claim; default https://auth.shnt.top)
AuthPortalURL string // AUTH_PORTAL_URL (returned by /v1/auth/config for the UI)
PortalTenantID string // fallback when JWT has no bgp_tenant_id / tenants.bgp
AuthRequired bool // AUTH_REQUIRED / EVOBGP_AUTH_REQUIRED (surfaced via /v1/auth/config)
// AuditIngestSecret — AUTH_AUDIT_INGEST_SECRET for portal push (optional).
AuditIngestSecret string
}
// New constructs Server and wiring for async jobs.
func New(opts Options) (*Server, error) {
backend, reg, pool, err := BootstrapWorkers(context.Background(), opts)
if err != nil {
return nil, err
}
var priv ed25519.PrivateKey
if strings.TrimSpace(opts.BundleSeedHex) != "" {
seed, err := hex.DecodeString(strings.TrimSpace(opts.BundleSeedHex))
if err != nil {
return nil, err
}
if len(seed) != ed25519.SeedSize {
return nil, errors.New("httpapi: BundleSeedHex must decode to 32 bytes")
}
priv = ed25519.NewKeyFromSeed(seed)
} else {
_, priv, _ = ed25519.GenerateKey(rand.Reader)
}
resolver, err := newAPIKeyResolver(opts.APIKeys, backend)
if err != nil {
return nil, err
}
fwResolver, err := newFirewallTokenResolver(backend)
if err != nil {
return nil, err
}
var pgMon *pgmonitor.Service
var maintCfg *maintenance.ConfigProvider
var maintStats *maintenance.DBStatsProvider
if pool != nil {
pgMon = pgmonitor.NewService(pool)
maintCfg = maintenance.NewConfigProvider(backend)
_ = maintCfg.Reload(context.Background())
maintStats = maintenance.NewDBStatsProvider(pgMon)
}
s := &Server{
store: backend,
pgPool: pool,
pgMonitor: pgMon,
maintConfig: maintCfg,
maintStats: maintStats,
jobs: reg,
bundlePriv: priv,
bundleCache: newNodeBundleCache(nodeBundleCacheMaxEntries, nodeBundleCacheMaxBytes),
keyResolver: resolver,
firewallResolver: fwResolver,
bundleSeedHex: strings.TrimSpace(opts.BundleSeedHex),
corsOrigins: parseCORSOrigins(opts.CORSAllowedOrigins),
cdnHTTP: NewCDNHTTPClient(),
runtimeLogs: runtimelogs.NewService(runtimelogs.ConfigFromEnv()),
runtimeLogsPolicyTenant: strings.TrimSpace(opts.RuntimeLogsPolicyTenant),
jwtSecret: strings.TrimSpace(opts.JWTSecret),
authIssuer: strings.TrimSpace(opts.AuthIssuer),
authPortalURL: strings.TrimSpace(opts.AuthPortalURL),
portalTenantID: strings.TrimSpace(opts.PortalTenantID),
authRequired: opts.AuthRequired,
auditIngestSecret: strings.TrimSpace(opts.AuditIngestSecret),
}
if s.authIssuer == "" {
s.authIssuer = "https://auth.shnt.top"
}
s.initAuditPusher(s.authPortalURL, s.auditIngestSecret)
s.mux = http.NewServeMux()
s.registerRoutes()
return s, nil
}
// Close releases database resources.
func (s *Server) Close() {
if s == nil {
return
}
s.backgroundMu.Lock()
if s.backgroundClosed {
s.backgroundMu.Unlock()
return
}
s.backgroundClosed = true
cancel := s.backgroundCancel
collector := s.telemetryCollector
s.backgroundMu.Unlock()
if cancel != nil {
cancel()
}
if collector != nil {
_ = collector.Close(context.Background())
}
// Do not close the pool while a worker is still persisting its final state.
if s.jobs != nil {
s.jobs.RequestCancelAll()
_ = s.jobs.Drain(context.Background())
}
if s.pgPool != nil {
s.pgPool.Close()
}
}
// Store exposes the backing store (for operators / tests).
func (s *Server) Store() store.Backend { return s.store }
// Jobs exposes the in-process async job registry (for scheduler / evobgp-all).
func (s *Server) Jobs() *jobs.Registry { return s.jobs }
// StartBackground starts shared schedulers and telemetry polling once until ctx is cancelled.
func (s *Server) StartBackground(ctx context.Context) {
if s == nil {
return
}
s.backgroundMu.Lock()
defer s.backgroundMu.Unlock()
if s.backgroundClosed || s.backgroundStarted {
return
}
s.backgroundStarted = true
backgroundCtx, cancel := context.WithCancel(ctx)
s.backgroundCancel = cancel
if s.store != nil {
s.telemetryCollector = telemetry.Start(backgroundCtx, s.store, telemetry.ConfigFromEnv())
}
if s.pgPool != nil {
pgmonitor.StartScheduler(backgroundCtx, s.pgPool)
if s.jobs != nil {
jobs.StartDurableQueueLoop(backgroundCtx, s.jobs)
jobs.StartDeploymentReconciliationLoop(backgroundCtx, s.jobs, s.store)
}
}
if s != nil && s.maintConfig != nil && s.jobs != nil {
maintenance.StartScheduler(backgroundCtx, s.maintConfig, func(policyID string, dryRun bool, idem string) {
key := idem
_, _, err := s.jobs.EnqueueContext(backgroundCtx, store.SystemJobTenantID, jobs.KindMaintenancePolicyRun, &key, nil, map[string]any{
"policy_id": policyID,
"dry_run": dryRun,
"trigger": "scheduler",
})
if err != nil {
logging.With("component", "maintenance.scheduler").Error("enqueue failed", "policy_id", policyID, "err", err)
}
}, 30*time.Second)
}
if s != nil && s.runtimeLogs != nil && s.store != nil {
runtimelogs.StartAutoCleanupScheduler(backgroundCtx, runtimelogs.SchedulerDeps{
Service: s.runtimeLogs,
Store: s.store,
PolicyTenant: s.runtimeLogsPolicyTenant,
}, 30*time.Second)
}
}