fix(httpapi): phase 1 compliance — ERR-01, SEC-04, CDN client, scheduler docs
Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -41,6 +41,9 @@ func main() {
|
|||||||
|
|
||||||
apiBase := strings.TrimSpace(os.Getenv("EVOBGP_CONTROL_PLANE_URL"))
|
apiBase := strings.TrimSpace(os.Getenv("EVOBGP_CONTROL_PLANE_URL"))
|
||||||
apiTok := strings.TrimSpace(os.Getenv("EVOBGP_SCHEDULER_BEARER"))
|
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{
|
deps := &scheduler.Deps{
|
||||||
Store: st,
|
Store: st,
|
||||||
HTTP: &http.Client{Timeout: 45 * time.Second},
|
HTTP: &http.Client{Timeout: 45 * time.Second},
|
||||||
|
|||||||
@@ -126,6 +126,8 @@ services:
|
|||||||
condition: service_started
|
condition: service_started
|
||||||
environment:
|
environment:
|
||||||
<<: *env-ref
|
<<: *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_CONTROL_PLANE_URL: http://evobgp-api:8080
|
||||||
EVOBGP_SCHEDULER_BEARER: dev
|
EVOBGP_SCHEDULER_BEARER: dev
|
||||||
logging: *default-logging
|
logging: *default-logging
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ import (
|
|||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"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).
|
// 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.
|
// 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) {
|
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
|
backend = mem
|
||||||
}
|
}
|
||||||
|
|
||||||
cdnHTTP := &http.Client{Timeout: 45 * time.Second}
|
cdnHTTP := NewCDNHTTPClient()
|
||||||
wk := &jobs.Worker{Store: backend, HTTPClient: cdnHTTP}
|
wk := &jobs.Worker{Store: backend, HTTPClient: cdnHTTP}
|
||||||
reg := jobs.NewRegistry(wk.Process)
|
reg := jobs.NewRegistry(wk.Process)
|
||||||
wk.Registry = reg
|
wk.Registry = reg
|
||||||
|
|||||||
@@ -2,9 +2,15 @@ package httpapi
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
internalErrorDetail = "an internal error occurred"
|
||||||
|
badGatewayDetail = "upstream request failed"
|
||||||
|
)
|
||||||
|
|
||||||
// Problem is RFC 9457 application/problem+json.
|
// Problem is RFC 9457 application/problem+json.
|
||||||
type Problem struct {
|
type Problem struct {
|
||||||
Type string `json:"type,omitempty"`
|
Type string `json:"type,omitempty"`
|
||||||
@@ -34,3 +40,19 @@ func writeJSON(w http.ResponseWriter, status int, v any) {
|
|||||||
func writeNoContent(w http.ResponseWriter) {
|
func writeNoContent(w http.ResponseWriter) {
|
||||||
w.WriteHeader(http.StatusNoContent)
|
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
@@ -83,7 +83,7 @@ func (s *Server) handleReady(w http.ResponseWriter, r *http.Request) {
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
if s.pgPool != nil {
|
if s.pgPool != nil {
|
||||||
if err := s.pgPool.Ping(ctx); err != 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})
|
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"status": "not_ready", "checks": checks})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -317,7 +317,7 @@ func (s *Server) handleGetModule(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeProblem(w, http.StatusNotFound, "Not Found", "module not found")
|
writeProblem(w, http.StatusNotFound, "Not Found", "module not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeProblem(w, http.StatusInternalServerError, "Internal Error", err.Error())
|
writeInternalError(w, "internal", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, moduleJSON(mod))
|
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")
|
writeProblem(w, http.StatusNotFound, "Not Found", "module not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeProblem(w, http.StatusInternalServerError, "Internal Error", err.Error())
|
writeInternalError(w, "internal", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
idem := r.Header.Get("Idempotency-Key")
|
idem := r.Header.Get("Idempotency-Key")
|
||||||
@@ -463,7 +463,7 @@ func (s *Server) handleModuleRefresh(w http.ResponseWriter, r *http.Request) {
|
|||||||
mid := mod.ID
|
mid := mod.ID
|
||||||
j, _, err := s.jobs.Enqueue(a.TenantID, jobs.KindModuleRefresh, idemPtr, &mid, map[string]any{"module_id": moduleID})
|
j, _, err := s.jobs.Enqueue(a.TenantID, jobs.KindModuleRefresh, idemPtr, &mid, map[string]any{"module_id": moduleID})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeProblem(w, http.StatusInternalServerError, "Internal Error", err.Error())
|
writeInternalError(w, "internal", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.Header().Set("Location", "/v1/jobs/"+j.ID)
|
w.Header().Set("Location", "/v1/jobs/"+j.ID)
|
||||||
@@ -512,7 +512,7 @@ func (s *Server) handleTenantRefresh(w http.ResponseWriter, r *http.Request) {
|
|||||||
"trigger": "api",
|
"trigger": "api",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeProblem(w, http.StatusInternalServerError, "Internal Error", err.Error())
|
writeInternalError(w, "internal", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.Header().Set("Location", "/v1/jobs/"+j.ID)
|
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")
|
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !s.requireAtLeast(w, a, "editor") {
|
if !s.requireAtLeast(w, a, "operator") {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
revID := r.PathValue("revision_id")
|
revID := r.PathValue("revision_id")
|
||||||
@@ -933,7 +933,7 @@ func (s *Server) handleRevisionRollback(w http.ResponseWriter, r *http.Request)
|
|||||||
"source_revision_id": revID,
|
"source_revision_id": revID,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeProblem(w, http.StatusInternalServerError, "Internal Error", err.Error())
|
writeInternalError(w, "internal", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.Header().Set("Location", "/v1/jobs/"+j.ID)
|
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,
|
"strategy": body.Strategy,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeProblem(w, http.StatusInternalServerError, "Internal Error", err.Error())
|
writeInternalError(w, "internal", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.Header().Set("Location", "/v1/jobs/"+j.ID)
|
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,
|
"speaker_id": spkID,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeProblem(w, http.StatusInternalServerError, "Internal Error", err.Error())
|
writeInternalError(w, "internal", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.Header().Set("Location", "/v1/jobs/"+j.ID)
|
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)
|
j, _, err := s.jobs.Enqueue(a.TenantID, jobs.KindBirdReload, idemPtr, nil, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeProblem(w, http.StatusInternalServerError, "Internal Error", err.Error())
|
writeInternalError(w, "internal", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.Header().Set("Location", "/v1/jobs/"+j.ID)
|
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)
|
tgz, err := bundle.BuildGzippedTar(rid, sid, rev.PreviewFragments, s.bundlePriv)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeProblem(w, http.StatusInternalServerError, "Internal Error", err.Error())
|
writeInternalError(w, "internal", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "application/gzip")
|
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}
|
patch := &store.SpeakerPatch{MetaJSON: &meta}
|
||||||
if _, err := s.store.UpdateSpeaker(a.TenantID, sid, patch); err != nil {
|
if _, err := s.store.UpdateSpeaker(a.TenantID, sid, patch); err != nil {
|
||||||
writeProblem(w, http.StatusInternalServerError, "Internal Error", err.Error())
|
writeInternalError(w, "internal", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ func writeStoreErr(w http.ResponseWriter, err error) {
|
|||||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", err.Error())
|
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeProblem(w, http.StatusInternalServerError, "Internal Error", err.Error())
|
writeInternalError(w, "store", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleListCDNSources(w http.ResponseWriter, r *http.Request) {
|
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")
|
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "invalid url")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
resp, err := http.DefaultClient.Do(req)
|
resp, err := s.cdnHTTP.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeProblem(w, http.StatusBadGateway, "Bad Gateway", err.Error())
|
writeBadGateway(w, "cdn preview fetch", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
_, _ = io.Copy(io.Discard, resp.Body)
|
_, _ = 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
|
return
|
||||||
}
|
}
|
||||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
|
raw, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeProblem(w, http.StatusBadGateway, "Bad Gateway", err.Error())
|
writeBadGateway(w, "cdn preview read body", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
pfxs, err := pipeline.ExtractCIDRs(string(raw), body.SourceKind, body.PrefixPath)
|
pfxs, err := pipeline.ExtractCIDRs(string(raw), body.SourceKind, body.PrefixPath)
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ type Server struct {
|
|||||||
apiKeys []apiKeyRecord
|
apiKeys []apiKeyRecord
|
||||||
insecureDev bool
|
insecureDev bool
|
||||||
corsOrigins []string
|
corsOrigins []string
|
||||||
|
cdnHTTP *http.Client
|
||||||
mux *http.ServeMux
|
mux *http.ServeMux
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,6 +68,7 @@ func New(opts Options) (*Server, error) {
|
|||||||
apiKeys: parseAPIKeysSpec(opts.APIKeys),
|
apiKeys: parseAPIKeysSpec(opts.APIKeys),
|
||||||
insecureDev: opts.InsecureDev && opts.SeedDemo,
|
insecureDev: opts.InsecureDev && opts.SeedDemo,
|
||||||
corsOrigins: parseCORSOrigins(opts.CORSAllowedOrigins),
|
corsOrigins: parseCORSOrigins(opts.CORSAllowedOrigins),
|
||||||
|
cdnHTTP: NewCDNHTTPClient(),
|
||||||
}
|
}
|
||||||
s.mux = http.NewServeMux()
|
s.mux = http.NewServeMux()
|
||||||
s.registerRoutes()
|
s.registerRoutes()
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
|
|||||||
}
|
}
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
tenant, modCDN, modIP, rev, speaker := srv.Store().DemoIDs()
|
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())
|
ts := httptest.NewServer(srv.Handler())
|
||||||
defer ts.Close()
|
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) {
|
t.Run("rollback queues job", func(t *testing.T) {
|
||||||
req, _ := http.NewRequest(http.MethodPost, base+"/v1/revisions/"+rev+"/rollback", nil)
|
req, _ := http.NewRequest(http.MethodPost, base+"/v1/revisions/"+rev+"/rollback", nil)
|
||||||
req.Header.Set("Authorization", "Bearer opkey")
|
req.Header.Set("Authorization", "Bearer opkey")
|
||||||
|
|||||||
Reference in New Issue
Block a user