fix(httpapi): phase 1 compliance — ERR-01, SEC-04, CDN client, scheduler docs

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-05-20 00:21:16 +07:00
co-authored by Cursor
parent aff27e8f7b
commit 1dd713514c
8 changed files with 67 additions and 19 deletions
+3
View File
@@ -41,6 +41,9 @@ func main() {
apiBase := strings.TrimSpace(os.Getenv("EVOBGP_CONTROL_PLANE_URL"))
apiTok := strings.TrimSpace(os.Getenv("EVOBGP_SCHEDULER_BEARER"))
if os.Getenv("EVOBGP_SCHEDULER_STANDALONE") == "1" && apiBase == "" {
log.Fatal("evobgp-scheduler: EVOBGP_SCHEDULER_STANDALONE=1 requires EVOBGP_CONTROL_PLANE_URL and EVOBGP_SCHEDULER_BEARER (split deploy must not use in-process jobs.Registry)")
}
deps := &scheduler.Deps{
Store: st,
HTTP: &http.Client{Timeout: 45 * time.Second},
+2
View File
@@ -126,6 +126,8 @@ services:
condition: service_started
environment:
<<: *env-ref
# Split deploy: scheduler must enqueue jobs via API, not in-process Registry (ARCH-04).
EVOBGP_SCHEDULER_STANDALONE: "1"
EVOBGP_CONTROL_PLANE_URL: http://evobgp-api:8080
EVOBGP_SCHEDULER_BEARER: dev
logging: *default-logging
+6 -1
View File
@@ -15,6 +15,11 @@ import (
"github.com/jackc/pgx/v5/pgxpool"
)
// NewCDNHTTPClient returns the shared HTTP client for CDN and preview fetches (PERF-02 / ERR-03).
func NewCDNHTTPClient() *http.Client {
return &http.Client{Timeout: 45 * time.Second}
}
// BootstrapWorkers opens the same store.Backend and jobs.Registry as New (without HTTP or bundle keys).
// Used by standalone worker binaries (scheduler, ingest, …) that share PostgreSQL with the API.
func BootstrapWorkers(ctx context.Context, opts Options) (store.Backend, *jobs.Registry, *pgxpool.Pool, error) {
@@ -44,7 +49,7 @@ func BootstrapWorkers(ctx context.Context, opts Options) (store.Backend, *jobs.R
backend = mem
}
cdnHTTP := &http.Client{Timeout: 45 * time.Second}
cdnHTTP := NewCDNHTTPClient()
wk := &jobs.Worker{Store: backend, HTTPClient: cdnHTTP}
reg := jobs.NewRegistry(wk.Process)
wk.Registry = reg
+22
View File
@@ -2,9 +2,15 @@ package httpapi
import (
"encoding/json"
"log"
"net/http"
)
const (
internalErrorDetail = "an internal error occurred"
badGatewayDetail = "upstream request failed"
)
// Problem is RFC 9457 application/problem+json.
type Problem struct {
Type string `json:"type,omitempty"`
@@ -34,3 +40,19 @@ func writeJSON(w http.ResponseWriter, status int, v any) {
func writeNoContent(w http.ResponseWriter) {
w.WriteHeader(http.StatusNoContent)
}
// writeInternalError logs err server-side and returns a generic 500 problem (ERR-01).
func writeInternalError(w http.ResponseWriter, operation string, err error) {
if err != nil {
log.Printf("httpapi: %s: %v", operation, err)
}
writeProblem(w, http.StatusInternalServerError, "Internal Error", internalErrorDetail)
}
// writeBadGateway logs err server-side and returns a generic 502 problem (ERR-01).
func writeBadGateway(w http.ResponseWriter, operation string, err error) {
if err != nil {
log.Printf("httpapi: %s: %v", operation, err)
}
writeProblem(w, http.StatusBadGateway, "Bad Gateway", badGatewayDetail)
}
+12 -12
View File
@@ -83,7 +83,7 @@ func (s *Server) handleReady(w http.ResponseWriter, r *http.Request) {
defer cancel()
if s.pgPool != nil {
if err := s.pgPool.Ping(ctx); err != nil {
checks["postgres"] = err.Error()
checks["postgres"] = "unavailable"
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"status": "not_ready", "checks": checks})
return
}
@@ -317,7 +317,7 @@ func (s *Server) handleGetModule(w http.ResponseWriter, r *http.Request) {
writeProblem(w, http.StatusNotFound, "Not Found", "module not found")
return
}
writeProblem(w, http.StatusInternalServerError, "Internal Error", err.Error())
writeInternalError(w, "internal", err)
return
}
writeJSON(w, http.StatusOK, moduleJSON(mod))
@@ -451,7 +451,7 @@ func (s *Server) handleModuleRefresh(w http.ResponseWriter, r *http.Request) {
writeProblem(w, http.StatusNotFound, "Not Found", "module not found")
return
}
writeProblem(w, http.StatusInternalServerError, "Internal Error", err.Error())
writeInternalError(w, "internal", err)
return
}
idem := r.Header.Get("Idempotency-Key")
@@ -463,7 +463,7 @@ func (s *Server) handleModuleRefresh(w http.ResponseWriter, r *http.Request) {
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())
writeInternalError(w, "internal", err)
return
}
w.Header().Set("Location", "/v1/jobs/"+j.ID)
@@ -512,7 +512,7 @@ func (s *Server) handleTenantRefresh(w http.ResponseWriter, r *http.Request) {
"trigger": "api",
})
if err != nil {
writeProblem(w, http.StatusInternalServerError, "Internal Error", err.Error())
writeInternalError(w, "internal", err)
return
}
w.Header().Set("Location", "/v1/jobs/"+j.ID)
@@ -916,7 +916,7 @@ func (s *Server) handleRevisionRollback(w http.ResponseWriter, r *http.Request)
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "editor") {
if !s.requireAtLeast(w, a, "operator") {
return
}
revID := r.PathValue("revision_id")
@@ -933,7 +933,7 @@ func (s *Server) handleRevisionRollback(w http.ResponseWriter, r *http.Request)
"source_revision_id": revID,
})
if err != nil {
writeProblem(w, http.StatusInternalServerError, "Internal Error", err.Error())
writeInternalError(w, "internal", err)
return
}
w.Header().Set("Location", "/v1/jobs/"+j.ID)
@@ -980,7 +980,7 @@ func (s *Server) handleApply(w http.ResponseWriter, r *http.Request) {
"strategy": body.Strategy,
})
if err != nil {
writeProblem(w, http.StatusInternalServerError, "Internal Error", err.Error())
writeInternalError(w, "internal", err)
return
}
w.Header().Set("Location", "/v1/jobs/"+j.ID)
@@ -1031,7 +1031,7 @@ func (s *Server) handleSpeakerApply(w http.ResponseWriter, r *http.Request) {
"speaker_id": spkID,
})
if err != nil {
writeProblem(w, http.StatusInternalServerError, "Internal Error", err.Error())
writeInternalError(w, "internal", err)
return
}
w.Header().Set("Location", "/v1/jobs/"+j.ID)
@@ -1056,7 +1056,7 @@ func (s *Server) handleBirdReload(w http.ResponseWriter, r *http.Request) {
}
j, _, err := s.jobs.Enqueue(a.TenantID, jobs.KindBirdReload, idemPtr, nil, nil)
if err != nil {
writeProblem(w, http.StatusInternalServerError, "Internal Error", err.Error())
writeInternalError(w, "internal", err)
return
}
w.Header().Set("Location", "/v1/jobs/"+j.ID)
@@ -1191,7 +1191,7 @@ func (s *Server) handleNodeBundle(w http.ResponseWriter, r *http.Request) {
}
tgz, err := bundle.BuildGzippedTar(rid, sid, rev.PreviewFragments, s.bundlePriv)
if err != nil {
writeProblem(w, http.StatusInternalServerError, "Internal Error", err.Error())
writeInternalError(w, "internal", err)
return
}
w.Header().Set("Content-Type", "application/gzip")
@@ -1241,7 +1241,7 @@ func (s *Server) handleNodeEnroll(w http.ResponseWriter, r *http.Request) {
}
patch := &store.SpeakerPatch{MetaJSON: &meta}
if _, err := s.store.UpdateSpeaker(a.TenantID, sid, patch); err != nil {
writeProblem(w, http.StatusInternalServerError, "Internal Error", err.Error())
writeInternalError(w, "internal", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{
+5 -5
View File
@@ -178,7 +178,7 @@ func writeStoreErr(w http.ResponseWriter, err error) {
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", err.Error())
return
}
writeProblem(w, http.StatusInternalServerError, "Internal Error", err.Error())
writeInternalError(w, "store", err)
}
func (s *Server) handleListCDNSources(w http.ResponseWriter, r *http.Request) {
@@ -251,20 +251,20 @@ func (s *Server) handlePreviewCDNSource(w http.ResponseWriter, r *http.Request)
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "invalid url")
return
}
resp, err := http.DefaultClient.Do(req)
resp, err := s.cdnHTTP.Do(req)
if err != nil {
writeProblem(w, http.StatusBadGateway, "Bad Gateway", err.Error())
writeBadGateway(w, "cdn preview fetch", err)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
_, _ = io.Copy(io.Discard, resp.Body)
writeProblem(w, http.StatusBadGateway, "Bad Gateway", fmt.Sprintf("upstream status: %s", resp.Status))
writeBadGateway(w, "cdn preview fetch", fmt.Errorf("upstream status: %s", resp.Status))
return
}
raw, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
if err != nil {
writeProblem(w, http.StatusBadGateway, "Bad Gateway", err.Error())
writeBadGateway(w, "cdn preview read body", err)
return
}
pfxs, err := pipeline.ExtractCIDRs(string(raw), body.SourceKind, body.PrefixPath)
+2
View File
@@ -24,6 +24,7 @@ type Server struct {
apiKeys []apiKeyRecord
insecureDev bool
corsOrigins []string
cdnHTTP *http.Client
mux *http.ServeMux
}
@@ -67,6 +68,7 @@ func New(opts Options) (*Server, error) {
apiKeys: parseAPIKeysSpec(opts.APIKeys),
insecureDev: opts.InsecureDev && opts.SeedDemo,
corsOrigins: parseCORSOrigins(opts.CORSAllowedOrigins),
cdnHTTP: NewCDNHTTPClient(),
}
s.mux = http.NewServeMux()
s.registerRoutes()
+15 -1
View File
@@ -28,7 +28,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
}
defer srv.Close()
tenant, modCDN, modIP, rev, speaker := srv.Store().DemoIDs()
srv.apiKeys = parseAPIKeysSpec("nodekey|" + tenant + "|node,opkey|" + tenant + "|operator")
srv.apiKeys = parseAPIKeysSpec("nodekey|" + tenant + "|node,opkey|" + tenant + "|operator,edkey|" + tenant + "|editor")
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
@@ -247,6 +247,20 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
}
})
t.Run("rollback forbidden for editor", func(t *testing.T) {
req, _ := http.NewRequest(http.MethodPost, base+"/v1/revisions/"+rev+"/rollback", nil)
req.Header.Set("Authorization", "Bearer edkey")
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d want 403: %s", resp.StatusCode, b)
}
})
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")