Some checks failed
ci / build (push) Has been cancelled
Completes FLUID-WP-0004. The Revision and Intent APIs (Blueprint 44.1 and 44.5) are served by fluid-control, which is deliberately off the request path and must never be reachable by interface consumers: it is the mechanism that evolves the interface in response to their behaviour. Intent amendment is a proposal, never an edit. Rewriting a recorded version returns 409, because changing what a version says would change what already-published revisions were governed by. A rejected candidate comes back as 422 with its full stage report rather than as a server fault. Rejection is a normal outcome (invariant 14) and the reasons are the evidence a later hypothesis needs. Also closes a real hole this workplan opened: `fluid revision publish` previously wrote straight into the evidence store, which was a way around the deterministic policy gate for anyone with shell access. It now runs the same pipeline the control plane does and requires a signing key. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014KmVxhJ35tCo7rE7UnLwWu Assistant: claude-code Assistant-Model: opus Assistant-Process: 1116572@bnt-lap001 Assistant-Session: 8ba9bb93-a72a-4883-b189-2499cce5c400
95 lines
2.8 KiB
Go
95 lines
2.8 KiB
Go
// 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
|
|
}
|
|
|
|
// NewServer wires the control APIs.
|
|
func NewServer(rev *RevisionAPI, in *IntentAPI) *Server {
|
|
return &Server{revisions: rev, intents: in}
|
|
}
|
|
|
|
// 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)
|
|
|
|
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
|
|
}
|