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
160
cmd/fluid-control/main.go
Normal file
160
cmd/fluid-control/main.go
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
// Command fluid-control serves the FLUID control-plane APIs.
|
||||
//
|
||||
// It publishes revisions and records governance decisions. It is not on the
|
||||
// request path: ArchitectureBlueprint.md invariant 2 requires the data plane to
|
||||
// keep serving when this process is down, and that separation is only real if
|
||||
// nothing here is reachable from the gateway.
|
||||
//
|
||||
// This surface must never be exposed to interface consumers. It is the
|
||||
// mechanism that evolves the interface in response to their behaviour, and a
|
||||
// consumer able to reach it could drive its own adaptation.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
"github.com/tegwick/fluid-core/internal/contract"
|
||||
"github.com/tegwick/fluid-core/internal/control"
|
||||
"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"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
log.Fatalf("fluid-control: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func run() error {
|
||||
var (
|
||||
addr = flag.String("addr", "127.0.0.1:8081", "listen address")
|
||||
store = flag.String("store", envOr("FLUID_STORE", "fluid.db"), "evidence store path")
|
||||
iface = flag.String("interface", os.Getenv("FLUID_INTERFACE"), "interface identifier")
|
||||
keyID = flag.String("key-id", envOr("FLUID_SIGNING_KEY_ID", "dev"), "signing key identifier")
|
||||
keyFile = flag.String("key-file", os.Getenv("FLUID_SIGNING_KEY"), "base64 ed25519 private key file")
|
||||
ephemeral = flag.Bool("ephemeral-key", false, "generate a throwaway signing key (development only)")
|
||||
)
|
||||
flag.Parse()
|
||||
|
||||
if *iface == "" {
|
||||
return errors.New("--interface is required")
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
ev, err := evidence.OpenSQLite(ctx, *store)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer ev.Close()
|
||||
|
||||
signer, err := loadSigner(*keyID, *keyFile, *ephemeral)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
intents := intent.New(ev, contract.InterfaceID(*iface))
|
||||
gate := policy.NewGate(policy.DefaultLimits())
|
||||
|
||||
pipeline, err := publish.New(publish.Options{
|
||||
Gate: gate,
|
||||
Signer: signer,
|
||||
Store: ev,
|
||||
Intents: intents,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: *addr,
|
||||
Handler: control.NewServer(control.NewRevisionAPI(ev, pipeline), control.NewIntentAPI(intents, gate)).Routes(),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
shutdown, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
_ = srv.Shutdown(shutdown)
|
||||
}()
|
||||
|
||||
log.Printf("control plane for %s listening on %s (store %s, signing key %s)",
|
||||
*iface, *addr, *store, signer.KeyID())
|
||||
log.Printf("public key: %s", base64.StdEncoding.EncodeToString(signer.PublicKey()))
|
||||
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadSigner resolves the signing key.
|
||||
//
|
||||
// An ephemeral key must be asked for explicitly. Generating one silently when
|
||||
// none is configured would mean a deployment could sign revisions with a key
|
||||
// nobody trusts and never notice until the router refused them.
|
||||
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(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
|
||||
}
|
||||
log.Print("WARNING: using an ephemeral signing key; revisions signed now " +
|
||||
"will not verify after a restart")
|
||||
return signer, nil
|
||||
}
|
||||
|
||||
return nil, errors.New("no signing key: pass --key-file, or --ephemeral-key for development")
|
||||
}
|
||||
|
||||
func trimSpace(s string) string {
|
||||
start, end := 0, len(s)
|
||||
for start < end && isSpace(s[start]) {
|
||||
start++
|
||||
}
|
||||
for end > start && isSpace(s[end-1]) {
|
||||
end--
|
||||
}
|
||||
return s[start:end]
|
||||
}
|
||||
|
||||
func isSpace(b byte) bool {
|
||||
return b == ' ' || b == '\t' || b == '\n' || b == '\r'
|
||||
}
|
||||
|
||||
func envOr(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
|
@ -2,6 +2,8 @@ package main
|
|||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
|
@ -17,6 +19,9 @@ import (
|
|||
"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"
|
||||
)
|
||||
|
||||
// open connects to the evidence store for this invocation.
|
||||
|
|
@ -127,6 +132,13 @@ func runRevision(ctx context.Context, g globals, args []string) error {
|
|||
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
|
||||
}
|
||||
|
|
@ -150,43 +162,54 @@ func runRevision(ctx context.Context, g globals, args []string) error {
|
|||
if d.ID == "" {
|
||||
return errors.New("descriptor has no revision id")
|
||||
}
|
||||
// The router refuses unsigned descriptors at runtime; refusing them here
|
||||
// as well means an operator finds out at publish time rather than when
|
||||
// traffic starts failing.
|
||||
if d.Signature == nil {
|
||||
fmt.Fprintln(os.Stderr,
|
||||
"warning: descriptor is unsigned; the router will refuse it once signature enforcement is enabled")
|
||||
}
|
||||
|
||||
body, err := json.Marshal(d)
|
||||
// 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
|
||||
}
|
||||
if err := store.PutRecord(ctx, contract.KindRevision, string(d.ID), body); err != nil {
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
if err := store.AppendEvent(ctx, contract.FluidEvent{
|
||||
SchemaVersion: "0.1",
|
||||
ID: contract.EventID(fmt.Sprintf("EV-pub-%s-%d", d.ID, time.Now().UnixNano())),
|
||||
OccurredAt: time.Now().UTC(),
|
||||
EntityType: contract.FluidEventEntityTypeRevision,
|
||||
EntityID: string(d.ID),
|
||||
EventType: "REVISION_PUBLISHED",
|
||||
Actor: contract.Actor{Type: contract.ActorTypeHuman, ID: operator()},
|
||||
Reason: fmt.Sprintf("published %s in state %s", d.ID, d.State),
|
||||
}); err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
// Binding the revision to its governing intent is what makes the later
|
||||
// audit question answerable (Blueprint 27).
|
||||
is := intent.New(store, contract.InterfaceID(iface))
|
||||
if err := is.Bind(ctx, d.ID, d.Intent.Version); err != nil {
|
||||
return fmt.Errorf("published, but binding to intent %s failed: %w", d.Intent.Version, err)
|
||||
}
|
||||
|
||||
fmt.Printf("published %s (%s), governed by %s\n", d.ID, d.State, d.Intent.Version)
|
||||
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":
|
||||
|
|
@ -508,3 +531,64 @@ func operator() string {
|
|||
}
|
||||
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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,4 +20,7 @@ func (f *flagSet) Bool(name string, value bool, usage string) *bool {
|
|||
return f.fs.Bool(name, value, usage)
|
||||
}
|
||||
func (f *flagSet) Int(name string, value int, usage string) *int { return f.fs.Int(name, value, usage) }
|
||||
func (f *flagSet) Parse(args []string) error { return f.fs.Parse(args) }
|
||||
func (f *flagSet) Float64(name string, value float64, usage string) *float64 {
|
||||
return f.fs.Float64(name, value, usage)
|
||||
}
|
||||
func (f *flagSet) Parse(args []string) error { return f.fs.Parse(args) }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue