feat: enhance evobgp with new command-line tools for bundle management, including pull, verify, and apply functionalities. Update go.mod to include necessary dependencies and complete todos in architecture plan for improved observability and deployment practices.
CI / changes (push) Successful in 4s
CI / go (push) Failing after 6s
CI / bird2 (push) Has been skipped
CI / openapi (push) Has been skipped

This commit is contained in:
Denozordec
2026-04-05 14:07:45 +07:00
parent 272542b92a
commit bf52b21150
131 changed files with 9222 additions and 17 deletions
+135
View File
@@ -0,0 +1,135 @@
package httpapi
import (
"context"
"net/http"
"strings"
)
type ctxKey int
const authCtxKey ctxKey = 1
// Auth holds resolved API identity for a request.
type Auth struct {
TenantID string
Role string // viewer, editor, operator, node
Token string
}
func authFromContext(ctx context.Context) (Auth, bool) {
a, ok := ctx.Value(authCtxKey).(Auth)
return a, ok
}
type apiKeyRecord struct {
token string
tenantID string
role string
}
func parseAPIKeysSpec(spec string) []apiKeyRecord {
spec = strings.TrimSpace(spec)
if spec == "" {
return nil
}
var out []apiKeyRecord
for _, part := range strings.Split(spec, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
fields := strings.Split(part, "|")
if len(fields) != 3 {
continue
}
out = append(out, apiKeyRecord{
token: strings.TrimSpace(fields[0]),
tenantID: strings.TrimSpace(fields[1]),
role: strings.TrimSpace(fields[2]),
})
}
return out
}
func (s *Server) authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if s.insecureDev {
h := r.Header.Get("Authorization")
const p = "Bearer "
if strings.HasPrefix(h, p) {
tok := strings.TrimSpace(strings.TrimPrefix(h, p))
if tok == "dev" {
if a, ok := s.devAuth(); ok {
r = r.WithContext(context.WithValue(r.Context(), authCtxKey, a))
next.ServeHTTP(w, r)
return
}
}
}
}
h := r.Header.Get("Authorization")
const p = "Bearer "
if !strings.HasPrefix(h, p) {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing or invalid bearer token")
return
}
raw := strings.TrimSpace(strings.TrimPrefix(h, p))
var matched *apiKeyRecord
for i := range s.apiKeys {
if s.apiKeys[i].token == raw {
matched = &s.apiKeys[i]
break
}
}
if matched == nil {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "unknown api key")
return
}
a := Auth{TenantID: matched.tenantID, Role: matched.role, Token: raw}
r = r.WithContext(context.WithValue(r.Context(), authCtxKey, a))
next.ServeHTTP(w, r)
})
}
func (s *Server) devAuth() (Auth, bool) {
tid, _, _, _, _ := s.store.DemoIDs()
if tid == "" {
return Auth{}, false
}
return Auth{TenantID: tid, Role: "operator", Token: "dev"}, true
}
func roleLevel(role string) int {
switch strings.ToLower(role) {
case "viewer":
return 1
case "editor":
return 2
case "operator":
return 3
default:
return 0
}
}
// requireAtLeast rejects node role and enforces viewer/editor/operator ladder.
func (s *Server) requireAtLeast(w http.ResponseWriter, a Auth, need string) bool {
if strings.ToLower(a.Role) == "node" {
writeProblem(w, http.StatusForbidden, "Forbidden", "node role cannot access this resource")
return false
}
if roleLevel(a.Role) < roleLevel(need) {
writeProblem(w, http.StatusForbidden, "Forbidden", "insufficient role")
return false
}
return true
}
func (s *Server) requireNode(w http.ResponseWriter, a Auth) bool {
if strings.ToLower(a.Role) != "node" {
writeProblem(w, http.StatusForbidden, "Forbidden", "node role required")
return false
}
return true
}
+48
View File
@@ -0,0 +1,48 @@
package httpapi
import (
"net/http"
"strings"
)
func parseCORSOrigins(spec string) []string {
spec = strings.TrimSpace(spec)
if spec == "" {
return nil
}
var out []string
for _, p := range strings.Split(spec, ",") {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
return out
}
func (s *Server) withCORS(h http.Handler) http.Handler {
if len(s.corsOrigins) == 0 {
return h
}
allowed := make(map[string]struct{}, len(s.corsOrigins))
for _, o := range s.corsOrigins {
allowed[o] = struct{}{}
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if origin != "" {
if _, ok := allowed[origin]; ok {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Add("Vary", "Origin")
}
}
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, Idempotency-Key, Accept, X-Tenant-Id")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
h.ServeHTTP(w, r)
})
}
+36
View File
@@ -0,0 +1,36 @@
package httpapi
import (
"encoding/json"
"net/http"
)
// Problem is RFC 9457 application/problem+json.
type Problem struct {
Type string `json:"type,omitempty"`
Title string `json:"title"`
Status int `json:"status"`
Detail string `json:"detail,omitempty"`
Instance string `json:"instance,omitempty"`
}
func writeProblem(w http.ResponseWriter, status int, title, detail string) {
w.Header().Set("Content-Type", "application/problem+json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(Problem{
Type: "about:blank",
Title: title,
Status: status,
Detail: detail,
})
}
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 writeNoContent(w http.ResponseWriter) {
w.WriteHeader(http.StatusNoContent)
}
+673
View File
@@ -0,0 +1,673 @@
package httpapi
import (
"crypto/ed25519"
"encoding/base64"
"encoding/json"
"net/http"
"os"
"sort"
"strconv"
"strings"
"time"
"evobgp/internal/bundle"
"evobgp/internal/jobs"
"evobgp/internal/observability"
"evobgp/internal/store"
)
// Handler returns the root HTTP handler (system routes public; rest under /v1/ authenticated).
func (s *Server) Handler() http.Handler {
v1 := http.NewServeMux()
s.registerV1(v1)
wrappedV1 := http.StripPrefix("/v1", v1)
s.mux.Handle("GET /metrics", observability.MetricsHandler())
s.mux.HandleFunc("GET /v1/health", s.handleHealth)
s.mux.HandleFunc("GET /v1/ready", s.handleReady)
s.mux.HandleFunc("GET /v1/version", s.handleVersion)
s.mux.Handle("/v1/", s.authMiddleware(wrappedV1))
return s.withCORS(observability.HTTPMiddleware(s.mux))
}
// BundlePublicKeyBase64 returns the Ed25519 public key for verifying bundles (share with evobgp-node).
func (s *Server) BundlePublicKeyBase64() string {
pub := s.bundlePriv.Public().(ed25519.PublicKey)
return base64.StdEncoding.EncodeToString(pub)
}
func (s *Server) registerRoutes() {
// routes attached in Handler()
}
func (s *Server) registerV1(m *http.ServeMux) {
m.HandleFunc("GET /modules", s.handleListModules)
m.HandleFunc("GET /modules/{module_id}", s.handleGetModule)
m.HandleFunc("GET /peers", s.handleListPeers)
m.HandleFunc("GET /speakers", s.handleListSpeakers)
m.HandleFunc("POST /modules/{module_id}/refresh", s.handleModuleRefresh)
m.HandleFunc("GET /revisions", s.handleListRevisions)
m.HandleFunc("GET /revisions/{revision_id}", s.handleGetRevision)
m.HandleFunc("GET /revisions/{revision_id}/preview", s.handleRevisionPreview)
m.HandleFunc("GET /revisions/{revision_a}/diff/{revision_b}", s.handleRevisionDiff)
m.HandleFunc("POST /revisions/{revision_id}/rollback", s.handleRevisionRollback)
m.HandleFunc("POST /apply", s.handleApply)
m.HandleFunc("POST /speakers/{id}/apply", s.handleSpeakerApply)
m.HandleFunc("POST /bird/reload", s.handleBirdReload)
m.HandleFunc("GET /jobs", s.handleListJobs)
m.HandleFunc("GET /jobs/{job_id}", s.handleGetJob)
m.HandleFunc("POST /jobs/{job_id}/cancel", s.handleCancelJob)
m.HandleFunc("GET /speakers/{speaker_id}/revisions/latest", s.handleNodeLatestRevision)
m.HandleFunc("GET /speakers/{speaker_id}/bundle/{revision_id}", s.handleNodeBundle)
m.HandleFunc("POST /nodes/enroll", s.handleNodeEnroll)
}
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) handleReady(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"status": "ready", "checks": map[string]string{"memory_store": "ok"}})
}
func (s *Server) handleVersion(w http.ResponseWriter, r *http.Request) {
sha := strings.TrimSpace(os.Getenv("EVOBGP_GIT_SHA"))
if sha == "" {
sha = "unknown"
}
writeJSON(w, http.StatusOK, map[string]string{"api_version": "0.1.0", "git_sha": sha})
}
func moduleJSON(mod *store.Module) map[string]any {
m := map[string]any{
"id": mod.ID,
"type": mod.Type,
"name": mod.Name,
"enabled": mod.Enabled,
"priority": mod.Priority,
"refresh_interval_sec": mod.RefreshIntervalSec,
"cron_expr": mod.CronExpr,
}
if mod.DefaultCommunityID != nil {
m["default_community_id"] = *mod.DefaultCommunityID
} else {
m["default_community_id"] = nil
}
if mod.DohProfileID != nil {
m["doh_profile_id"] = *mod.DohProfileID
} else {
m["doh_profile_id"] = nil
}
return m
}
func peerJSON(p *store.BGPPeer) map[string]any {
m := map[string]any{
"id": p.ID,
"name": p.Name,
"neighbor": p.Neighbor,
"session_state": p.SessionState,
}
if p.SpeakerID != nil {
m["bgp_speaker_id"] = *p.SpeakerID
} else {
m["bgp_speaker_id"] = nil
}
return m
}
func speakerJSON(sp *store.Speaker) map[string]any {
m := map[string]any{
"id": sp.ID,
"role": sp.Role,
"endpoint": sp.Endpoint,
}
if sp.LastAppliedRevisionID != nil {
m["last_applied_revision_id"] = *sp.LastAppliedRevisionID
} else {
m["last_applied_revision_id"] = nil
}
return m
}
func (s *Server) handleListModules(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "viewer") {
return
}
mods := s.store.ListModules(a.TenantID)
items := make([]map[string]any, 0, len(mods))
for _, mod := range mods {
items = append(items, moduleJSON(mod))
}
writeJSON(w, http.StatusOK, map[string]any{
"items": items, "next_cursor": nil, "has_more": false,
})
}
func (s *Server) handleGetModule(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "viewer") {
return
}
mod, err := s.store.GetModule(a.TenantID, r.PathValue("module_id"))
if err != nil {
if err == store.ErrNotFound || err == store.ErrTenantScope {
writeProblem(w, http.StatusNotFound, "Not Found", "module not found")
return
}
writeProblem(w, http.StatusInternalServerError, "Internal Error", err.Error())
return
}
writeJSON(w, http.StatusOK, moduleJSON(mod))
}
func (s *Server) handleListPeers(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "viewer") {
return
}
peers := s.store.ListPeers(a.TenantID)
items := make([]map[string]any, 0, len(peers))
for _, p := range peers {
items = append(items, peerJSON(p))
}
writeJSON(w, http.StatusOK, map[string]any{
"items": items, "next_cursor": nil, "has_more": false,
})
}
func (s *Server) handleListSpeakers(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "viewer") {
return
}
speakers := s.store.ListSpeakersForTenant(a.TenantID)
items := make([]map[string]any, 0, len(speakers))
for _, sp := range speakers {
items = append(items, speakerJSON(sp))
}
writeJSON(w, http.StatusOK, map[string]any{
"items": items, "next_cursor": nil, "has_more": false,
})
}
func (s *Server) handleModuleRefresh(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "editor") {
return
}
moduleID := r.PathValue("module_id")
mod, err := s.store.GetModule(a.TenantID, moduleID)
if err != nil {
if err == store.ErrNotFound || err == store.ErrTenantScope {
writeProblem(w, http.StatusNotFound, "Not Found", "module not found")
return
}
writeProblem(w, http.StatusInternalServerError, "Internal Error", err.Error())
return
}
if mod.Type == "IP_RANGES" {
writeNoContent(w)
return
}
idem := r.Header.Get("Idempotency-Key")
var idemPtr *string
if strings.TrimSpace(idem) != "" {
idem = strings.TrimSpace(idem)
idemPtr = &idem
}
mid := mod.ID
j, _, err := s.jobs.Enqueue(a.TenantID, jobs.KindModuleRefresh, idemPtr, &mid, map[string]any{"module_id": moduleID})
if err != nil {
writeProblem(w, http.StatusInternalServerError, "Internal Error", err.Error())
return
}
w.Header().Set("Location", "/v1/jobs/"+j.ID)
snap := j.Snapshot()
writeJSON(w, http.StatusAccepted, map[string]any{"job_id": snap["job_id"], "status": snap["status"]})
}
func (s *Server) handleListRevisions(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "viewer") {
return
}
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
if limit == 0 {
limit = 50
}
cursor := r.URL.Query().Get("cursor")
moduleID := r.URL.Query().Get("module_id")
items, next, more := s.store.ListRevisions(a.TenantID, moduleID, cursor, limit)
out := make([]map[string]any, 0, len(items))
for _, rev := range items {
out = append(out, revisionJSON(rev))
}
writeJSON(w, http.StatusOK, map[string]any{
"items": out, "next_cursor": strPtrOrNull(next), "has_more": more,
})
}
func revisionJSON(rev *store.Revision) map[string]any {
m := map[string]any{
"id": rev.ID,
"content_hash": rev.ContentHash,
"created_at": rev.CreatedAt.UTC().Format(time.RFC3339Nano),
"materialized_prefix_count": rev.MaterializedPrefixCount,
}
if rev.ModuleID != "" {
m["module_id"] = rev.ModuleID
} else {
m["module_id"] = nil
}
if rev.ParentRevisionID != nil {
m["parent_revision_id"] = *rev.ParentRevisionID
} else {
m["parent_revision_id"] = nil
}
return m
}
func strPtrOrNull(s string) any {
if s == "" {
return nil
}
return s
}
func (s *Server) handleGetRevision(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "viewer") {
return
}
rev, err := s.store.GetRevision(a.TenantID, r.PathValue("revision_id"))
if err != nil {
writeProblem(w, http.StatusNotFound, "Not Found", "revision not found")
return
}
writeJSON(w, http.StatusOK, revisionJSON(rev))
}
func (s *Server) handleRevisionPreview(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "viewer") {
return
}
rev, err := s.store.GetRevision(a.TenantID, r.PathValue("revision_id"))
if err != nil {
writeProblem(w, http.StatusNotFound, "Not Found", "revision not found")
return
}
acc := r.Header.Get("Accept")
if strings.Contains(acc, "text/plain") && !strings.Contains(acc, "application/json") {
var b strings.Builder
for _, k := range sortedFragmentKeys(rev.PreviewFragments) {
b.WriteString("# --- ")
b.WriteString(k)
b.WriteString(" ---\n")
b.WriteString(rev.PreviewFragments[k])
b.WriteByte('\n')
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(b.String()))
return
}
obj := make(map[string]any, len(rev.PreviewFragments)+1)
for k, v := range rev.PreviewFragments {
obj[k] = v
}
writeJSON(w, http.StatusOK, obj)
}
func (s *Server) handleRevisionDiff(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "viewer") {
return
}
d, err := s.store.RevisionDiff(a.TenantID, r.PathValue("revision_a"), r.PathValue("revision_b"))
if err != nil {
writeProblem(w, http.StatusNotFound, "Not Found", "revision not found")
return
}
writeJSON(w, http.StatusOK, d)
}
func (s *Server) handleRevisionRollback(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "editor") {
return
}
revID := r.PathValue("revision_id")
if _, err := s.store.GetRevision(a.TenantID, revID); err != nil {
writeProblem(w, http.StatusNotFound, "Not Found", "revision not found")
return
}
idem := strings.TrimSpace(r.Header.Get("Idempotency-Key"))
var idemPtr *string
if idem != "" {
idemPtr = &idem
}
j, _, err := s.jobs.Enqueue(a.TenantID, jobs.KindRevisionRollback, idemPtr, nil, map[string]any{
"source_revision_id": revID,
})
if err != nil {
writeProblem(w, http.StatusInternalServerError, "Internal Error", err.Error())
return
}
w.Header().Set("Location", "/v1/jobs/"+j.ID)
snap := j.Snapshot()
writeJSON(w, http.StatusAccepted, map[string]any{"job_id": snap["job_id"], "status": snap["status"]})
}
func (s *Server) handleApply(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if strings.ToLower(a.Role) != "operator" {
writeProblem(w, http.StatusForbidden, "Forbidden", "operator role required")
return
}
var body struct {
RevisionID string `json:"revision_id"`
Strategy string `json:"strategy"`
DryRun bool `json:"dry_run"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
revID := strings.TrimSpace(body.RevisionID)
if revID == "" {
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "revision_id required")
return
}
if _, err := s.store.GetRevision(a.TenantID, revID); err != nil {
writeProblem(w, http.StatusNotFound, "Not Found", "revision not found")
return
}
if body.DryRun {
writeJSON(w, http.StatusOK, map[string]any{"dry_run": true, "revision_id": revID})
return
}
idem := strings.TrimSpace(r.Header.Get("Idempotency-Key"))
var idemPtr *string
if idem != "" {
idemPtr = &idem
}
j, _, err := s.jobs.Enqueue(a.TenantID, jobs.KindDeployApply, idemPtr, nil, map[string]any{
"revision_id": revID,
"strategy": body.Strategy,
})
if err != nil {
writeProblem(w, http.StatusInternalServerError, "Internal Error", err.Error())
return
}
w.Header().Set("Location", "/v1/jobs/"+j.ID)
snap := j.Snapshot()
writeJSON(w, http.StatusAccepted, map[string]any{"job_id": snap["job_id"], "status": snap["status"]})
}
func (s *Server) handleSpeakerApply(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if strings.ToLower(a.Role) != "operator" {
writeProblem(w, http.StatusForbidden, "Forbidden", "operator role required")
return
}
spkID := r.PathValue("id")
if _, err := s.store.GetSpeaker(a.TenantID, spkID); err != nil {
writeProblem(w, http.StatusNotFound, "Not Found", "speaker not found")
return
}
var body struct {
RevisionID string `json:"revision_id"`
DryRun bool `json:"dry_run"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
revID := strings.TrimSpace(body.RevisionID)
if revID == "" {
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "revision_id required")
return
}
if _, err := s.store.GetRevision(a.TenantID, revID); err != nil {
writeProblem(w, http.StatusNotFound, "Not Found", "revision not found")
return
}
if body.DryRun {
writeJSON(w, http.StatusOK, map[string]any{"dry_run": true})
return
}
idem := strings.TrimSpace(r.Header.Get("Idempotency-Key"))
var idemPtr *string
if idem != "" {
idemPtr = &idem
}
j, _, err := s.jobs.Enqueue(a.TenantID, jobs.KindDeployApply, idemPtr, nil, map[string]any{
"revision_id": revID,
"speaker_id": spkID,
})
if err != nil {
writeProblem(w, http.StatusInternalServerError, "Internal Error", err.Error())
return
}
w.Header().Set("Location", "/v1/jobs/"+j.ID)
snap := j.Snapshot()
writeJSON(w, http.StatusAccepted, map[string]any{"job_id": snap["job_id"], "status": snap["status"]})
}
func (s *Server) handleBirdReload(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if strings.ToLower(a.Role) != "operator" {
writeProblem(w, http.StatusForbidden, "Forbidden", "operator role required")
return
}
idem := strings.TrimSpace(r.Header.Get("Idempotency-Key"))
var idemPtr *string
if idem != "" {
idemPtr = &idem
}
j, _, err := s.jobs.Enqueue(a.TenantID, jobs.KindBirdReload, idemPtr, nil, nil)
if err != nil {
writeProblem(w, http.StatusInternalServerError, "Internal Error", err.Error())
return
}
w.Header().Set("Location", "/v1/jobs/"+j.ID)
writeJSON(w, http.StatusAccepted, map[string]any{"job_id": j.ID, "status": "queued"})
}
func (s *Server) handleListJobs(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "viewer") {
return
}
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
status := r.URL.Query().Get("status")
kind := r.URL.Query().Get("kind")
cursor := r.URL.Query().Get("cursor")
list, next, more := s.jobs.List(a.TenantID, status, kind, cursor, limit)
items := make([]map[string]any, 0, len(list))
for _, j := range list {
items = append(items, j.Snapshot())
}
writeJSON(w, http.StatusOK, map[string]any{
"items": items, "next_cursor": strPtrOrNull(next), "has_more": more,
})
}
func (s *Server) handleGetJob(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "viewer") {
return
}
j, err := s.jobs.Get(a.TenantID, r.PathValue("job_id"))
if err != nil {
writeProblem(w, http.StatusNotFound, "Not Found", "job not found")
return
}
writeJSON(w, http.StatusOK, j.Snapshot())
}
func (s *Server) handleCancelJob(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "editor") {
return
}
j, err := s.jobs.RequestCancel(a.TenantID, r.PathValue("job_id"))
if err != nil {
writeProblem(w, http.StatusNotFound, "Not Found", "job not found")
return
}
writeJSON(w, http.StatusAccepted, j.Snapshot())
}
func (s *Server) handleNodeLatestRevision(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireNode(w, a) {
return
}
sid := r.PathValue("speaker_id")
sp, err := s.store.GetSpeakerAnyTenant(sid)
if err != nil {
writeProblem(w, http.StatusNotFound, "Not Found", "speaker not found")
return
}
if sp.TenantID != a.TenantID {
writeProblem(w, http.StatusForbidden, "Forbidden", "speaker not in tenant scope")
return
}
rid, at, err := s.store.LatestPublishedRevision(sid)
if err != nil {
writeProblem(w, http.StatusNotFound, "Not Found", "no published revision")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"revision_id": rid, "published_at": at.UTC().Format(time.RFC3339Nano),
})
}
func (s *Server) handleNodeBundle(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireNode(w, a) {
return
}
sid := r.PathValue("speaker_id")
rid := r.PathValue("revision_id")
sp, err := s.store.GetSpeakerAnyTenant(sid)
if err != nil {
writeProblem(w, http.StatusNotFound, "Not Found", "speaker not found")
return
}
if sp.TenantID != a.TenantID {
writeProblem(w, http.StatusForbidden, "Forbidden", "speaker not in tenant scope")
return
}
rev, err := s.store.GetRevision(a.TenantID, rid)
if err != nil {
writeProblem(w, http.StatusNotFound, "Not Found", "revision not found")
return
}
tgz, err := bundle.BuildGzippedTar(rid, sid, rev.PreviewFragments, s.bundlePriv)
if err != nil {
writeProblem(w, http.StatusInternalServerError, "Internal Error", err.Error())
return
}
w.Header().Set("Content-Type", "application/gzip")
w.Header().Set("Content-Disposition", `attachment; filename="bundle.tar.gz"`)
w.WriteHeader(http.StatusOK)
_, _ = w.Write(tgz)
}
func (s *Server) handleNodeEnroll(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireNode(w, a) {
return
}
var req map[string]any
_ = json.NewDecoder(r.Body).Decode(&req)
writeJSON(w, http.StatusOK, map[string]any{
"status": "accepted",
"message": "enrollment stub; operator approval required in production",
})
}
func sortedFragmentKeys(m map[string]string) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
+78
View File
@@ -0,0 +1,78 @@
package httpapi
import (
"crypto/ed25519"
"crypto/rand"
"encoding/hex"
"errors"
"net/http"
"strings"
"evobgp/internal/jobs"
"evobgp/internal/observability"
"evobgp/internal/store"
)
// Server implements EvoBGP control-plane HTTP API (subset focused on jobs, deploy, node bundle).
type Server struct {
store *store.Memory
jobs *jobs.Registry
bundlePriv ed25519.PrivateKey
apiKeys []apiKeyRecord
insecureDev bool
corsOrigins []string
mux *http.ServeMux
}
// Options configures the API server.
type Options struct {
// APIKeys is comma-separated "token|tenantUUID|role" (role: viewer, editor, operator, node).
APIKeys string
// InsecureDev with SeedDemo allows Bearer "dev" as operator for the demo tenant (local only).
InsecureDev bool
SeedDemo bool
// BundleSeedHex is 64 hex chars (32 bytes) for deterministic Ed25519 bundle signing; if empty, random.
BundleSeedHex string
// CORSAllowedOrigins is comma-separated list of allowed browser Origins (e.g. http://localhost:4173).
CORSAllowedOrigins string
}
// New constructs Server and wiring for async jobs.
func New(opts Options) (*Server, error) {
mem := store.NewMemory()
if opts.SeedDemo {
mem.SeedDemo()
}
wk := &jobs.Worker{Store: mem}
reg := jobs.NewRegistry(wk.Process)
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)
}
s := &Server{
store: mem,
jobs: reg,
bundlePriv: priv,
apiKeys: parseAPIKeysSpec(opts.APIKeys),
insecureDev: opts.InsecureDev && opts.SeedDemo,
corsOrigins: parseCORSOrigins(opts.CORSAllowedOrigins),
}
observability.RegisterStoreMetrics(mem)
s.mux = http.NewServeMux()
s.registerRoutes()
return s, nil
}
// Store exposes the in-memory store (for operators / tests).
func (s *Server) Store() *store.Memory { return s.store }
+228
View File
@@ -0,0 +1,228 @@
package httpapi
import (
"crypto/ed25519"
"encoding/base64"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"evobgp/internal/jobs"
"evobgp/internal/signing"
)
const testBundleSeed = "0101010101010101010101010101010101010101010101010101010101010101"
func TestAPIRefreshApplyJobsBundle(t *testing.T) {
srv, err := New(Options{
InsecureDev: true,
SeedDemo: true,
BundleSeedHex: testBundleSeed,
})
if err != nil {
t.Fatal(err)
}
tenant, modCDN, modIP, rev, speaker := srv.Store().DemoIDs()
srv.apiKeys = parseAPIKeysSpec("nodekey|" + tenant + "|node,opkey|" + tenant + "|operator")
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
client := ts.Client()
base := ts.URL
t.Run("prometheus metrics", func(t *testing.T) {
req, _ := http.NewRequest(http.MethodGet, base+"/metrics", nil)
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d: %s", resp.StatusCode, b)
}
raw, _ := io.ReadAll(resp.Body)
s := string(raw)
for _, needle := range []string{
"evobgp_materialized_prefixes_max",
"evobgp_bgp_peers_configured_total",
"evobgp_http_requests_total", // incremented by this scrape request
} {
if !strings.Contains(s, needle) {
t.Fatalf("metrics body missing %q", needle)
}
}
})
t.Run("refresh IP_RANGES no op", func(t *testing.T) {
req, _ := http.NewRequest(http.MethodPost, base+"/v1/modules/"+modIP+"/refresh", nil)
req.Header.Set("Authorization", "Bearer opkey")
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d: %s", resp.StatusCode, b)
}
})
t.Run("refresh CDN queues job", func(t *testing.T) {
req, _ := http.NewRequest(http.MethodPost, base+"/v1/modules/"+modCDN+"/refresh", nil)
req.Header.Set("Authorization", "Bearer opkey")
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusAccepted {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d: %s", resp.StatusCode, b)
}
var body struct {
JobID string `json:"job_id"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
t.Fatal(err)
}
waitJob(t, client, base, "opkey", body.JobID)
})
t.Run("preview revision", func(t *testing.T) {
req, _ := http.NewRequest(http.MethodGet, base+"/v1/revisions/"+rev+"/preview", nil)
req.Header.Set("Authorization", "Bearer opkey")
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d: %s", resp.StatusCode, b)
}
})
t.Run("list modules peers speakers", func(t *testing.T) {
for _, path := range []string{"/v1/modules", "/v1/peers", "/v1/speakers"} {
req, _ := http.NewRequest(http.MethodGet, base+path, nil)
req.Header.Set("Authorization", "Bearer opkey")
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
b, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("%s status %d: %s", path, resp.StatusCode, b)
}
var body struct {
Items []map[string]any `json:"items"`
}
if err := json.Unmarshal(b, &body); err != nil {
t.Fatalf("%s json: %v", path, err)
}
if len(body.Items) < 1 {
t.Fatalf("%s expected items", path)
}
}
})
t.Run("rollback queues job", func(t *testing.T) {
req, _ := http.NewRequest(http.MethodPost, base+"/v1/revisions/"+rev+"/rollback", nil)
req.Header.Set("Authorization", "Bearer opkey")
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusAccepted {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d: %s", resp.StatusCode, b)
}
var body struct {
JobID string `json:"job_id"`
}
_ = json.NewDecoder(resp.Body).Decode(&body)
waitJob(t, client, base, "opkey", body.JobID)
})
t.Run("apply all speakers", func(t *testing.T) {
req, _ := http.NewRequest(http.MethodPost, base+"/v1/apply", strings.NewReader(`{"revision_id":"`+rev+`"}`))
req.Header.Set("Authorization", "Bearer opkey")
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusAccepted {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d: %s", resp.StatusCode, b)
}
var body struct {
JobID string `json:"job_id"`
}
_ = json.NewDecoder(resp.Body).Decode(&body)
waitJob(t, client, base, "opkey", body.JobID)
})
pubB64 := srv.BundlePublicKeyBase64()
pubBytes, err := base64.StdEncoding.DecodeString(pubB64)
if err != nil {
t.Fatal(err)
}
t.Run("node bundle roundtrip verify", func(t *testing.T) {
req, _ := http.NewRequest(http.MethodGet, base+"/v1/speakers/"+speaker+"/bundle/"+rev, nil)
req.Header.Set("Authorization", "Bearer nodekey")
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d: %s", resp.StatusCode, b)
}
raw, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatal(err)
}
_, err = signing.VerifyGzippedTar(raw, ed25519.PublicKey(pubBytes))
if err != nil {
t.Fatal(err)
}
})
}
func waitJob(t *testing.T, client *http.Client, base, token, jobID string) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
req, _ := http.NewRequest(http.MethodGet, base+"/v1/jobs/"+jobID, nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
b, _ := io.ReadAll(resp.Body)
resp.Body.Close()
var body struct {
Status string `json:"status"`
}
_ = json.Unmarshal(b, &body)
if body.Status == jobs.StatusSucceeded || body.Status == jobs.StatusFailed {
if body.Status != jobs.StatusSucceeded {
t.Fatalf("job %s status %s", jobID, body.Status)
}
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("job %s did not complete", jobID)
}