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,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
|
||||
}
|
||||
Reference in New Issue
Block a user