352 lines
11 KiB
Go
352 lines
11 KiB
Go
|
|
package control
|
||
|
|
|
||
|
|
import (
|
||
|
|
"errors"
|
||
|
|
"net/http"
|
||
|
|
|
||
|
|
"github.com/tegwick/fluid-core/internal/contract"
|
||
|
|
"github.com/tegwick/fluid-core/internal/science"
|
||
|
|
)
|
||
|
|
|
||
|
|
// ScienceAPI implements ArchitectureBlueprint.md sections 44.3 and 44.4: the
|
||
|
|
// hypothesis and experiment control surfaces.
|
||
|
|
//
|
||
|
|
// It is one type rather than two because the operations are entangled —
|
||
|
|
// starting an experiment moves its hypotheses, finalizing one moves them back —
|
||
|
|
// and splitting them would mean two handlers reaching into the same lifecycle.
|
||
|
|
type ScienceAPI struct {
|
||
|
|
hypotheses *science.HypothesisStore
|
||
|
|
experiments *science.ExperimentController
|
||
|
|
}
|
||
|
|
|
||
|
|
// NewScienceAPI returns the hypothesis and experiment APIs.
|
||
|
|
func NewScienceAPI(h *science.HypothesisStore, e *science.ExperimentController) *ScienceAPI {
|
||
|
|
return &ScienceAPI{hypotheses: h, experiments: e}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------- hypotheses ----------
|
||
|
|
|
||
|
|
func (a *ScienceAPI) handleHypotheses(w http.ResponseWriter, r *http.Request) {
|
||
|
|
switch r.Method {
|
||
|
|
case http.MethodGet:
|
||
|
|
state := contract.FluidHypothesisState(r.URL.Query().Get("state"))
|
||
|
|
if state != "" && !state.Valid() {
|
||
|
|
writeError(w, http.StatusBadRequest, "unknown state filter")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
list, err := a.hypotheses.List(r.Context(), state)
|
||
|
|
if err != nil {
|
||
|
|
writeError(w, http.StatusInternalServerError, "could not list hypotheses")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
writeJSON(w, http.StatusOK, map[string]any{"hypotheses": list})
|
||
|
|
|
||
|
|
case http.MethodPost:
|
||
|
|
var req struct {
|
||
|
|
Hypothesis contract.FluidHypothesis `json:"hypothesis"`
|
||
|
|
Actor contract.Actor `json:"actor"`
|
||
|
|
}
|
||
|
|
if err := decodeBody(r, &req); err != nil {
|
||
|
|
writeError(w, http.StatusBadRequest, "could not decode request", err.Error())
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if req.Actor.ID == "" {
|
||
|
|
writeError(w, http.StatusBadRequest, "a hypothesis must name its author")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
h, err := a.hypotheses.Create(r.Context(), req.Hypothesis, req.Actor)
|
||
|
|
if err != nil {
|
||
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
||
|
|
return
|
||
|
|
}
|
||
|
|
writeJSON(w, http.StatusCreated, h)
|
||
|
|
|
||
|
|
default:
|
||
|
|
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TransitionRequest moves a hypothesis through its lifecycle.
|
||
|
|
type TransitionRequest struct {
|
||
|
|
State contract.FluidHypothesisState `json:"state,omitempty"`
|
||
|
|
Reason string `json:"reason"`
|
||
|
|
Actor contract.Actor `json:"actor"`
|
||
|
|
|
||
|
|
// Outcome closes a hypothesis under evaluation.
|
||
|
|
Outcome *contract.FluidHypothesisOutcomeStatus `json:"outcome,omitempty"`
|
||
|
|
Summary string `json:"summary,omitempty"`
|
||
|
|
EvidenceRefs []contract.EvidenceRef `json:"evidence_refs,omitempty"`
|
||
|
|
|
||
|
|
// AttachRevision and AttachExperiment link a hypothesis to its artifacts.
|
||
|
|
AttachRevision contract.RevisionID `json:"attach_revision,omitempty"`
|
||
|
|
AttachExperiment contract.ExperimentID `json:"attach_experiment,omitempty"`
|
||
|
|
}
|
||
|
|
|
||
|
|
func (a *ScienceAPI) handleHypothesisItem(w http.ResponseWriter, r *http.Request) {
|
||
|
|
id := contract.HypothesisID(pathTail(r.URL.Path, "/control/v1/hypotheses"))
|
||
|
|
if id == "" {
|
||
|
|
writeError(w, http.StatusNotFound, "no hypothesis named")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
switch r.Method {
|
||
|
|
case http.MethodGet:
|
||
|
|
h, err := a.hypotheses.Get(r.Context(), id)
|
||
|
|
if err != nil {
|
||
|
|
writeError(w, statusForStoreError(err), "hypothesis not found")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
writeJSON(w, http.StatusOK, h)
|
||
|
|
|
||
|
|
case http.MethodPatch:
|
||
|
|
var req TransitionRequest
|
||
|
|
if err := decodeBody(r, &req); err != nil {
|
||
|
|
writeError(w, http.StatusBadRequest, "could not decode request", err.Error())
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if req.Actor.ID == "" {
|
||
|
|
writeError(w, http.StatusBadRequest, "a lifecycle change must name its actor")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
if req.AttachRevision != "" {
|
||
|
|
if err := a.hypotheses.AttachRevision(r.Context(), id, req.AttachRevision, req.Actor); err != nil {
|
||
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
||
|
|
return
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if req.AttachExperiment != "" {
|
||
|
|
if err := a.hypotheses.AttachExperiment(r.Context(), id, req.AttachExperiment, req.Actor); err != nil {
|
||
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
||
|
|
return
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if req.Outcome != nil {
|
||
|
|
h, err := a.hypotheses.RecordOutcome(r.Context(), id, *req.Outcome, req.Summary, req.EvidenceRefs, req.Actor)
|
||
|
|
if err != nil {
|
||
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
||
|
|
return
|
||
|
|
}
|
||
|
|
writeJSON(w, http.StatusOK, h)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
if req.State != "" {
|
||
|
|
h, err := a.hypotheses.Transition(r.Context(), id, req.State, req.Actor, req.Reason)
|
||
|
|
if err != nil {
|
||
|
|
// An incomplete hypothesis or a forbidden move is the caller
|
||
|
|
// being told what the lifecycle requires, not a server fault.
|
||
|
|
status := http.StatusUnprocessableEntity
|
||
|
|
if errors.Is(err, science.ErrNotFound) {
|
||
|
|
status = http.StatusNotFound
|
||
|
|
}
|
||
|
|
writeError(w, status, err.Error())
|
||
|
|
return
|
||
|
|
}
|
||
|
|
writeJSON(w, http.StatusOK, h)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
h, err := a.hypotheses.Get(r.Context(), id)
|
||
|
|
if err != nil {
|
||
|
|
writeError(w, statusForStoreError(err), "hypothesis not found")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
writeJSON(w, http.StatusOK, h)
|
||
|
|
|
||
|
|
default:
|
||
|
|
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// CompeteRequest forms or resolves a competition group.
|
||
|
|
type CompeteRequest struct {
|
||
|
|
GroupID string `json:"group_id"`
|
||
|
|
Members []contract.HypothesisID `json:"members,omitempty"`
|
||
|
|
Winner contract.HypothesisID `json:"winner,omitempty"`
|
||
|
|
Reason string `json:"reason,omitempty"`
|
||
|
|
Actor contract.Actor `json:"actor"`
|
||
|
|
}
|
||
|
|
|
||
|
|
func (a *ScienceAPI) handleCompetition(w http.ResponseWriter, r *http.Request) {
|
||
|
|
if r.Method != http.MethodPost {
|
||
|
|
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
var req CompeteRequest
|
||
|
|
if err := decodeBody(r, &req); err != nil {
|
||
|
|
writeError(w, http.StatusBadRequest, "could not decode request", err.Error())
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if req.Actor.ID == "" {
|
||
|
|
writeError(w, http.StatusBadRequest, "a competition change must name its actor")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
if req.Winner != "" {
|
||
|
|
group, err := a.hypotheses.Resolve(r.Context(), req.GroupID, req.Winner, req.Actor, req.Reason)
|
||
|
|
if err != nil {
|
||
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
||
|
|
return
|
||
|
|
}
|
||
|
|
writeJSON(w, http.StatusOK, group)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
group, err := a.hypotheses.Compete(r.Context(), req.GroupID, req.Members, req.Actor)
|
||
|
|
if err != nil {
|
||
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
||
|
|
return
|
||
|
|
}
|
||
|
|
writeJSON(w, http.StatusCreated, group)
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------- experiments ----------
|
||
|
|
|
||
|
|
func (a *ScienceAPI) handleExperiments(w http.ResponseWriter, r *http.Request) {
|
||
|
|
switch r.Method {
|
||
|
|
case http.MethodGet:
|
||
|
|
state := contract.FluidExperimentResultState(r.URL.Query().Get("state"))
|
||
|
|
list, err := a.experiments.List(r.Context(), state)
|
||
|
|
if err != nil {
|
||
|
|
writeError(w, http.StatusInternalServerError, "could not list experiments")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
writeJSON(w, http.StatusOK, map[string]any{"experiments": list})
|
||
|
|
|
||
|
|
case http.MethodPost:
|
||
|
|
var req struct {
|
||
|
|
Experiment contract.FluidExperiment `json:"experiment"`
|
||
|
|
Actor contract.Actor `json:"actor"`
|
||
|
|
}
|
||
|
|
if err := decodeBody(r, &req); err != nil {
|
||
|
|
writeError(w, http.StatusBadRequest, "could not decode request", err.Error())
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if req.Actor.ID == "" {
|
||
|
|
writeError(w, http.StatusBadRequest, "an experiment must name its designer")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
e, err := a.experiments.Design(r.Context(), req.Experiment, req.Actor)
|
||
|
|
if err != nil {
|
||
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
||
|
|
return
|
||
|
|
}
|
||
|
|
writeJSON(w, http.StatusCreated, e)
|
||
|
|
|
||
|
|
default:
|
||
|
|
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ExperimentActionRequest drives an experiment's lifecycle.
|
||
|
|
//
|
||
|
|
// The routing policy that starting or stopping produces is returned to the
|
||
|
|
// caller rather than installed here. Blueprint section 17 keeps the controller
|
||
|
|
// out of the traffic path, and installing policy from this handler would put it
|
||
|
|
// straight back in.
|
||
|
|
type ExperimentActionRequest struct {
|
||
|
|
Action string `json:"action"` // start, stop, finalize, amend
|
||
|
|
|
||
|
|
Generation int64 `json:"generation,omitempty"`
|
||
|
|
DefaultRevision contract.RevisionID `json:"default_revision,omitempty"`
|
||
|
|
|
||
|
|
Reason string `json:"reason,omitempty"`
|
||
|
|
Preferred contract.RevisionID `json:"preferred_revision,omitempty"`
|
||
|
|
Evidence []contract.EvidenceRef `json:"evidence_refs,omitempty"`
|
||
|
|
Change string `json:"change,omitempty"`
|
||
|
|
|
||
|
|
Actor contract.Actor `json:"actor"`
|
||
|
|
}
|
||
|
|
|
||
|
|
func (a *ScienceAPI) handleExperimentItem(w http.ResponseWriter, r *http.Request) {
|
||
|
|
id := contract.ExperimentID(pathTail(r.URL.Path, "/control/v1/experiments"))
|
||
|
|
if id == "" {
|
||
|
|
writeError(w, http.StatusNotFound, "no experiment named")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
switch r.Method {
|
||
|
|
case http.MethodGet:
|
||
|
|
e, err := a.experiments.Get(r.Context(), id)
|
||
|
|
if err != nil {
|
||
|
|
writeError(w, statusForStoreError(err), "experiment not found")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
writeJSON(w, http.StatusOK, e)
|
||
|
|
|
||
|
|
case http.MethodPost:
|
||
|
|
var req ExperimentActionRequest
|
||
|
|
if err := decodeBody(r, &req); err != nil {
|
||
|
|
writeError(w, http.StatusBadRequest, "could not decode request", err.Error())
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if req.Actor.ID == "" {
|
||
|
|
writeError(w, http.StatusBadRequest, "an experiment action must name its actor")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
a.act(w, r, id, req)
|
||
|
|
|
||
|
|
default:
|
||
|
|
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (a *ScienceAPI) act(w http.ResponseWriter, r *http.Request, id contract.ExperimentID, req ExperimentActionRequest) {
|
||
|
|
switch req.Action {
|
||
|
|
case "start":
|
||
|
|
e, policy, err := a.experiments.Start(r.Context(), id, req.Generation, req.DefaultRevision, req.Actor)
|
||
|
|
if err != nil {
|
||
|
|
status := http.StatusBadRequest
|
||
|
|
if errors.Is(err, science.ErrTooManyExperiments) {
|
||
|
|
// The concurrency limit is a temporary condition, not a
|
||
|
|
// malformed request.
|
||
|
|
status = http.StatusConflict
|
||
|
|
}
|
||
|
|
writeError(w, status, err.Error())
|
||
|
|
return
|
||
|
|
}
|
||
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
||
|
|
"experiment": e,
|
||
|
|
"routing_policy": contract.RoutingPolicyDocument{RoutingPolicy: policy},
|
||
|
|
"note": "install this policy to expose the experiment; the controller does not route traffic itself",
|
||
|
|
})
|
||
|
|
|
||
|
|
case "stop":
|
||
|
|
e, policy, err := a.experiments.Stop(r.Context(), id, req.Generation, req.DefaultRevision, req.Actor, req.Reason)
|
||
|
|
if err != nil {
|
||
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
||
|
|
return
|
||
|
|
}
|
||
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
||
|
|
"experiment": e,
|
||
|
|
"routing_policy": contract.RoutingPolicyDocument{RoutingPolicy: policy},
|
||
|
|
"note": "install this policy to return traffic to the default revision",
|
||
|
|
})
|
||
|
|
|
||
|
|
case "finalize":
|
||
|
|
e, err := a.experiments.Finalize(r.Context(), id, req.Preferred, req.Actor, req.Reason, req.Evidence)
|
||
|
|
if err != nil {
|
||
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
||
|
|
return
|
||
|
|
}
|
||
|
|
writeJSON(w, http.StatusOK, e)
|
||
|
|
|
||
|
|
case "amend":
|
||
|
|
e, err := a.experiments.Amend(r.Context(), id, req.Change, req.Reason, req.Actor)
|
||
|
|
if err != nil {
|
||
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
||
|
|
return
|
||
|
|
}
|
||
|
|
writeJSON(w, http.StatusOK, e)
|
||
|
|
|
||
|
|
default:
|
||
|
|
writeError(w, http.StatusBadRequest,
|
||
|
|
"unknown action; expected start, stop, finalize or amend")
|
||
|
|
}
|
||
|
|
}
|