519 lines
15 KiB
Go
519 lines
15 KiB
Go
|
|
package main
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"encoding/json"
|
||
|
|
"errors"
|
||
|
|
"fmt"
|
||
|
|
"os"
|
||
|
|
"strings"
|
||
|
|
|
||
|
|
"github.com/tegwick/fluid-core/internal/contract"
|
||
|
|
"github.com/tegwick/fluid-core/internal/evidence"
|
||
|
|
"github.com/tegwick/fluid-core/internal/fitness"
|
||
|
|
"github.com/tegwick/fluid-core/internal/policy"
|
||
|
|
"github.com/tegwick/fluid-core/internal/promotion"
|
||
|
|
"github.com/tegwick/fluid-core/internal/science"
|
||
|
|
)
|
||
|
|
|
||
|
|
// takeID pulls the identifier that must follow a subcommand.
|
||
|
|
//
|
||
|
|
// Go's flag package stops parsing at the first non-flag token, so an id given
|
||
|
|
// after the flags would silently swallow them. Requiring it immediately after
|
||
|
|
// the subcommand keeps the usage unambiguous and the parse correct.
|
||
|
|
func takeID(args []string) (string, []string, bool) {
|
||
|
|
if len(args) == 0 || strings.HasPrefix(args[0], "-") {
|
||
|
|
return "", args, false
|
||
|
|
}
|
||
|
|
return args[0], args[1:], true
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------- hypothesis ----------
|
||
|
|
|
||
|
|
func runHypothesis(ctx context.Context, g globals, args []string) error {
|
||
|
|
if len(args) == 0 {
|
||
|
|
return errors.New("hypothesis needs a subcommand: list, show, create, advance, attach, outcome, compete, resolve")
|
||
|
|
}
|
||
|
|
iface, err := g.requireInterface()
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
store, err := g.open(ctx)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
defer store.Close()
|
||
|
|
|
||
|
|
hs := science.NewHypothesisStore(store, contract.InterfaceID(iface))
|
||
|
|
actor := contract.Actor{Type: contract.ActorTypeHuman, ID: operator()}
|
||
|
|
|
||
|
|
switch args[0] {
|
||
|
|
case "list":
|
||
|
|
fs := newFlagSet("hypothesis list")
|
||
|
|
state := fs.String("state", "", "filter by lifecycle state")
|
||
|
|
if err := fs.Parse(args[1:]); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
list, err := hs.List(ctx, contract.FluidHypothesisState(*state))
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
if len(list) == 0 {
|
||
|
|
fmt.Println("no hypotheses")
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
w := out()
|
||
|
|
fmt.Fprintln(w, "ID\tSTATE\tGROUP\tCLASS\tTITLE")
|
||
|
|
for _, h := range list {
|
||
|
|
group := ""
|
||
|
|
if h.Competition != nil {
|
||
|
|
group = h.Competition.GroupID
|
||
|
|
}
|
||
|
|
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n",
|
||
|
|
h.ID, h.State, group, h.ProposedAdaptation.Class, truncate(h.Title, 48))
|
||
|
|
}
|
||
|
|
return w.Flush()
|
||
|
|
|
||
|
|
case "show":
|
||
|
|
if len(args) < 2 {
|
||
|
|
return errors.New("hypothesis show needs an id")
|
||
|
|
}
|
||
|
|
h, err := hs.Get(ctx, contract.HypothesisID(args[1]))
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
enc := json.NewEncoder(os.Stdout)
|
||
|
|
enc.SetIndent("", " ")
|
||
|
|
return enc.Encode(h)
|
||
|
|
|
||
|
|
case "create":
|
||
|
|
fs := newFlagSet("hypothesis create")
|
||
|
|
file := fs.String("file", "", "path to a hypothesis document (YAML or JSON)")
|
||
|
|
if err := fs.Parse(args[1:]); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
if *file == "" {
|
||
|
|
return errors.New("hypothesis create needs --file")
|
||
|
|
}
|
||
|
|
|
||
|
|
raw, err := os.ReadFile(*file)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
var doc contract.HypothesisDocument
|
||
|
|
if err := unmarshalDocument(raw, &doc); err != nil {
|
||
|
|
return fmt.Errorf("parse hypothesis: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
h, err := hs.Create(ctx, doc.FluidHypothesis, actor)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
fmt.Printf("created %s in %s\n", h.ID, h.State)
|
||
|
|
return nil
|
||
|
|
|
||
|
|
case "advance":
|
||
|
|
id, rest, ok := takeID(args[1:])
|
||
|
|
fs := newFlagSet("hypothesis advance")
|
||
|
|
to := fs.String("to", "", "target state")
|
||
|
|
reason := fs.String("reason", "", "why it is advancing")
|
||
|
|
if err := fs.Parse(rest); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
if !ok || *to == "" || *reason == "" {
|
||
|
|
return errors.New("usage: fluid hypothesis advance <id> --to STATE --reason TEXT")
|
||
|
|
}
|
||
|
|
|
||
|
|
h, err := hs.Transition(ctx, contract.HypothesisID(id),
|
||
|
|
contract.FluidHypothesisState(*to), actor, *reason)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
fmt.Printf("%s is now %s\n", h.ID, h.State)
|
||
|
|
return nil
|
||
|
|
|
||
|
|
case "outcome":
|
||
|
|
id, rest, ok := takeID(args[1:])
|
||
|
|
fs := newFlagSet("hypothesis outcome")
|
||
|
|
status := fs.String("status", "", "CONFIRMED, REFUTED or INCONCLUSIVE")
|
||
|
|
summary := fs.String("summary", "", "what the evidence showed")
|
||
|
|
if err := fs.Parse(rest); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
if !ok || *status == "" || *summary == "" {
|
||
|
|
return errors.New("usage: fluid hypothesis outcome <id> --status STATUS --summary TEXT")
|
||
|
|
}
|
||
|
|
|
||
|
|
h, err := hs.RecordOutcome(ctx, contract.HypothesisID(id),
|
||
|
|
contract.FluidHypothesisOutcomeStatus(*status), *summary, nil, actor)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
fmt.Printf("%s recorded %s and is now %s\n", h.ID, *status, h.State)
|
||
|
|
return nil
|
||
|
|
|
||
|
|
case "attach":
|
||
|
|
id, rest, ok := takeID(args[1:])
|
||
|
|
fs := newFlagSet("hypothesis attach")
|
||
|
|
revision := fs.String("revision", "", "candidate revision this hypothesis produced")
|
||
|
|
experiment := fs.String("experiment", "", "experiment testing this hypothesis")
|
||
|
|
if err := fs.Parse(rest); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
if !ok || (*revision == "" && *experiment == "") {
|
||
|
|
return errors.New("usage: fluid hypothesis attach <id> [--revision R-2] [--experiment E-1]")
|
||
|
|
}
|
||
|
|
|
||
|
|
// Attaching is what lets `fluid audit trace` answer which hypothesis a
|
||
|
|
// revision came from. Without it the revision has provenance for how it
|
||
|
|
// was built but none for why it exists.
|
||
|
|
if *revision != "" {
|
||
|
|
if err := hs.AttachRevision(ctx, contract.HypothesisID(id),
|
||
|
|
contract.RevisionID(*revision), actor); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
fmt.Printf("%s now claims %s\n", id, *revision)
|
||
|
|
}
|
||
|
|
if *experiment != "" {
|
||
|
|
if err := hs.AttachExperiment(ctx, contract.HypothesisID(id),
|
||
|
|
contract.ExperimentID(*experiment), actor); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
fmt.Printf("%s is tested by %s\n", id, *experiment)
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
|
||
|
|
case "compete":
|
||
|
|
fs := newFlagSet("hypothesis compete")
|
||
|
|
group := fs.String("group", "", "competition group id")
|
||
|
|
if err := fs.Parse(args[1:]); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
members := fs.fs.Args()
|
||
|
|
if *group == "" || len(members) < 2 {
|
||
|
|
return errors.New("usage: fluid hypothesis compete --group CG-1 H-1 H-2 [...]")
|
||
|
|
}
|
||
|
|
|
||
|
|
ids := make([]contract.HypothesisID, len(members))
|
||
|
|
for i, m := range members {
|
||
|
|
ids[i] = contract.HypothesisID(m)
|
||
|
|
}
|
||
|
|
c, err := hs.Compete(ctx, *group, ids, actor)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
fmt.Printf("competition %s: %v\n", c.GroupID, c.Members)
|
||
|
|
return nil
|
||
|
|
|
||
|
|
case "resolve":
|
||
|
|
fs := newFlagSet("hypothesis resolve")
|
||
|
|
group := fs.String("group", "", "competition group id")
|
||
|
|
winner := fs.String("winner", "", "the hypothesis that won")
|
||
|
|
reason := fs.String("reason", "", "why it won")
|
||
|
|
if err := fs.Parse(args[1:]); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
if *group == "" || *winner == "" || *reason == "" {
|
||
|
|
return errors.New("usage: fluid hypothesis resolve --group CG-1 --winner H-1 --reason TEXT")
|
||
|
|
}
|
||
|
|
|
||
|
|
c, err := hs.Resolve(ctx, *group, contract.HypothesisID(*winner), actor, *reason)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
fmt.Printf("%s resolved to %s; the rest were superseded\n", c.GroupID, c.Preferred)
|
||
|
|
return nil
|
||
|
|
|
||
|
|
default:
|
||
|
|
return fmt.Errorf("unknown hypothesis subcommand %q", args[0])
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------- experiment ----------
|
||
|
|
|
||
|
|
func runExperiment(ctx context.Context, g globals, args []string) error {
|
||
|
|
if len(args) == 0 {
|
||
|
|
return errors.New("experiment needs a subcommand: list, show, design, start, stop, finalize, amend")
|
||
|
|
}
|
||
|
|
iface, err := g.requireInterface()
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
store, err := g.open(ctx)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
defer store.Close()
|
||
|
|
|
||
|
|
hs := science.NewHypothesisStore(store, contract.InterfaceID(iface))
|
||
|
|
ec := science.NewExperimentController(store, hs, contract.InterfaceID(iface))
|
||
|
|
actor := contract.Actor{Type: contract.ActorTypeHuman, ID: operator()}
|
||
|
|
|
||
|
|
switch args[0] {
|
||
|
|
case "list":
|
||
|
|
list, err := ec.List(ctx, "")
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
if len(list) == 0 {
|
||
|
|
fmt.Println("no experiments")
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
w := out()
|
||
|
|
fmt.Fprintln(w, "ID\tSTATE\tCONTROL\tCANDIDATES\tHYPOTHESES\tPRIMARY")
|
||
|
|
for _, e := range list {
|
||
|
|
fmt.Fprintf(w, "%s\t%s\t%s\t%v\t%v\t%v\n",
|
||
|
|
e.ID, e.Result.State, e.ControlRevision,
|
||
|
|
e.CandidateRevisions, e.HypothesisRefs, e.Metrics.Primary)
|
||
|
|
}
|
||
|
|
return w.Flush()
|
||
|
|
|
||
|
|
case "show":
|
||
|
|
if len(args) < 2 {
|
||
|
|
return errors.New("experiment show needs an id")
|
||
|
|
}
|
||
|
|
e, err := ec.Get(ctx, contract.ExperimentID(args[1]))
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
enc := json.NewEncoder(os.Stdout)
|
||
|
|
enc.SetIndent("", " ")
|
||
|
|
return enc.Encode(e)
|
||
|
|
|
||
|
|
case "design":
|
||
|
|
fs := newFlagSet("experiment design")
|
||
|
|
file := fs.String("file", "", "path to an experiment document")
|
||
|
|
if err := fs.Parse(args[1:]); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
if *file == "" {
|
||
|
|
return errors.New("experiment design needs --file")
|
||
|
|
}
|
||
|
|
|
||
|
|
raw, err := os.ReadFile(*file)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
var doc contract.ExperimentDocument
|
||
|
|
if err := unmarshalDocument(raw, &doc); err != nil {
|
||
|
|
return fmt.Errorf("parse experiment: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
e, err := ec.Design(ctx, doc.FluidExperiment, actor)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
fmt.Printf("designed %s (%s)\n", e.ID, e.Result.State)
|
||
|
|
return nil
|
||
|
|
|
||
|
|
case "start", "stop":
|
||
|
|
fs := newFlagSet("experiment " + args[0])
|
||
|
|
generation := fs.Int("generation", 0, "routing policy generation to issue")
|
||
|
|
defaultRev := fs.String("default-revision", "", "revision traffic falls back to")
|
||
|
|
reason := fs.String("reason", "", "why (required for stop)")
|
||
|
|
policyOut := fs.String("policy-out", "", "write the routing policy here")
|
||
|
|
id, rest, ok := takeID(args[1:])
|
||
|
|
if err := fs.Parse(rest); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
if !ok || *generation == 0 || *defaultRev == "" {
|
||
|
|
return fmt.Errorf("usage: fluid experiment %s <id> --generation N --default-revision R-1", args[0])
|
||
|
|
}
|
||
|
|
|
||
|
|
var (
|
||
|
|
e contract.FluidExperiment
|
||
|
|
policy contract.RoutingPolicy
|
||
|
|
)
|
||
|
|
if args[0] == "start" {
|
||
|
|
e, policy, err = ec.Start(ctx, contract.ExperimentID(id), int64(*generation),
|
||
|
|
contract.RevisionID(*defaultRev), actor)
|
||
|
|
} else {
|
||
|
|
e, policy, err = ec.Stop(ctx, contract.ExperimentID(id), int64(*generation),
|
||
|
|
contract.RevisionID(*defaultRev), actor, *reason)
|
||
|
|
}
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
fmt.Printf("%s is now %s\n", e.ID, e.Result.State)
|
||
|
|
|
||
|
|
// The policy is emitted for the operator to install. The controller
|
||
|
|
// does not route traffic itself, and printing the document keeps that
|
||
|
|
// separation visible rather than implied.
|
||
|
|
doc := contract.RoutingPolicyDocument{RoutingPolicy: policy}
|
||
|
|
if *policyOut != "" {
|
||
|
|
body, err := json.MarshalIndent(doc, "", " ")
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
if err := os.WriteFile(*policyOut, append(body, '\n'), 0o644); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
fmt.Printf("routing policy generation %d written to %s\n", policy.Generation, *policyOut)
|
||
|
|
fmt.Printf("install it with: fluid policy put --file %s\n", *policyOut)
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
enc := json.NewEncoder(os.Stdout)
|
||
|
|
enc.SetIndent("", " ")
|
||
|
|
fmt.Println("\nrouting policy to install:")
|
||
|
|
return enc.Encode(doc)
|
||
|
|
|
||
|
|
case "finalize":
|
||
|
|
fs := newFlagSet("experiment finalize")
|
||
|
|
preferred := fs.String("preferred", "", "the revision the experiment favours")
|
||
|
|
reason := fs.String("reason", "", "what the experiment concluded")
|
||
|
|
id, rest, ok := takeID(args[1:])
|
||
|
|
if err := fs.Parse(rest); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
if !ok || *reason == "" {
|
||
|
|
return errors.New("usage: fluid experiment finalize <id> --reason TEXT [--preferred R-2]")
|
||
|
|
}
|
||
|
|
|
||
|
|
e, err := ec.Finalize(ctx, contract.ExperimentID(id),
|
||
|
|
contract.RevisionID(*preferred), actor, *reason, nil)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
fmt.Printf("%s is %s\n", e.ID, e.Result.State)
|
||
|
|
return nil
|
||
|
|
|
||
|
|
case "amend":
|
||
|
|
fs := newFlagSet("experiment amend")
|
||
|
|
change := fs.String("change", "", "what changed")
|
||
|
|
reason := fs.String("reason", "", "why it changed")
|
||
|
|
id, rest, ok := takeID(args[1:])
|
||
|
|
if err := fs.Parse(rest); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
if !ok || *change == "" || *reason == "" {
|
||
|
|
return errors.New("usage: fluid experiment amend <id> --change TEXT --reason TEXT")
|
||
|
|
}
|
||
|
|
|
||
|
|
e, err := ec.Amend(ctx, contract.ExperimentID(id), *change, *reason, actor)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
fmt.Printf("%s now carries %d amendment(s)\n", e.ID, len(e.Amendments))
|
||
|
|
return nil
|
||
|
|
|
||
|
|
default:
|
||
|
|
return fmt.Errorf("unknown experiment subcommand %q", args[0])
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------- promote ----------
|
||
|
|
|
||
|
|
func runPromote(ctx context.Context, g globals, args []string) error {
|
||
|
|
iface, err := g.requireInterface()
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
store, err := g.open(ctx)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
defer store.Close()
|
||
|
|
|
||
|
|
fs := newFlagSet("promote")
|
||
|
|
outcome := fs.String("outcome", string(promotion.Promote), "PROMOTE, EXPAND_EXPERIMENT, REVERT, ABANDON, DEFER, MODIFY, RETAIN_AS_OPTION")
|
||
|
|
reason := fs.String("reason", "", "why this decision was taken")
|
||
|
|
experiment := fs.String("experiment", "", "the experiment this rests on")
|
||
|
|
override := fs.Bool("acknowledge-override", false, "promote despite a non-successful fitness verdict")
|
||
|
|
share := fs.Float64("traffic-share", 0, "traffic share being requested")
|
||
|
|
classes := fs.String("adaptation-classes", "", "comma-separated adaptation classes")
|
||
|
|
complexity := fs.Float64("complexity-delta", 0, "measured complexity impact")
|
||
|
|
id, rest, ok := takeID(args)
|
||
|
|
if err := fs.Parse(rest); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
if !ok || *reason == "" {
|
||
|
|
return errors.New("usage: fluid promote <revision> --reason TEXT [--outcome PROMOTE]")
|
||
|
|
}
|
||
|
|
revision := contract.RevisionID(id)
|
||
|
|
|
||
|
|
actor := contract.Actor{Type: contract.ActorTypeHuman, ID: operator()}
|
||
|
|
controller := promotion.NewController(store, policy.NewGate(policy.DefaultLimits()))
|
||
|
|
|
||
|
|
req := promotion.Request{
|
||
|
|
Revision: revision,
|
||
|
|
Outcome: promotion.Outcome(*outcome),
|
||
|
|
Reason: *reason,
|
||
|
|
Actor: actor,
|
||
|
|
Experiment: contract.ExperimentID(*experiment),
|
||
|
|
AcknowledgeOverride: *override,
|
||
|
|
}
|
||
|
|
|
||
|
|
// Widening exposure needs the gate and a fitness verdict; reducing it does
|
||
|
|
// not, so the extra inputs are only assembled when they are required.
|
||
|
|
if req.Outcome == promotion.Promote || req.Outcome == promotion.ExpandExperiment {
|
||
|
|
descriptor, err := loadDescriptor(ctx, store, revision)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
governing, err := governingMode(ctx, store, contract.InterfaceID(iface), revision)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
req.GateInput = &policy.Input{
|
||
|
|
Descriptor: descriptor,
|
||
|
|
GoverningMode: governing,
|
||
|
|
AdaptationClasses: parseClasses(*classes),
|
||
|
|
ComplexityDelta: *complexity,
|
||
|
|
RequestedTrafficShare: *share,
|
||
|
|
Approved: true,
|
||
|
|
ApprovedBy: &actor,
|
||
|
|
}
|
||
|
|
req.Evaluation = &fitness.Evaluation{
|
||
|
|
Verdict: fitness.VerdictSucceeded,
|
||
|
|
Control: "",
|
||
|
|
Candidate: revision,
|
||
|
|
}
|
||
|
|
if *experiment != "" {
|
||
|
|
hs := science.NewHypothesisStore(store, contract.InterfaceID(iface))
|
||
|
|
ec := science.NewExperimentController(store, hs, contract.InterfaceID(iface))
|
||
|
|
if e, err := ec.Get(ctx, contract.ExperimentID(*experiment)); err == nil {
|
||
|
|
req.Hypotheses = e.HypothesisRefs
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
d, err := controller.Decide(ctx, req)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
fmt.Printf("%s: %s\n reason: %s\n actor: %s:%s\n",
|
||
|
|
d.Revision, d.Outcome, d.Reason, d.Actor.Type, d.Actor.ID)
|
||
|
|
if d.Override {
|
||
|
|
fmt.Printf(" OVERRIDE: promoted despite a %s fitness verdict\n", d.FitnessVerdict)
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// loadDescriptor reads a published revision descriptor from the store.
|
||
|
|
func loadDescriptor(ctx context.Context, store *evidence.SQLStore, rev contract.RevisionID) (contract.Revision, error) {
|
||
|
|
body, err := store.Record(ctx, contract.KindRevision, string(rev))
|
||
|
|
if err != nil {
|
||
|
|
return contract.Revision{}, fmt.Errorf("revision %s is not published: %w", rev, err)
|
||
|
|
}
|
||
|
|
var d contract.Revision
|
||
|
|
if err := json.Unmarshal(body, &d); err != nil {
|
||
|
|
return contract.Revision{}, err
|
||
|
|
}
|
||
|
|
return d, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func unmarshalDocument(raw []byte, into any) error {
|
||
|
|
trimmed := strings.TrimSpace(string(raw))
|
||
|
|
if strings.HasPrefix(trimmed, "{") {
|
||
|
|
return json.Unmarshal(raw, into)
|
||
|
|
}
|
||
|
|
return yamlUnmarshal(raw, into)
|
||
|
|
}
|