Add evidence store, intent store and the fluid CLI
Some checks failed
ci / build (push) Has been cancelled
Some checks failed
ci / build (push) Has been cancelled
Completes FLUID-WP-0003. The evidence store is append-only at the database, not by convention in Go: a trigger blocks UPDATE and DELETE on fluid_events in both SQLite and Postgres, so the guarantee binds a psql session and the CLI equally, not just callers who go through the Go API. Records are derived summary state and may be superseded; the event log stays the authority. The intent store makes a recorded version immutable and content addressed, since reassigning what governed a revision after the fact would break the one audit question Blueprint 27 exists to answer. The unfilled InterfaceEvolutionIntent template is rejected rather than defaulted, because a template still listing every FLUID-N mode has not been completed and defaulting it would pick a permissive authority by accident. The CLI reads the evidence store directly rather than through the control-plane API, so an operator can reconstruct history when the control plane is down. 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
791e419973
commit
d52dcc92a9
11 changed files with 1836 additions and 3 deletions
510
cmd/fluid/commands.go
Normal file
510
cmd/fluid/commands.go
Normal file
|
|
@ -0,0 +1,510 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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"
|
||||
)
|
||||
|
||||
// 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)")
|
||||
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")
|
||||
}
|
||||
// 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)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := store.PutRecord(ctx, contract.KindRevision, string(d.ID), body); 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 {
|
||||
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)
|
||||
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 is the command that has to answer the eleven questions in
|
||||
// FluidAPIStandards.md section 25. It is deliberately built from events rather
|
||||
// than from summary records: the records say what is true now, the events say
|
||||
// how it came to be.
|
||||
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)
|
||||
|
||||
is := intent.New(store, contract.InterfaceID(iface))
|
||||
if v, err := is.GoverningIntent(ctx, contract.RevisionID(target)); err == nil {
|
||||
fmt.Printf("Governed by %s (%s), authority mode %s\n\n", v.Version, v.Digest, v.Mode)
|
||||
} else {
|
||||
fmt.Printf("Governed by: UNKNOWN — no intent binding recorded\n\n")
|
||||
}
|
||||
|
||||
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, ","), oneLine(ev.Reason))
|
||||
}
|
||||
if err := w.Flush(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
count, err := store.Telemetry(ctx, evidence.TelemetryFilter{Revision: contract.RevisionID(target)})
|
||||
if err == nil {
|
||||
fmt.Printf("\n%d telemetry event(s) recorded against %s\n", len(count), target)
|
||||
}
|
||||
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"
|
||||
}
|
||||
23
cmd/fluid/flags.go
Normal file
23
cmd/fluid/flags.go
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"os"
|
||||
)
|
||||
|
||||
// flagSet wraps flag.FlagSet so subcommands report errors instead of exiting,
|
||||
// which keeps error handling in one place in run().
|
||||
type flagSet struct{ fs *flag.FlagSet }
|
||||
|
||||
func newStdFlagSet(name string) *flag.FlagSet {
|
||||
fs := flag.NewFlagSet(name, flag.ContinueOnError)
|
||||
fs.SetOutput(os.Stderr)
|
||||
return fs
|
||||
}
|
||||
|
||||
func (f *flagSet) String(name, value, usage string) *string { return f.fs.String(name, value, usage) }
|
||||
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) }
|
||||
144
cmd/fluid/main.go
Normal file
144
cmd/fluid/main.go
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
// Command fluid is the FLUID operator CLI.
|
||||
//
|
||||
// It reads and writes the evidence store directly rather than going through the
|
||||
// control-plane API, so that an operator can still inspect an interface's
|
||||
// history when the control plane is down. Auditability must not depend on the
|
||||
// availability of the thing being audited.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const usage = `fluid — FLUID operator CLI
|
||||
|
||||
Usage:
|
||||
fluid [--store PATH] [--interface ID] <command> [arguments]
|
||||
|
||||
Commands:
|
||||
intent put Record an interface evolution intent version
|
||||
intent activate Make a recorded version the governing intent
|
||||
intent show Show the active or a named intent version
|
||||
revision publish Publish a signed revision descriptor
|
||||
revision list List published revisions
|
||||
revision show Show one revision descriptor
|
||||
policy put Install a routing policy
|
||||
policy show Show the current routing policy
|
||||
events Show audit events
|
||||
telemetry Show recorded telemetry
|
||||
audit trace Reconstruct the history behind a revision
|
||||
|
||||
Global flags:
|
||||
--store PATH Evidence store path (default $FLUID_STORE or ./fluid.db)
|
||||
--interface ID Interface identifier (default $FLUID_INTERFACE)
|
||||
`
|
||||
|
||||
func main() {
|
||||
if err := run(os.Args[1:]); err != nil {
|
||||
if errors.Is(err, flag.ErrHelp) {
|
||||
os.Exit(0)
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, "fluid:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
type globals struct {
|
||||
store string
|
||||
iface string
|
||||
remaining []string
|
||||
}
|
||||
|
||||
func parseGlobals(args []string) (globals, error) {
|
||||
g := globals{
|
||||
store: envOr("FLUID_STORE", "fluid.db"),
|
||||
iface: os.Getenv("FLUID_INTERFACE"),
|
||||
}
|
||||
|
||||
i := 0
|
||||
for i < len(args) {
|
||||
switch args[i] {
|
||||
case "--store":
|
||||
if i+1 >= len(args) {
|
||||
return g, errors.New("--store needs a value")
|
||||
}
|
||||
g.store, i = args[i+1], i+2
|
||||
case "--interface":
|
||||
if i+1 >= len(args) {
|
||||
return g, errors.New("--interface needs a value")
|
||||
}
|
||||
g.iface, i = args[i+1], i+2
|
||||
case "-h", "--help", "help":
|
||||
fmt.Print(usage)
|
||||
return g, flag.ErrHelp
|
||||
default:
|
||||
if strings.HasPrefix(args[i], "--") {
|
||||
return g, fmt.Errorf("unknown global flag %q", args[i])
|
||||
}
|
||||
g.remaining = args[i:]
|
||||
return g, nil
|
||||
}
|
||||
}
|
||||
g.remaining = nil
|
||||
return g, nil
|
||||
}
|
||||
|
||||
func run(args []string) error {
|
||||
g, err := parseGlobals(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(g.remaining) == 0 {
|
||||
fmt.Print(usage)
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
cmd, rest := g.remaining[0], g.remaining[1:]
|
||||
|
||||
switch cmd {
|
||||
case "intent":
|
||||
return runIntent(ctx, g, rest)
|
||||
case "revision":
|
||||
return runRevision(ctx, g, rest)
|
||||
case "policy":
|
||||
return runPolicy(ctx, g, rest)
|
||||
case "events":
|
||||
return runEvents(ctx, g, rest)
|
||||
case "telemetry":
|
||||
return runTelemetry(ctx, g, rest)
|
||||
case "audit":
|
||||
return runAudit(ctx, g, rest)
|
||||
default:
|
||||
return fmt.Errorf("unknown command %q (try `fluid help`)", cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func envOr(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// requireInterface fails clearly rather than silently operating on the wrong
|
||||
// interface's history.
|
||||
func (g globals) requireInterface() (string, error) {
|
||||
if g.iface == "" {
|
||||
return "", errors.New("no interface selected: pass --interface or set FLUID_INTERFACE")
|
||||
}
|
||||
return g.iface, nil
|
||||
}
|
||||
|
||||
func (g globals) storePath() string {
|
||||
if abs, err := filepath.Abs(g.store); err == nil {
|
||||
return abs
|
||||
}
|
||||
return g.store
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue