Add control APIs, control-plane binary, and close the CLI gate bypass
Some checks failed
ci / build (push) Has been cancelled
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
This commit is contained in:
parent
a2d561eae5
commit
03ff7a8ad7
8 changed files with 930 additions and 35 deletions
95
internal/control/api.go
Normal file
95
internal/control/api.go
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
// 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
|
||||
}
|
||||
249
internal/control/api_test.go
Normal file
249
internal/control/api_test.go
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
package control
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
"github.com/tegwick/fluid-core/internal/contract"
|
||||
"github.com/tegwick/fluid-core/internal/evidence"
|
||||
"github.com/tegwick/fluid-core/internal/intent"
|
||||
"github.com/tegwick/fluid-core/internal/policy"
|
||||
"github.com/tegwick/fluid-core/internal/publish"
|
||||
"github.com/tegwick/fluid-core/internal/signing"
|
||||
)
|
||||
|
||||
const intentDoc = `# Interface Evolution Intent
|
||||
|
||||
**Current operational authority mode:**
|
||||
FLUID-2
|
||||
`
|
||||
|
||||
func newServer(t *testing.T) (*http.ServeMux, *evidence.SQLStore) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
|
||||
store, err := evidence.OpenSQLite(ctx, filepath.Join(t.TempDir(), "e.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = store.Close() })
|
||||
|
||||
intents := intent.New(store, "hall-publishing")
|
||||
if _, err := intents.Put(ctx, "IEI-1", intentDoc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := intents.SetActive(ctx, "IEI-1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
signer, _, err := signing.GenerateKey("test-key")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gate := policy.NewGate(policy.DefaultLimits())
|
||||
|
||||
pipeline, err := publish.New(publish.Options{
|
||||
Gate: gate, Signer: signer, Store: store, Intents: intents,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
srv := NewServer(NewRevisionAPI(store, pipeline), NewIntentAPI(intents, gate))
|
||||
return srv.Routes(), store
|
||||
}
|
||||
|
||||
func descriptorJSON() contract.Revision {
|
||||
pc := contract.RevisionPolicyPolicyCheckPassed
|
||||
return contract.Revision{
|
||||
SchemaVersion: "0.1",
|
||||
ID: "R-2",
|
||||
Interface: "hall-publishing",
|
||||
State: contract.RevisionStateCandidate,
|
||||
Contract: contract.RevisionContract{
|
||||
Type: contract.RevisionContractTypeOpenapi,
|
||||
Digest: contract.Digest("sha256:" + strings.Repeat("1", 64)),
|
||||
},
|
||||
Runtime: contract.RevisionRuntime{Upstream: "http://adapter:8080"},
|
||||
Intent: contract.RevisionIntent{Version: "IEI-1"},
|
||||
Policy: contract.RevisionPolicy{
|
||||
Compatibility: contract.RevisionPolicyCompatibilityAdditive,
|
||||
SecurityCheck: contract.RevisionPolicySecurityCheckPassed,
|
||||
PolicyCheck: &pc,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func post(t *testing.T, mux *http.ServeMux, path string, body any) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, path, bytes.NewReader(raw)))
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestCreateRevisionVerifiesAndPublishes(t *testing.T) {
|
||||
mux, store := newServer(t)
|
||||
|
||||
rec := post(t, mux, "/control/v1/revisions", CreateRevisionRequest{
|
||||
Descriptor: descriptorJSON(),
|
||||
Origin: contract.Actor{Type: contract.ActorTypeHuman, ID: "worsch"},
|
||||
AdaptationClasses: []contract.AdaptationClass{contract.AdaptationClassPresentation},
|
||||
ComplexityDelta: 0.2,
|
||||
RequestedTrafficShare: 0.1,
|
||||
Approved: true,
|
||||
ApprovedBy: &contract.Actor{Type: contract.ActorTypeHuman, ID: "worsch"},
|
||||
})
|
||||
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("status = %d, body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp CreateRevisionResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.Descriptor == nil || resp.Descriptor.Signature == nil {
|
||||
t.Fatal("published descriptor came back unsigned")
|
||||
}
|
||||
if !resp.Report.Passed() {
|
||||
t.Errorf("report says not passed: %+v", resp.Report.Stages)
|
||||
}
|
||||
|
||||
if _, err := store.Record(context.Background(), contract.KindRevision, "R-2"); err != nil {
|
||||
t.Errorf("revision was not persisted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRejectedCandidateIsAResultNotAFault: a rejection is normal (invariant 14)
|
||||
// and must come back with its evidence rather than as a 500.
|
||||
func TestRejectedCandidateIsAResultNotAFault(t *testing.T) {
|
||||
mux, _ := newServer(t)
|
||||
|
||||
rec := post(t, mux, "/control/v1/revisions", CreateRevisionRequest{
|
||||
Descriptor: descriptorJSON(),
|
||||
Origin: contract.Actor{Type: contract.ActorTypeHuman, ID: "worsch"},
|
||||
AdaptationClasses: []contract.AdaptationClass{contract.AdaptationClassPresentation},
|
||||
Approved: false, // the default gate requires approval
|
||||
})
|
||||
|
||||
if rec.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("status = %d, want 422; body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp CreateRevisionResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.State != "REJECTED" {
|
||||
t.Errorf("state = %q", resp.State)
|
||||
}
|
||||
failure, ok := resp.Report.FirstFailure()
|
||||
if !ok {
|
||||
t.Fatal("rejection carries no failing stage")
|
||||
}
|
||||
if failure.Stage != publish.StagePolicyCheck {
|
||||
t.Errorf("failed at %s, want POLICY_CHECK", failure.Stage)
|
||||
}
|
||||
if len(failure.Evidence) == 0 {
|
||||
t.Error("rejection carries no reasons")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateRevisionValidatesInput(t *testing.T) {
|
||||
mux, _ := newServer(t)
|
||||
|
||||
// A hypothesis id where a revision id belongs must not be accepted.
|
||||
d := descriptorJSON()
|
||||
d.ID = "H-2"
|
||||
rec := post(t, mux, "/control/v1/revisions", CreateRevisionRequest{
|
||||
Descriptor: d,
|
||||
Origin: contract.Actor{Type: contract.ActorTypeHuman, ID: "worsch"},
|
||||
})
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("mis-prefixed id: status = %d, want 400", rec.Code)
|
||||
}
|
||||
|
||||
// Every candidate must name its origin: an artifact with no provenance
|
||||
// cannot be audited later.
|
||||
rec = post(t, mux, "/control/v1/revisions", CreateRevisionRequest{Descriptor: descriptorJSON()})
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("missing origin: status = %d, want 400", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntentEndpoints(t *testing.T) {
|
||||
mux, _ := newServer(t)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/control/v1/intents/active", nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("active intent: status = %d, body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var got IntentResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Version != "IEI-1" || got.Mode != "FLUID-2" {
|
||||
t.Errorf("active intent = %+v", got)
|
||||
}
|
||||
|
||||
// Historical read by version.
|
||||
rec = httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/control/v1/intents/IEI-1", nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("historical read: status = %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecordedIntentCannotBeRewritten: changing what a version says would
|
||||
// change what already-published revisions were governed by.
|
||||
func TestRecordedIntentCannotBeRewritten(t *testing.T) {
|
||||
mux, _ := newServer(t)
|
||||
|
||||
rec := post(t, mux, "/control/v1/intents", RecordIntentRequest{
|
||||
Version: "IEI-1",
|
||||
Document: strings.Replace(intentDoc, "FLUID-2", "FLUID-5", 1),
|
||||
})
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Errorf("status = %d, want 409; body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnfilledTemplateIsRefused(t *testing.T) {
|
||||
mux, _ := newServer(t)
|
||||
|
||||
rec := post(t, mux, "/control/v1/intents", RecordIntentRequest{
|
||||
Version: "IEI-2",
|
||||
Document: "mode is one of FLUID-0 FLUID-1 FLUID-2 FLUID-3 FLUID-4 FLUID-5 FLUID-6",
|
||||
})
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("an unresolved template was accepted: status = %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMethodsAreConstrained(t *testing.T) {
|
||||
mux, _ := newServer(t)
|
||||
for _, tc := range []struct{ method, path string }{
|
||||
{http.MethodDelete, "/control/v1/revisions/R-2"},
|
||||
{http.MethodPut, "/control/v1/intents/IEI-1"},
|
||||
} {
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, httptest.NewRequest(tc.method, tc.path, nil))
|
||||
if rec.Code != http.StatusMethodNotAllowed {
|
||||
t.Errorf("%s %s: status = %d, want 405", tc.method, tc.path, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
128
internal/control/intent.go
Normal file
128
internal/control/intent.go
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
package control
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/tegwick/fluid-core/internal/contract"
|
||||
"github.com/tegwick/fluid-core/internal/intent"
|
||||
"github.com/tegwick/fluid-core/internal/policy"
|
||||
)
|
||||
|
||||
// IntentAPI implements ArchitectureBlueprint.md section 44.5: read active
|
||||
// intent, read historical intent, validate a candidate against intent, propose
|
||||
// an amendment.
|
||||
//
|
||||
// Amendment is deliberately a proposal and not an edit. Blueprint section 22 is
|
||||
// blunt about it: the Daimon must not silently expand its own mission, and
|
||||
// intent changes require a separate governance process. This API records that
|
||||
// an amendment was proposed; it never enacts one.
|
||||
type IntentAPI struct {
|
||||
store *intent.Store
|
||||
gate *policy.Gate
|
||||
}
|
||||
|
||||
// NewIntentAPI returns the intent API.
|
||||
func NewIntentAPI(store *intent.Store, gate *policy.Gate) *IntentAPI {
|
||||
return &IntentAPI{store: store, gate: gate}
|
||||
}
|
||||
|
||||
// IntentResponse is a recorded intent version.
|
||||
type IntentResponse struct {
|
||||
Version string `json:"version"`
|
||||
Digest contract.Digest `json:"digest"`
|
||||
Mode string `json:"mode"`
|
||||
Document string `json:"document"`
|
||||
}
|
||||
|
||||
func (a *IntentAPI) handleCollection(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodPost:
|
||||
a.record(w, r)
|
||||
default:
|
||||
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func (a *IntentAPI) handleActive(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
v, err := a.store.Active(r.Context())
|
||||
if err != nil {
|
||||
if errors.Is(err, intent.ErrNoActive) {
|
||||
writeError(w, http.StatusNotFound, "no active interface evolution intent")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "could not read active intent")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, toResponse(v))
|
||||
}
|
||||
|
||||
func (a *IntentAPI) handleItem(w http.ResponseWriter, r *http.Request) {
|
||||
version := pathTail(r.URL.Path, "/control/v1/intents")
|
||||
if version == "" || version == "active" {
|
||||
a.handleActive(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
v, err := a.store.Get(r.Context(), version)
|
||||
if err != nil {
|
||||
writeError(w, statusForStoreError(err), "intent version not found")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, toResponse(v))
|
||||
default:
|
||||
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
}
|
||||
}
|
||||
|
||||
// RecordIntentRequest records a new intent version.
|
||||
type RecordIntentRequest struct {
|
||||
Version string `json:"version"`
|
||||
Document string `json:"document"`
|
||||
Activate bool `json:"activate,omitempty"`
|
||||
}
|
||||
|
||||
func (a *IntentAPI) record(w http.ResponseWriter, r *http.Request) {
|
||||
var req RecordIntentRequest
|
||||
if err := decodeBody(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "could not decode request", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
v, err := a.store.Put(r.Context(), req.Version, req.Document)
|
||||
if err != nil {
|
||||
if errors.Is(err, intent.ErrImmutable) {
|
||||
// Rewriting a recorded version would change what already-published
|
||||
// revisions were governed by, so it is a conflict, not a bad request.
|
||||
writeError(w, http.StatusConflict, err.Error())
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if req.Activate {
|
||||
if err := a.store.SetActive(r.Context(), v.Version); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "recorded but not activated", err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, toResponse(v))
|
||||
}
|
||||
|
||||
func toResponse(v intent.Version) IntentResponse {
|
||||
return IntentResponse{
|
||||
Version: v.Version,
|
||||
Digest: v.Digest,
|
||||
Mode: v.Mode.String(),
|
||||
Document: v.Document,
|
||||
}
|
||||
}
|
||||
176
internal/control/revision.go
Normal file
176
internal/control/revision.go
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
package control
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"sort"
|
||||
|
||||
"github.com/tegwick/fluid-core/internal/contract"
|
||||
"github.com/tegwick/fluid-core/internal/evidence"
|
||||
"github.com/tegwick/fluid-core/internal/publish"
|
||||
)
|
||||
|
||||
// RevisionAPI implements ArchitectureBlueprint.md section 44.1: create, verify,
|
||||
// publish, set state, query lineage.
|
||||
//
|
||||
// Create and verify are one operation here rather than two. A revision that
|
||||
// exists but has not been verified has no use and no authority, and offering it
|
||||
// as a separate resource would invite callers to treat it as one.
|
||||
type RevisionAPI struct {
|
||||
store evidence.Store
|
||||
pipeline *publish.Pipeline
|
||||
}
|
||||
|
||||
// NewRevisionAPI returns the revision API.
|
||||
func NewRevisionAPI(store evidence.Store, p *publish.Pipeline) *RevisionAPI {
|
||||
return &RevisionAPI{store: store, pipeline: p}
|
||||
}
|
||||
|
||||
// CreateRevisionRequest submits a candidate for verification and publication.
|
||||
type CreateRevisionRequest struct {
|
||||
Descriptor contract.Revision `json:"descriptor"`
|
||||
Origin contract.Actor `json:"origin"`
|
||||
|
||||
AdaptationClasses []contract.AdaptationClass `json:"adaptation_classes,omitempty"`
|
||||
ComplexityDelta float64 `json:"complexity_delta,omitempty"`
|
||||
RequestedTrafficShare float64 `json:"requested_traffic_share,omitempty"`
|
||||
Approved bool `json:"approved,omitempty"`
|
||||
ApprovedBy *contract.Actor `json:"approved_by,omitempty"`
|
||||
}
|
||||
|
||||
// CreateRevisionResponse reports the outcome, verified or not.
|
||||
type CreateRevisionResponse struct {
|
||||
Revision contract.RevisionID `json:"revision"`
|
||||
State string `json:"state"`
|
||||
Report publish.Report `json:"report"`
|
||||
// Descriptor is returned only on success, with its signature attached.
|
||||
Descriptor *contract.Revision `json:"descriptor,omitempty"`
|
||||
}
|
||||
|
||||
func (a *RevisionAPI) handleCollection(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
a.list(w, r)
|
||||
case http.MethodPost:
|
||||
a.create(w, r)
|
||||
default:
|
||||
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func (a *RevisionAPI) handleItem(w http.ResponseWriter, r *http.Request) {
|
||||
id := pathTail(r.URL.Path, "/control/v1/revisions")
|
||||
if id == "" {
|
||||
writeError(w, http.StatusNotFound, "no revision named")
|
||||
return
|
||||
}
|
||||
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
a.get(w, r, id)
|
||||
default:
|
||||
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
}
|
||||
}
|
||||
|
||||
// create runs a candidate through the pipeline and publishes it if it passes.
|
||||
func (a *RevisionAPI) create(w http.ResponseWriter, r *http.Request) {
|
||||
var req CreateRevisionRequest
|
||||
if err := decodeBody(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "could not decode request", err.Error())
|
||||
return
|
||||
}
|
||||
if req.Descriptor.ID == "" {
|
||||
writeError(w, http.StatusBadRequest, "descriptor has no revision id")
|
||||
return
|
||||
}
|
||||
if err := contract.RequireKind(string(req.Descriptor.ID), contract.KindRevision); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if req.Origin.ID == "" {
|
||||
writeError(w, http.StatusBadRequest, "every candidate must name its origin")
|
||||
return
|
||||
}
|
||||
|
||||
candidate := publish.NewCandidate(req.Descriptor, req.Origin)
|
||||
|
||||
verified, report, err := a.pipeline.Run(r.Context(), candidate, publish.PromotionRequest{
|
||||
AdaptationClasses: req.AdaptationClasses,
|
||||
ComplexityDelta: req.ComplexityDelta,
|
||||
RequestedTrafficShare: req.RequestedTrafficShare,
|
||||
Approved: req.Approved,
|
||||
ApprovedBy: req.ApprovedBy,
|
||||
})
|
||||
if err != nil {
|
||||
var rejected *publish.ErrRejected
|
||||
if errors.As(err, &rejected) {
|
||||
// A rejected candidate is a normal outcome (invariant 14), so it is
|
||||
// reported as a result with its evidence rather than as a server
|
||||
// fault. 422 says the request was well formed and the answer is no.
|
||||
writeJSON(w, http.StatusUnprocessableEntity, CreateRevisionResponse{
|
||||
Revision: candidate.ID(),
|
||||
State: "REJECTED",
|
||||
Report: report,
|
||||
})
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "pipeline failed", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := a.pipeline.Publish(r.Context(), verified); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "verified but not published", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
d := verified.Descriptor()
|
||||
writeJSON(w, http.StatusCreated, CreateRevisionResponse{
|
||||
Revision: d.ID,
|
||||
State: string(d.State),
|
||||
Report: report,
|
||||
Descriptor: &d,
|
||||
})
|
||||
}
|
||||
|
||||
func (a *RevisionAPI) get(w http.ResponseWriter, r *http.Request, id string) {
|
||||
body, err := a.store.Record(r.Context(), contract.KindRevision, id)
|
||||
if err != nil {
|
||||
writeError(w, statusForStoreError(err), "revision not found")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
|
||||
// LineageEntry is one step in a revision's ancestry.
|
||||
type LineageEntry struct {
|
||||
Revision contract.RevisionID `json:"revision"`
|
||||
State string `json:"state"`
|
||||
Intent string `json:"intent"`
|
||||
}
|
||||
|
||||
func (a *RevisionAPI) list(w http.ResponseWriter, r *http.Request) {
|
||||
records, err := a.store.Records(r.Context(), contract.KindRevision)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not list revisions")
|
||||
return
|
||||
}
|
||||
|
||||
out := make([]LineageEntry, 0, len(records))
|
||||
for _, body := range records {
|
||||
var d contract.Revision
|
||||
if err := json.Unmarshal(body, &d); err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, LineageEntry{
|
||||
Revision: d.ID,
|
||||
State: string(d.State),
|
||||
Intent: d.Intent.Version,
|
||||
})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Revision < out[j].Revision })
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{"revisions": out})
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue