691 lines
20 KiB
Go
691 lines
20 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"context"
|
|
"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)
|
|
s.registerCRUDRoutes(m)
|
|
}
|
|
|
|
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) {
|
|
checks := map[string]string{"store": "ok", "jobs": "memory"}
|
|
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
|
|
defer cancel()
|
|
if s.pgPool != nil {
|
|
if err := s.pgPool.Ping(ctx); err != nil {
|
|
checks["postgres"] = err.Error()
|
|
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"status": "not_ready", "checks": checks})
|
|
return
|
|
}
|
|
checks["postgres"] = "ok"
|
|
} else {
|
|
checks["store_backend"] = "memory"
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"status": "ready", "checks": checks})
|
|
}
|
|
|
|
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,
|
|
"remote_asn": p.RemoteASN,
|
|
"enabled": p.Enabled,
|
|
"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
|
|
}
|