// Package control implements the FLUID control-plane APIs. // // ArchitectureBlueprint.md section 44 asks for a small set of internal control // APIs. They are internal on purpose: this is the surface that publishes // revisions and records governance decisions, and it must never be reachable by // the consumers whose behaviour it evolves in response to. // // Nothing here is on the request path. The data plane keeps serving when this // server is down (invariant 2), which is also why the CLI reads the evidence // store directly rather than through these endpoints. package control import ( "encoding/json" "errors" "net/http" "strings" "github.com/tegwick/fluid-core/internal/evidence" ) // Server exposes the control APIs over HTTP. type Server struct { revisions *RevisionAPI intents *IntentAPI pressure *PressureAPI science *ScienceAPI } // NewServer wires the control APIs. The pressure API may be nil where an // interface runs without an observation plane. func NewServer(rev *RevisionAPI, in *IntentAPI, p *PressureAPI, sci *ScienceAPI) *Server { return &Server{revisions: rev, intents: in, pressure: p, science: sci} } // Routes returns the control-plane mux. func (s *Server) Routes() *http.ServeMux { mux := http.NewServeMux() mux.HandleFunc("/control/v1/revisions", s.revisions.handleCollection) mux.HandleFunc("/control/v1/revisions/", s.revisions.handleItem) mux.HandleFunc("/control/v1/intents", s.intents.handleCollection) mux.HandleFunc("/control/v1/intents/", s.intents.handleItem) mux.HandleFunc("/control/v1/intents/active", s.intents.handleActive) if s.pressure != nil { mux.HandleFunc("/control/v1/pressure", s.pressure.handleCollection) mux.HandleFunc("/control/v1/pressure/", s.pressure.handleItem) mux.HandleFunc("/control/v1/telemetry", s.pressure.handleTelemetry) // Consumer-reachable, unlike the rest of this surface. mux.HandleFunc("/v1/feedback", s.pressure.handleFeedback) } if s.science != nil { mux.HandleFunc("/control/v1/hypotheses", s.science.handleHypotheses) mux.HandleFunc("/control/v1/hypotheses/", s.science.handleHypothesisItem) mux.HandleFunc("/control/v1/competitions", s.science.handleCompetition) mux.HandleFunc("/control/v1/experiments", s.science.handleExperiments) mux.HandleFunc("/control/v1/experiments/", s.science.handleExperimentItem) } mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) }) return mux } // apiError is the control-plane error shape. type apiError struct { Error string `json:"error"` Detail string `json:"detail,omitempty"` Causes []string `json:"causes,omitempty"` } func writeJSON(w http.ResponseWriter, status int, body any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(body) } func writeError(w http.ResponseWriter, status int, msg string, causes ...string) { writeJSON(w, status, apiError{Error: msg, Causes: causes}) } // statusForStoreError maps store failures onto HTTP without leaking detail. func statusForStoreError(err error) int { if errors.Is(err, evidence.ErrNotFound) { return http.StatusNotFound } return http.StatusInternalServerError } // pathTail returns the segment after prefix, or "" when there is none. func pathTail(path, prefix string) string { rest := strings.TrimPrefix(path, prefix) rest = strings.Trim(rest, "/") if rest == "" { return "" } if i := strings.Index(rest, "/"); i >= 0 { return rest[:i] } return rest } func decodeBody(r *http.Request, into any) error { dec := json.NewDecoder(http.MaxBytesReader(nil, r.Body, 1<<20)) dec.DisallowUnknownFields() if err := dec.Decode(into); err != nil { return err } return nil }