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.
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user