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
160 lines
4.4 KiB
Go
160 lines
4.4 KiB
Go
// 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
|
|
}
|