Some checks failed
ci / build (push) Has been cancelled
Completes FLUID-WP-0006. The loop now runs end to end from the command line: two competing presentation hypotheses, an experiment that issues a routing policy rather than touching traffic, an amendment, a stop that returns traffic to the default, a confirmed outcome, a resolved competition, and a promotion the gate can refuse. Starting or stopping an experiment returns the routing policy for the operator to install rather than installing it. Blueprint 17 keeps the controller out of the traffic path, and installing from the handler would put it straight back in; emitting the document keeps the separation visible instead of implied. `fluid audit trace` now answers the section 25 questions from events rather than summary records, and names the rivals a hypothesis beat: an audit asking which hypotheses were considered is not answered by naming only the winner. Two fixes found by driving the CLI rather than only the tests. Go's flag package stops at the first positional, so ids given after flags silently swallowed them; ids are now taken before parsing. And there was no way to attach a revision to the hypothesis that produced it, which left `audit trace` unable to say why a revision existed -- `hypothesis attach` closes that. 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
679 lines
18 KiB
Go
679 lines
18 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"crypto/ed25519"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"sort"
|
|
"strings"
|
|
"text/tabwriter"
|
|
"time"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
_ "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/science"
|
|
"github.com/tegwick/fluid-core/internal/signing"
|
|
)
|
|
|
|
// open connects to the evidence store for this invocation.
|
|
func (g globals) open(ctx context.Context) (*evidence.SQLStore, error) {
|
|
return evidence.OpenSQLite(ctx, g.storePath())
|
|
}
|
|
|
|
func out() *tabwriter.Writer {
|
|
return tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
|
|
}
|
|
|
|
// ---------- intent ----------
|
|
|
|
func runIntent(ctx context.Context, g globals, args []string) error {
|
|
if len(args) == 0 {
|
|
return errors.New("intent needs a subcommand: put, activate, show")
|
|
}
|
|
iface, err := g.requireInterface()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
store, err := g.open(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer store.Close()
|
|
|
|
is := intent.New(store, contract.InterfaceID(iface))
|
|
|
|
switch args[0] {
|
|
case "put":
|
|
fs := newFlagSet("intent put")
|
|
version := fs.String("version", "", "intent version label, such as IEI-1")
|
|
file := fs.String("file", "", "path to the InterfaceEvolutionIntent document")
|
|
activate := fs.Bool("activate", false, "make this version the governing intent")
|
|
if err := fs.Parse(args[1:]); err != nil {
|
|
return err
|
|
}
|
|
if *version == "" || *file == "" {
|
|
return errors.New("intent put needs --version and --file")
|
|
}
|
|
|
|
doc, err := os.ReadFile(*file)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
v, err := is.Put(ctx, *version, string(doc))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("recorded %s\n digest %s\n mode %s\n", v.Version, v.Digest, v.Mode)
|
|
|
|
if *activate {
|
|
if err := is.SetActive(ctx, v.Version); err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf(" active yes\n")
|
|
}
|
|
return nil
|
|
|
|
case "activate":
|
|
if len(args) < 2 {
|
|
return errors.New("intent activate needs a version")
|
|
}
|
|
if err := is.SetActive(ctx, args[1]); err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("%s is now the governing intent\n", args[1])
|
|
return nil
|
|
|
|
case "show":
|
|
var v intent.Version
|
|
if len(args) > 1 {
|
|
v, err = is.Get(ctx, args[1])
|
|
} else {
|
|
v, err = is.Active(ctx)
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("version %s\ndigest %s\nmode %s\nrecorded %s\n\n",
|
|
v.Version, v.Digest, v.Mode, v.RecordedAt.Format(time.RFC3339))
|
|
fmt.Println(v.Document)
|
|
return nil
|
|
|
|
default:
|
|
return fmt.Errorf("unknown intent subcommand %q", args[0])
|
|
}
|
|
}
|
|
|
|
// ---------- revision ----------
|
|
|
|
func runRevision(ctx context.Context, g globals, args []string) error {
|
|
if len(args) == 0 {
|
|
return errors.New("revision needs a subcommand: publish, list, show")
|
|
}
|
|
iface, err := g.requireInterface()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
store, err := g.open(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer store.Close()
|
|
|
|
switch args[0] {
|
|
case "publish":
|
|
fs := newFlagSet("revision publish")
|
|
file := fs.String("file", "", "path to the revision descriptor (YAML or JSON)")
|
|
keyFile := fs.String("key-file", os.Getenv("FLUID_SIGNING_KEY"), "base64 ed25519 signing key file")
|
|
keyID := fs.String("key-id", envOr("FLUID_SIGNING_KEY_ID", "dev"), "signing key identifier")
|
|
ephemeral := fs.Bool("ephemeral-key", false, "sign with a throwaway key (development only)")
|
|
classes := fs.String("adaptation-classes", "", "comma-separated adaptation classes")
|
|
complexity := fs.Float64("complexity-delta", 0, "measured complexity impact")
|
|
share := fs.Float64("traffic-share", 0, "requested traffic share")
|
|
approvedBy := fs.String("approved-by", "", "authorizing operator; required by the default gate")
|
|
if err := fs.Parse(args[1:]); err != nil {
|
|
return err
|
|
}
|
|
if *file == "" {
|
|
return errors.New("revision publish needs --file")
|
|
}
|
|
|
|
raw, err := os.ReadFile(*file)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var doc contract.RevisionDescriptorDocument
|
|
if err := yaml.Unmarshal(raw, &doc); err != nil {
|
|
return fmt.Errorf("parse descriptor: %w", err)
|
|
}
|
|
d := doc.Revision
|
|
|
|
if string(d.Interface) != iface {
|
|
return fmt.Errorf("descriptor is for interface %q, but --interface is %q", d.Interface, iface)
|
|
}
|
|
if d.ID == "" {
|
|
return errors.New("descriptor has no revision id")
|
|
}
|
|
|
|
// Publishing goes through the same pipeline the control plane uses.
|
|
// A CLI that could write a revision straight into the store would be a
|
|
// way around the deterministic policy gate, which would make the gate
|
|
// decorative for anyone with shell access.
|
|
signer, err := loadSigner(*keyID, *keyFile, *ephemeral)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
intents := intent.New(store, contract.InterfaceID(iface))
|
|
pipeline, err := publish.New(publish.Options{
|
|
Gate: policy.NewGate(policy.DefaultLimits()),
|
|
Signer: signer,
|
|
Store: store,
|
|
Intents: intents,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var approver *contract.Actor
|
|
if *approvedBy != "" {
|
|
approver = &contract.Actor{Type: contract.ActorTypeHuman, ID: *approvedBy}
|
|
}
|
|
|
|
candidate := publish.NewCandidate(d, contract.Actor{
|
|
Type: contract.ActorTypeHuman, ID: operator(),
|
|
})
|
|
|
|
verified, report, err := pipeline.Run(ctx, candidate, publish.PromotionRequest{
|
|
AdaptationClasses: parseClasses(*classes),
|
|
ComplexityDelta: *complexity,
|
|
RequestedTrafficShare: *share,
|
|
Approved: approver != nil,
|
|
ApprovedBy: approver,
|
|
})
|
|
if err != nil {
|
|
printReport(report)
|
|
return err
|
|
}
|
|
if err := pipeline.Publish(ctx, verified); err != nil {
|
|
return err
|
|
}
|
|
|
|
printReport(report)
|
|
fmt.Printf("\npublished %s (%s), governed by %s, signed by %s\n",
|
|
d.ID, d.State, d.Intent.Version, verified.Descriptor().Signature.KeyID)
|
|
return nil
|
|
|
|
case "list":
|
|
records, err := store.Records(ctx, contract.KindRevision)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(records) == 0 {
|
|
fmt.Println("no revisions published")
|
|
return nil
|
|
}
|
|
|
|
ids := make([]string, 0, len(records))
|
|
for id := range records {
|
|
ids = append(ids, id)
|
|
}
|
|
sort.Strings(ids)
|
|
|
|
w := out()
|
|
fmt.Fprintln(w, "REVISION\tSTATE\tINTENT\tCOMPATIBILITY\tUPSTREAM")
|
|
for _, id := range ids {
|
|
var d contract.Revision
|
|
if err := json.Unmarshal(records[id], &d); err != nil {
|
|
fmt.Fprintf(w, "%s\t(unreadable)\t\t\t\n", id)
|
|
continue
|
|
}
|
|
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n",
|
|
d.ID, d.State, d.Intent.Version, d.Policy.Compatibility, d.Runtime.Upstream)
|
|
}
|
|
return w.Flush()
|
|
|
|
case "show":
|
|
if len(args) < 2 {
|
|
return errors.New("revision show needs a revision id")
|
|
}
|
|
body, err := store.Record(ctx, contract.KindRevision, args[1])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var pretty any
|
|
_ = json.Unmarshal(body, &pretty)
|
|
enc := json.NewEncoder(os.Stdout)
|
|
enc.SetIndent("", " ")
|
|
return enc.Encode(pretty)
|
|
|
|
default:
|
|
return fmt.Errorf("unknown revision subcommand %q", args[0])
|
|
}
|
|
}
|
|
|
|
// ---------- policy ----------
|
|
|
|
const policyRecordID = "__routing_policy__"
|
|
|
|
func runPolicy(ctx context.Context, g globals, args []string) error {
|
|
if len(args) == 0 {
|
|
return errors.New("policy needs a subcommand: put, show")
|
|
}
|
|
iface, err := g.requireInterface()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
store, err := g.open(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer store.Close()
|
|
|
|
switch args[0] {
|
|
case "put":
|
|
fs := newFlagSet("policy put")
|
|
file := fs.String("file", "", "path to the routing policy")
|
|
if err := fs.Parse(args[1:]); err != nil {
|
|
return err
|
|
}
|
|
if *file == "" {
|
|
return errors.New("policy put needs --file")
|
|
}
|
|
|
|
raw, err := os.ReadFile(*file)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var doc contract.RoutingPolicyDocument
|
|
if err := yaml.Unmarshal(raw, &doc); err != nil {
|
|
return fmt.Errorf("parse policy: %w", err)
|
|
}
|
|
p := doc.RoutingPolicy
|
|
|
|
if string(p.Interface) != iface {
|
|
return fmt.Errorf("policy is for interface %q, but --interface is %q", p.Interface, iface)
|
|
}
|
|
|
|
// Generations are monotonic in the registry; catching a regression here
|
|
// avoids shipping a policy the gateway will silently refuse.
|
|
if existing, err := store.Record(ctx, contract.KindRoutingPolicy, policyRecordID); err == nil {
|
|
var prev contract.RoutingPolicy
|
|
if json.Unmarshal(existing, &prev) == nil && p.Generation <= prev.Generation {
|
|
return fmt.Errorf("policy generation %d is not newer than the installed %d",
|
|
p.Generation, prev.Generation)
|
|
}
|
|
}
|
|
|
|
body, err := json.Marshal(p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := store.PutRecord(ctx, contract.KindRoutingPolicy, policyRecordID, body); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := store.AppendEvent(ctx, contract.FluidEvent{
|
|
SchemaVersion: "0.1",
|
|
ID: contract.EventID(fmt.Sprintf("EV-policy-%d", time.Now().UnixNano())),
|
|
OccurredAt: time.Now().UTC(),
|
|
EntityType: contract.FluidEventEntityTypeRoutingPolicy,
|
|
EntityID: fmt.Sprintf("generation-%d", p.Generation),
|
|
EventType: "ROUTING_POLICY_INSTALLED",
|
|
Actor: contract.Actor{Type: contract.ActorTypeHuman, ID: operator()},
|
|
Reason: fmt.Sprintf("default %s, %d rule(s)", p.DefaultRevision, len(p.Rules)),
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
|
|
fmt.Printf("installed routing policy generation %d (default %s, %d rules)\n",
|
|
p.Generation, p.DefaultRevision, len(p.Rules))
|
|
return nil
|
|
|
|
case "show":
|
|
body, err := store.Record(ctx, contract.KindRoutingPolicy, policyRecordID)
|
|
if err != nil {
|
|
if errors.Is(err, evidence.ErrNotFound) {
|
|
fmt.Println("no routing policy installed")
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
var pretty any
|
|
_ = json.Unmarshal(body, &pretty)
|
|
enc := json.NewEncoder(os.Stdout)
|
|
enc.SetIndent("", " ")
|
|
return enc.Encode(pretty)
|
|
|
|
default:
|
|
return fmt.Errorf("unknown policy subcommand %q", args[0])
|
|
}
|
|
}
|
|
|
|
// ---------- events and telemetry ----------
|
|
|
|
func runEvents(ctx context.Context, g globals, args []string) error {
|
|
fs := newFlagSet("events")
|
|
entity := fs.String("entity", "", "filter by entity id, such as R-1")
|
|
kind := fs.String("type", "", "filter by event type")
|
|
limit := fs.Int("limit", 50, "maximum events to show")
|
|
if err := fs.Parse(args); err != nil {
|
|
return err
|
|
}
|
|
|
|
store, err := g.open(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer store.Close()
|
|
|
|
events, err := store.Events(ctx, evidence.EventFilter{
|
|
EntityID: *entity,
|
|
EventType: *kind,
|
|
Limit: *limit,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(events) == 0 {
|
|
fmt.Println("no events")
|
|
return nil
|
|
}
|
|
|
|
w := out()
|
|
fmt.Fprintln(w, "WHEN\tENTITY\tTYPE\tACTOR\tREASON")
|
|
for _, ev := range events {
|
|
fmt.Fprintf(w, "%s\t%s %s\t%s\t%s:%s\t%s\n",
|
|
ev.OccurredAt.Format(time.RFC3339), ev.EntityType, ev.EntityID,
|
|
ev.EventType, ev.Actor.Type, ev.Actor.ID, oneLine(ev.Reason))
|
|
}
|
|
return w.Flush()
|
|
}
|
|
|
|
func runTelemetry(ctx context.Context, g globals, args []string) error {
|
|
fs := newFlagSet("telemetry")
|
|
revision := fs.String("revision", "", "filter by revision")
|
|
kind := fs.String("kind", "", "filter by event kind")
|
|
limit := fs.Int("limit", 50, "maximum rows to show")
|
|
if err := fs.Parse(args); err != nil {
|
|
return err
|
|
}
|
|
|
|
store, err := g.open(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer store.Close()
|
|
|
|
rows, err := store.Telemetry(ctx, evidence.TelemetryFilter{
|
|
InterfaceID: contract.InterfaceID(g.iface),
|
|
Revision: contract.RevisionID(*revision),
|
|
Kind: contract.FluidTelemetryKind(*kind),
|
|
Limit: *limit,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(rows) == 0 {
|
|
fmt.Println("no telemetry")
|
|
return nil
|
|
}
|
|
|
|
w := out()
|
|
fmt.Fprintln(w, "WHEN\tKIND\tREVISION\tRESOLVED BY\tROUTE\tSTATUS")
|
|
for _, ev := range rows {
|
|
route, status := "", ""
|
|
if ev.Request != nil {
|
|
route = ev.Request.Route
|
|
if ev.Request.Status != nil {
|
|
status = fmt.Sprint(*ev.Request.Status)
|
|
}
|
|
}
|
|
reason := ""
|
|
if ev.Resolution != nil {
|
|
reason = string(ev.Resolution.Reason)
|
|
}
|
|
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n",
|
|
ev.OccurredAt.Format(time.RFC3339), ev.Kind, deref(ev.Revision), reason, route, status)
|
|
}
|
|
return w.Flush()
|
|
}
|
|
|
|
// ---------- audit ----------
|
|
|
|
// runAudit reconstructs the chain behind a revision.
|
|
//
|
|
// This command has to answer the eleven questions in FluidAPIStandards.md
|
|
// section 25. It is built from events rather than from summary records: the
|
|
// records say what is true now, the events say how it came to be, and only the
|
|
// second can settle a question about a decision taken months ago.
|
|
func runAudit(ctx context.Context, g globals, args []string) error {
|
|
if len(args) < 2 || args[0] != "trace" {
|
|
return errors.New("usage: fluid audit trace <revision>")
|
|
}
|
|
target := args[1]
|
|
|
|
iface, err := g.requireInterface()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
store, err := g.open(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer store.Close()
|
|
|
|
events, err := store.Events(ctx, evidence.EventFilter{EntityID: target})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(events) == 0 {
|
|
return fmt.Errorf("no history recorded for %s", target)
|
|
}
|
|
|
|
fmt.Printf("Audit trace for %s\n\n", target)
|
|
|
|
// Which intent governed it, and under what authority.
|
|
is := intent.New(store, contract.InterfaceID(iface))
|
|
if v, err := is.GoverningIntent(ctx, contract.RevisionID(target)); err == nil {
|
|
fmt.Printf("Governed by %s (%s) at authority mode %s\n\n", v.Version, v.Digest, v.Mode)
|
|
} else {
|
|
fmt.Printf("Governed by: UNKNOWN - no intent binding recorded\n\n")
|
|
}
|
|
|
|
// What evidence triggered it, and which hypotheses were considered.
|
|
hs := science.NewHypothesisStore(store, contract.InterfaceID(iface))
|
|
if err := printOrigins(ctx, store, hs, contract.RevisionID(target)); err != nil {
|
|
return err
|
|
}
|
|
|
|
// What happened, in order, and who authorized it.
|
|
fmt.Println("Lifecycle")
|
|
w := out()
|
|
fmt.Fprintln(w, " WHEN\tEVENT\tACTOR\tINPUTS\tREASON")
|
|
for _, ev := range events {
|
|
fmt.Fprintf(w, " %s\t%s\t%s:%s\t%s\t%s\n",
|
|
ev.OccurredAt.Format(time.RFC3339), ev.EventType,
|
|
ev.Actor.Type, ev.Actor.ID,
|
|
strings.Join(ev.Inputs, ","), truncate(oneLine(ev.Reason), 64))
|
|
}
|
|
if err := w.Flush(); err != nil {
|
|
return err
|
|
}
|
|
|
|
// What happened after deployment.
|
|
telemetry, err := store.Telemetry(ctx, evidence.TelemetryFilter{Revision: contract.RevisionID(target)})
|
|
if err == nil {
|
|
errorCount := 0
|
|
for _, ev := range telemetry {
|
|
if ev.Error != nil {
|
|
errorCount++
|
|
}
|
|
}
|
|
fmt.Printf("\nObserved\n %d telemetry event(s), %d of them errors\n", len(telemetry), errorCount)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// printOrigins reports the hypotheses and pressure behind a revision.
|
|
func printOrigins(ctx context.Context, store *evidence.SQLStore, hs *science.HypothesisStore, rev contract.RevisionID) error {
|
|
all, err := hs.List(ctx, "")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var origins []contract.FluidHypothesis
|
|
for _, h := range all {
|
|
for _, candidate := range h.CandidateRevisionRefs {
|
|
if candidate == rev {
|
|
origins = append(origins, h)
|
|
}
|
|
}
|
|
}
|
|
if len(origins) == 0 {
|
|
fmt.Println("Origins\n no hypothesis claims this revision")
|
|
fmt.Println()
|
|
return nil
|
|
}
|
|
|
|
fmt.Println("Origins")
|
|
w := out()
|
|
fmt.Fprintln(w, " HYPOTHESIS\tSTATE\tGROUP\tCLAIM")
|
|
for _, h := range origins {
|
|
group := ""
|
|
if h.Competition != nil {
|
|
group = h.Competition.GroupID
|
|
}
|
|
fmt.Fprintf(w, " %s\t%s\t%s\t%s\n",
|
|
h.ID, h.State, group, truncate(oneLine(h.Explanation.Claim), 56))
|
|
}
|
|
if err := w.Flush(); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Rivals matter: an audit asking which hypotheses were considered is not
|
|
// answered by naming only the one that won.
|
|
for _, h := range origins {
|
|
if h.Competition == nil || len(h.Competition.Alternatives) == 0 {
|
|
continue
|
|
}
|
|
fmt.Printf(" %s competed against %v in %s\n",
|
|
h.ID, h.Competition.Alternatives, h.Competition.GroupID)
|
|
}
|
|
|
|
fmt.Println()
|
|
return nil
|
|
}
|
|
|
|
// ---------- helpers ----------
|
|
|
|
func newFlagSet(name string) *flagSet { return &flagSet{fs: newStdFlagSet(name)} }
|
|
|
|
func oneLine(s string) string {
|
|
return strings.Join(strings.Fields(s), " ")
|
|
}
|
|
|
|
func deref(s *contract.RevisionID) string {
|
|
if s == nil {
|
|
return ""
|
|
}
|
|
return string(*s)
|
|
}
|
|
|
|
func operator() string {
|
|
if v := os.Getenv("FLUID_OPERATOR"); v != "" {
|
|
return v
|
|
}
|
|
if v := os.Getenv("USER"); v != "" {
|
|
return v
|
|
}
|
|
return "unknown"
|
|
}
|
|
|
|
// parseClasses splits a comma-separated adaptation class list.
|
|
func parseClasses(s string) []contract.AdaptationClass {
|
|
if strings.TrimSpace(s) == "" {
|
|
return nil
|
|
}
|
|
var out []contract.AdaptationClass
|
|
for _, part := range strings.Split(s, ",") {
|
|
if p := strings.TrimSpace(part); p != "" {
|
|
out = append(out, contract.AdaptationClass(p))
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// printReport renders the pipeline outcome stage by stage, so a rejection says
|
|
// which gate refused and why rather than only that it failed.
|
|
func printReport(r publish.Report) {
|
|
if len(r.Stages) == 0 {
|
|
return
|
|
}
|
|
w := out()
|
|
fmt.Fprintln(w, "STAGE\tRESULT\tDETAIL")
|
|
for _, s := range r.Stages {
|
|
verdict := "pass"
|
|
if !s.Passed {
|
|
verdict = "FAIL"
|
|
}
|
|
fmt.Fprintf(w, "%s\t%s\t%s\n", s.Stage, verdict, oneLine(s.Detail))
|
|
}
|
|
_ = w.Flush()
|
|
}
|
|
|
|
// loadSigner resolves the signing key for a publish.
|
|
//
|
|
// An ephemeral key must be requested explicitly: signing with a key nobody
|
|
// trusts produces revisions the router will refuse, and finding that out at
|
|
// publish time is far better than at traffic time.
|
|
func loadSigner(keyID, keyFile string, ephemeral bool) (*signing.Signer, error) {
|
|
if keyFile != "" {
|
|
raw, err := os.ReadFile(keyFile)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read signing key: %w", err)
|
|
}
|
|
decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(raw)))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("signing key is not valid base64: %w", err)
|
|
}
|
|
return signing.NewSigner(keyID, ed25519.PrivateKey(decoded))
|
|
}
|
|
if ephemeral {
|
|
signer, _, err := signing.GenerateKey(keyID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
fmt.Fprintln(os.Stderr,
|
|
"warning: signing with an ephemeral key; this revision will not verify after the key is gone")
|
|
return signer, nil
|
|
}
|
|
return nil, errors.New("no signing key: pass --key-file, or --ephemeral-key for development")
|
|
}
|
|
|
|
// yamlUnmarshal is a small indirection so science.go does not import yaml
|
|
// directly; the parser choice stays in one place.
|
|
func yamlUnmarshal(raw []byte, into any) error { return yaml.Unmarshal(raw, into) }
|
|
|
|
// governingMode resolves the authority mode governing a revision.
|
|
func governingMode(ctx context.Context, store *evidence.SQLStore, iface contract.InterfaceID, rev contract.RevisionID) (intent.AuthorityMode, error) {
|
|
is := intent.New(store, iface)
|
|
if v, err := is.GoverningIntent(ctx, rev); err == nil {
|
|
return v.Mode, nil
|
|
}
|
|
v, err := is.Active(ctx)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("no intent governs %s and none is active: %w", rev, err)
|
|
}
|
|
return v.Mode, nil
|
|
}
|