1286 lines
36 KiB
Go
1286 lines
36 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"context"
|
|
"crypto/ed25519"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"evobgp/internal/birdfmt"
|
|
"evobgp/internal/bundle"
|
|
"evobgp/internal/jobs"
|
|
"evobgp/internal/observability"
|
|
"evobgp/internal/pipeline"
|
|
"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 /router-lists/catalog", s.handleRouterListsCatalog)
|
|
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("POST /tenant/refresh", s.handleTenantRefresh)
|
|
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_id}/diagnostic-log", s.handleRevisionDiagnosticLog)
|
|
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 /bird/status", s.handleBirdStatus)
|
|
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"] = "unavailable"
|
|
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.LastRefreshedAt != nil {
|
|
m["last_refreshed_at"] = mod.LastRefreshedAt.UTC().Format(time.RFC3339Nano)
|
|
} else {
|
|
m["last_refreshed_at"] = nil
|
|
}
|
|
if mod.DefaultCommunityID != nil {
|
|
m["default_community_id"] = *mod.DefaultCommunityID
|
|
} else {
|
|
m["default_community_id"] = nil
|
|
}
|
|
ids := mod.EffectiveDohProfileIDs()
|
|
if len(ids) > 0 {
|
|
m["doh_profile_ids"] = ids
|
|
} else {
|
|
m["doh_profile_ids"] = []string{}
|
|
}
|
|
m["doh_resolver_policy"] = store.NormalizeDohResolverPolicy(mod.DohResolverPolicy)
|
|
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
|
|
}
|
|
typeFilter := strings.TrimSpace(r.URL.Query().Get("type"))
|
|
enabledRaw := strings.TrimSpace(r.URL.Query().Get("enabled"))
|
|
var enabledFilter *bool
|
|
if enabledRaw != "" {
|
|
v, err := strconv.ParseBool(enabledRaw)
|
|
if err != nil {
|
|
writeProblem(w, http.StatusBadRequest, "Bad Request", "enabled must be boolean")
|
|
return
|
|
}
|
|
enabledFilter = &v
|
|
}
|
|
|
|
mods := s.store.ListModules(a.TenantID)
|
|
items := make([]map[string]any, 0, len(mods))
|
|
for _, mod := range mods {
|
|
if typeFilter != "" && mod.Type != typeFilter {
|
|
continue
|
|
}
|
|
if enabledFilter != nil && mod.Enabled != *enabledFilter {
|
|
continue
|
|
}
|
|
items = append(items, moduleJSON(mod))
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"items": items, "next_cursor": nil, "has_more": false,
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleRouterListsCatalog(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)
|
|
moduleItems := make([]map[string]any, 0, len(mods))
|
|
domains := make([]map[string]any, 0)
|
|
asns := make([]map[string]any, 0)
|
|
ipRanges := make([]map[string]any, 0)
|
|
|
|
for _, mod := range mods {
|
|
switch mod.Type {
|
|
case "DOMAINS", "AS_PREFIXES", "IP_RANGES":
|
|
moduleItems = append(moduleItems, moduleJSON(mod))
|
|
default:
|
|
continue
|
|
}
|
|
|
|
switch mod.Type {
|
|
case "DOMAINS":
|
|
list, err := s.store.ListDomainEntries(a.TenantID, mod.ID)
|
|
if err != nil {
|
|
writeStoreErr(w, err)
|
|
return
|
|
}
|
|
for _, x := range list {
|
|
domains = append(domains, map[string]any{
|
|
"module_id": mod.ID,
|
|
"entry": domainEntryJSON(x),
|
|
})
|
|
}
|
|
case "AS_PREFIXES":
|
|
list, err := s.store.ListASEntries(a.TenantID, mod.ID)
|
|
if err != nil {
|
|
writeStoreErr(w, err)
|
|
return
|
|
}
|
|
for _, x := range list {
|
|
asns = append(asns, map[string]any{
|
|
"module_id": mod.ID,
|
|
"entry": asEntryJSON(x),
|
|
})
|
|
}
|
|
case "IP_RANGES":
|
|
list, err := s.store.ListIPRangeEntries(a.TenantID, mod.ID)
|
|
if err != nil {
|
|
writeStoreErr(w, err)
|
|
return
|
|
}
|
|
for _, x := range list {
|
|
ipRanges = append(ipRanges, map[string]any{
|
|
"module_id": mod.ID,
|
|
"entry": ipRangeJSON(x),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
comms, err := s.store.ListCommunities(a.TenantID)
|
|
if err != nil {
|
|
writeStoreErr(w, err)
|
|
return
|
|
}
|
|
communityItems := make([]map[string]any, 0, len(comms))
|
|
for _, c := range comms {
|
|
communityItems = append(communityItems, map[string]any{
|
|
"id": c.ID,
|
|
"community": c.Community,
|
|
"title": c.Title,
|
|
})
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"modules": map[string]any{
|
|
"items": moduleItems,
|
|
},
|
|
"domains": map[string]any{
|
|
"items": domains,
|
|
},
|
|
"asns": map[string]any{
|
|
"items": asns,
|
|
},
|
|
"ip_ranges": map[string]any{
|
|
"items": ipRanges,
|
|
},
|
|
"communities": map[string]any{
|
|
"items": communityItems,
|
|
},
|
|
})
|
|
}
|
|
|
|
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
|
|
}
|
|
writeInternalError(w, "internal", err)
|
|
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)
|
|
liveStates := s.liveBGPProtocolStates(r.Context())
|
|
items := make([]map[string]any, 0, len(peers))
|
|
for _, p := range peers {
|
|
row := peerJSON(p)
|
|
if st, ok := liveStates[peerProtocolNameForID(p.ID)]; ok && strings.TrimSpace(st) != "" {
|
|
row["session_state"] = strings.TrimSpace(st)
|
|
}
|
|
items = append(items, row)
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"items": items, "next_cursor": nil, "has_more": false,
|
|
})
|
|
}
|
|
|
|
func (s *Server) liveBGPProtocolStates(ctx context.Context) map[string]string {
|
|
sock := strings.TrimSpace(os.Getenv("EVOBGP_BIRDC_SOCKET"))
|
|
if sock == "" {
|
|
return map[string]string{}
|
|
}
|
|
out, err := birdfmt.ShowProtocols(ctx, sock, strings.TrimSpace(os.Getenv("EVOBGP_BIRDC_BIN")))
|
|
if err != nil {
|
|
return map[string]string{}
|
|
}
|
|
return parseBGPProtocolStates(out)
|
|
}
|
|
|
|
// parseBGPProtocolStates parses `birdc show protocols all` summary rows into protocol_name -> state.
|
|
func parseBGPProtocolStates(output string) map[string]string {
|
|
out := make(map[string]string)
|
|
for _, raw := range strings.Split(output, "\n") {
|
|
line := strings.TrimSpace(raw)
|
|
if line == "" {
|
|
continue
|
|
}
|
|
low := strings.ToLower(line)
|
|
if strings.HasPrefix(low, "bird ") || strings.HasPrefix(low, "name ") || strings.HasPrefix(low, "table ") {
|
|
continue
|
|
}
|
|
fields := strings.Fields(line)
|
|
if len(fields) < 4 {
|
|
continue
|
|
}
|
|
if !strings.EqualFold(fields[1], "BGP") {
|
|
continue
|
|
}
|
|
state := extractBGPSessionState(line)
|
|
if state == "" {
|
|
state = fields[3]
|
|
}
|
|
out[fields[0]] = state
|
|
}
|
|
return out
|
|
}
|
|
|
|
func extractBGPSessionState(line string) string {
|
|
known := []string{
|
|
"Established",
|
|
"Idle",
|
|
"Connect",
|
|
"Active",
|
|
"OpenSent",
|
|
"OpenConfirm",
|
|
}
|
|
for _, st := range known {
|
|
if strings.Contains(line, st) {
|
|
return st
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// peerProtocolNameForID must stay in sync with pipeline peer protocol naming.
|
|
func peerProtocolNameForID(peerID string) string {
|
|
s := strings.ReplaceAll(strings.TrimSpace(peerID), "-", "")
|
|
if len(s) > 16 {
|
|
s = s[:16]
|
|
}
|
|
if s == "" {
|
|
s = "x"
|
|
}
|
|
return "evobgp_p_" + s
|
|
}
|
|
|
|
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
|
|
}
|
|
writeInternalError(w, "internal", err)
|
|
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 {
|
|
writeInternalError(w, "internal", err)
|
|
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) handleTenantRefresh(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
|
|
}
|
|
var body struct {
|
|
ModuleIDs []string `json:"module_ids"`
|
|
}
|
|
if r.Body != nil && r.ContentLength != 0 {
|
|
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body); err != nil && err != io.EOF {
|
|
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid JSON body")
|
|
return
|
|
}
|
|
}
|
|
moduleIDs := body.ModuleIDs
|
|
if len(moduleIDs) == 0 {
|
|
now := time.Now().UTC()
|
|
for _, mod := range s.store.ListModules(a.TenantID) {
|
|
if pipeline.ModuleDueForScheduler(mod, now) {
|
|
moduleIDs = append(moduleIDs, mod.ID)
|
|
}
|
|
}
|
|
}
|
|
if len(moduleIDs) == 0 {
|
|
writeJSON(w, http.StatusNoContent, map[string]any{"message": "no modules due for refresh"})
|
|
return
|
|
}
|
|
idem := strings.TrimSpace(r.Header.Get("Idempotency-Key"))
|
|
var idemPtr *string
|
|
if idem != "" {
|
|
idemPtr = &idem
|
|
}
|
|
j, _, err := s.jobs.Enqueue(a.TenantID, jobs.KindTenantRefresh, idemPtr, nil, map[string]any{
|
|
"module_ids": moduleIDs,
|
|
"trigger": "api",
|
|
})
|
|
if err != nil {
|
|
writeInternalError(w, "internal", err)
|
|
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"],
|
|
"module_ids": moduleIDs,
|
|
"modules_due": len(moduleIDs),
|
|
})
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// enqueueModuleRefreshIfEnabled queues module_refresh when the module exists and is enabled (best-effort, no HTTP error).
|
|
func (s *Server) enqueueModuleRefreshIfEnabled(tenantID, moduleID, trigger string) {
|
|
if s.jobs == nil {
|
|
return
|
|
}
|
|
mod, err := s.store.GetModule(tenantID, moduleID)
|
|
if err != nil || !mod.Enabled {
|
|
return
|
|
}
|
|
mid := moduleID
|
|
_, _, _ = s.jobs.Enqueue(tenantID, jobs.KindModuleRefresh, nil, &mid, map[string]any{
|
|
"module_id": moduleID,
|
|
"trigger": trigger,
|
|
})
|
|
}
|
|
|
|
// enqueuePeerReconcile queues fast peer-only reconcile/render (best-effort, no HTTP error).
|
|
func (s *Server) enqueuePeerReconcile(tenantID, trigger string) {
|
|
if s.jobs == nil {
|
|
return
|
|
}
|
|
_, _, _ = s.jobs.Enqueue(tenantID, jobs.KindPeerReconcile, nil, nil, map[string]any{
|
|
"trigger": trigger,
|
|
"job_title": "Обновление BGP пиров",
|
|
})
|
|
}
|
|
|
|
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) handleRevisionDiagnosticLog(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
|
|
}
|
|
revID := r.PathValue("revision_id")
|
|
rev, err := s.store.GetRevision(a.TenantID, revID)
|
|
if err != nil {
|
|
writeProblem(w, http.StatusNotFound, "Not Found", "revision not found")
|
|
return
|
|
}
|
|
|
|
moduleType := ""
|
|
moduleName := ""
|
|
if strings.TrimSpace(rev.ModuleID) != "" {
|
|
if mod, modErr := s.store.GetModule(a.TenantID, rev.ModuleID); modErr == nil && mod != nil {
|
|
moduleType = strings.TrimSpace(mod.Type)
|
|
moduleName = strings.TrimSpace(mod.Name)
|
|
}
|
|
}
|
|
|
|
communityLabels := map[string]string{}
|
|
if communities, listErr := s.store.ListCommunities(a.TenantID); listErr == nil {
|
|
for _, c := range communities {
|
|
if c == nil {
|
|
continue
|
|
}
|
|
label := strings.TrimSpace(c.Title)
|
|
if label == "" {
|
|
label = strings.TrimSpace(c.Community)
|
|
}
|
|
if label == "" {
|
|
label = c.ID
|
|
}
|
|
communityLabels[c.ID] = label
|
|
}
|
|
}
|
|
|
|
type rawRow struct {
|
|
Prefix string
|
|
Source string
|
|
Kind string
|
|
SourceName string
|
|
SourceDetail string
|
|
CommunityID string
|
|
CommunityLabel string
|
|
}
|
|
type summary struct {
|
|
Kind string
|
|
Source string
|
|
SourceDetail string
|
|
CommunityID string
|
|
CommunityLabel string
|
|
Count int
|
|
Sample []string
|
|
}
|
|
|
|
cdnSourceURLByID := map[string]string{}
|
|
if moduleType == "CDN_CIDRS" && strings.TrimSpace(rev.ModuleID) != "" {
|
|
if sources, srcErr := s.store.ListCDNSources(a.TenantID, rev.ModuleID); srcErr == nil {
|
|
for _, src := range sources {
|
|
if src == nil {
|
|
continue
|
|
}
|
|
id := strings.TrimSpace(src.ID)
|
|
url := strings.TrimSpace(src.URL)
|
|
if id != "" && url != "" {
|
|
cdnSourceURLByID[id] = url
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
rows := make([]rawRow, 0, rev.MaterializedPrefixCount)
|
|
byGroup := map[string]*summary{}
|
|
cursor := ""
|
|
for {
|
|
page, next, more := s.store.ListRevisionPrefixes(a.TenantID, revID, cursor, 2000)
|
|
for _, p := range page {
|
|
kind, sourceName := classifyRevisionSource(p.Source)
|
|
sourceDetail := sourceName
|
|
if kind == "cdn" {
|
|
if url, ok := cdnSourceURLByID[sourceName]; ok && strings.TrimSpace(url) != "" {
|
|
sourceDetail = url
|
|
}
|
|
}
|
|
communityID := "none"
|
|
if p.CommunityID != nil && strings.TrimSpace(*p.CommunityID) != "" {
|
|
communityID = strings.TrimSpace(*p.CommunityID)
|
|
}
|
|
communityLabel := "без community"
|
|
if communityID != "none" {
|
|
if lbl, ok := communityLabels[communityID]; ok && strings.TrimSpace(lbl) != "" {
|
|
communityLabel = lbl
|
|
} else {
|
|
communityLabel = communityID
|
|
}
|
|
}
|
|
rows = append(rows, rawRow{
|
|
Prefix: p.Prefix,
|
|
Source: p.Source,
|
|
Kind: kind,
|
|
SourceName: sourceName,
|
|
SourceDetail: sourceDetail,
|
|
CommunityID: communityID,
|
|
CommunityLabel: communityLabel,
|
|
})
|
|
groupKey := kind + "|" + sourceName + "|" + communityID
|
|
g, ok := byGroup[groupKey]
|
|
if !ok {
|
|
g = &summary{
|
|
Kind: kind,
|
|
Source: sourceName,
|
|
SourceDetail: sourceDetail,
|
|
CommunityID: communityID,
|
|
CommunityLabel: communityLabel,
|
|
Sample: make([]string, 0, 5),
|
|
}
|
|
byGroup[groupKey] = g
|
|
}
|
|
g.Count++
|
|
if len(g.Sample) < 5 {
|
|
g.Sample = append(g.Sample, p.Prefix)
|
|
}
|
|
}
|
|
if !more || strings.TrimSpace(next) == "" {
|
|
break
|
|
}
|
|
cursor = next
|
|
}
|
|
|
|
summaryKeys := make([]string, 0, len(byGroup))
|
|
for k := range byGroup {
|
|
summaryKeys = append(summaryKeys, k)
|
|
}
|
|
sort.Strings(summaryKeys)
|
|
|
|
sort.Slice(rows, func(i, j int) bool {
|
|
if rows[i].Kind != rows[j].Kind {
|
|
return rows[i].Kind < rows[j].Kind
|
|
}
|
|
if rows[i].SourceName != rows[j].SourceName {
|
|
return rows[i].SourceName < rows[j].SourceName
|
|
}
|
|
if rows[i].CommunityID != rows[j].CommunityID {
|
|
return rows[i].CommunityID < rows[j].CommunityID
|
|
}
|
|
return rows[i].Prefix < rows[j].Prefix
|
|
})
|
|
|
|
var b strings.Builder
|
|
b.WriteString("EvoBGP revision diagnostic log\n")
|
|
b.WriteString("generated_at=" + time.Now().UTC().Format(time.RFC3339Nano) + "\n")
|
|
b.WriteString("tenant_id=" + a.TenantID + "\n")
|
|
b.WriteString("revision_id=" + rev.ID + "\n")
|
|
b.WriteString("revision_created_at=" + rev.CreatedAt.UTC().Format(time.RFC3339Nano) + "\n")
|
|
b.WriteString("content_hash=" + rev.ContentHash + "\n")
|
|
b.WriteString("materialized_prefix_count=" + strconv.Itoa(rev.MaterializedPrefixCount) + "\n")
|
|
if rev.ModuleID != "" {
|
|
b.WriteString("module_id=" + rev.ModuleID + "\n")
|
|
} else {
|
|
b.WriteString("module_id=\n")
|
|
}
|
|
b.WriteString("module_type=" + moduleType + "\n")
|
|
b.WriteString("module_name=" + moduleName + "\n")
|
|
b.WriteString("fetched_rows=" + strconv.Itoa(len(rows)) + "\n\n")
|
|
|
|
b.WriteString("## Aggregation by source and community\n")
|
|
for _, k := range summaryKeys {
|
|
g := byGroup[k]
|
|
b.WriteString("- kind=" + g.Kind +
|
|
" source=" + g.Source +
|
|
" source_detail=" + g.SourceDetail +
|
|
" community_id=" + g.CommunityID +
|
|
" community_label=" + g.CommunityLabel +
|
|
" count=" + strconv.Itoa(g.Count))
|
|
if len(g.Sample) > 0 {
|
|
b.WriteString(" sample=" + strings.Join(g.Sample, ","))
|
|
}
|
|
b.WriteByte('\n')
|
|
}
|
|
|
|
b.WriteString("\n## Raw rows\n")
|
|
b.WriteString("prefix\tkind\tsource\tsource_detail\tcommunity_id\tcommunity_label\n")
|
|
for _, row := range rows {
|
|
b.WriteString(row.Prefix)
|
|
b.WriteByte('\t')
|
|
b.WriteString(row.Kind)
|
|
b.WriteByte('\t')
|
|
b.WriteString(row.Source)
|
|
b.WriteByte('\t')
|
|
b.WriteString(strings.ReplaceAll(row.SourceDetail, "\t", " "))
|
|
b.WriteByte('\t')
|
|
b.WriteString(row.CommunityID)
|
|
b.WriteByte('\t')
|
|
b.WriteString(strings.ReplaceAll(row.CommunityLabel, "\t", " "))
|
|
b.WriteByte('\n')
|
|
}
|
|
|
|
filename := "revision-" + shortRevisionID(rev.ID) + "-diagnostic.log"
|
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
|
w.Header().Set("Content-Disposition", `attachment; filename="`+filename+`"`)
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(b.String()))
|
|
}
|
|
|
|
func classifyRevisionSource(src string) (kind, sourceName string) {
|
|
switch {
|
|
case strings.HasPrefix(src, "as:"):
|
|
return "asn", strings.TrimPrefix(src, "as:")
|
|
case strings.HasPrefix(src, "domain:"):
|
|
return "domain", strings.TrimPrefix(src, "domain:")
|
|
case strings.HasPrefix(src, "cdn:"):
|
|
return "cdn", strings.TrimPrefix(src, "cdn:")
|
|
case src == "ip_range":
|
|
return "ip_range", "manual_ranges"
|
|
default:
|
|
if strings.TrimSpace(src) == "" {
|
|
return "unknown", "unknown"
|
|
}
|
|
return "source", strings.TrimSpace(src)
|
|
}
|
|
}
|
|
|
|
func shortRevisionID(id string) string {
|
|
s := strings.TrimSpace(id)
|
|
if len(s) <= 8 {
|
|
return s
|
|
}
|
|
return s[:8]
|
|
}
|
|
|
|
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, "operator") {
|
|
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 {
|
|
writeInternalError(w, "internal", err)
|
|
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 {
|
|
writeInternalError(w, "internal", err)
|
|
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 {
|
|
writeInternalError(w, "internal", err)
|
|
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 {
|
|
writeInternalError(w, "internal", err)
|
|
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) handleBirdStatus(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
|
|
}
|
|
ctx, cancel := context.WithTimeout(r.Context(), 12*time.Second)
|
|
defer cancel()
|
|
st := birdfmt.InspectLocalBird(ctx)
|
|
writeJSON(w, http.StatusOK, st)
|
|
}
|
|
|
|
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 {
|
|
writeInternalError(w, "internal", err)
|
|
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 body struct {
|
|
SpeakerID string `json:"speaker_id"`
|
|
PublicKey string `json:"public_key"`
|
|
}
|
|
dec := json.NewDecoder(r.Body)
|
|
if err := dec.Decode(&body); err != nil && !errors.Is(err, io.EOF) {
|
|
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid JSON body")
|
|
return
|
|
}
|
|
sid := strings.TrimSpace(body.SpeakerID)
|
|
if sid == "" {
|
|
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "speaker_id is required")
|
|
return
|
|
}
|
|
sp, err := s.store.GetSpeaker(a.TenantID, sid)
|
|
if err != nil {
|
|
writeProblem(w, http.StatusNotFound, "Not Found", "speaker not found")
|
|
return
|
|
}
|
|
updates := map[string]any{
|
|
"node_enrolled_at": time.Now().UTC().Format(time.RFC3339Nano),
|
|
}
|
|
if pk := strings.TrimSpace(body.PublicKey); pk != "" {
|
|
updates["node_public_key"] = pk
|
|
}
|
|
meta, err := mergeSpeakerMetaJSON(sp.MetaJSON, updates)
|
|
if err != nil {
|
|
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "speaker meta_json must be a JSON object (or empty)")
|
|
return
|
|
}
|
|
patch := &store.SpeakerPatch{MetaJSON: &meta}
|
|
if _, err := s.store.UpdateSpeaker(a.TenantID, sid, patch); err != nil {
|
|
writeInternalError(w, "internal", err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"status": "enrolled",
|
|
"speaker_id": sp.ID,
|
|
"tenant_id": a.TenantID,
|
|
})
|
|
}
|
|
|
|
func mergeSpeakerMetaJSON(existing string, updates map[string]any) (string, error) {
|
|
existing = strings.TrimSpace(existing)
|
|
var m map[string]any
|
|
if existing != "" {
|
|
if err := json.Unmarshal([]byte(existing), &m); err != nil {
|
|
return "", err
|
|
}
|
|
if m == nil {
|
|
return "", errors.New("meta must be a JSON object")
|
|
}
|
|
}
|
|
if m == nil {
|
|
m = make(map[string]any)
|
|
}
|
|
for k, v := range updates {
|
|
m[k] = v
|
|
}
|
|
b, err := json.Marshal(m)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return string(b), nil
|
|
}
|
|
|
|
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
|
|
}
|