df58bc25a3
---ci--- project: orca phase: 3 milestone: v0.3 status: complete requirements: covered: [REQ-022, REQ-030, REQ-032] partial: [] ---/ci--- v0.3 milestone merged to main. Includes all v0.2 work (P08-P10) that was previously on the milestone branch but not yet merged to main, plus the v0.3 completion work (iter.Seq streaming + doctor network/db). v0.2 phases included: P08 (mTLS), P09 (scheduling), P10 (security scan). v0.3 phases: P0 (pre-execution), P1 (iter.Seq streaming), P2 (doctor), P3 (final review+ship). Total: 40 requirements, all complete. No new go.mod dependencies. Full test suite passes under -race. gofmt + go vet clean.
265 lines
6.9 KiB
Go
265 lines
6.9 KiB
Go
package cli
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"os/signal"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/spf13/cobra"
|
|
|
|
"git.cloudinit.dev/coreci/orca/internal/certpaths"
|
|
"git.cloudinit.dev/coreci/orca/internal/engine"
|
|
"git.cloudinit.dev/coreci/orca/internal/model"
|
|
"git.cloudinit.dev/coreci/orca/internal/security"
|
|
"git.cloudinit.dev/coreci/orca/internal/store"
|
|
)
|
|
|
|
func openDB() (*sql.DB, func() error, error) {
|
|
db, err := store.Open(certpaths.DBPath())
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return db, db.Close, nil
|
|
}
|
|
|
|
func newLogger() *slog.Logger {
|
|
return slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
|
}
|
|
|
|
func nodeRegistry() (*engine.NodeRegistry, func() error, error) {
|
|
db, closer, err := openDB()
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
repo := store.NewNodeRepo(db)
|
|
audit := engine.NewAudit(store.NewAuditRepo(db), newLogger())
|
|
return engine.NewNodeRegistry(repo, audit, newLogger()), closer, nil
|
|
}
|
|
|
|
var (
|
|
joinName string
|
|
joinAddr string
|
|
joinCAFinger string
|
|
leaveID string
|
|
nodeWatch bool
|
|
)
|
|
|
|
var nodeCmd = &cobra.Command{
|
|
Use: "node",
|
|
Short: "Manage orca nodes",
|
|
Long: "Join, leave, or list orca nodes in the registry.",
|
|
}
|
|
|
|
var nodeJoinCmd = &cobra.Command{
|
|
Use: "join",
|
|
Short: "Join a node to the orca registry",
|
|
Long: "Register a node in the local orca registry. Persisted to SQLite.",
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
if joinName == "" {
|
|
return fmt.Errorf("--name is required")
|
|
}
|
|
if joinAddr == "" {
|
|
joinAddr = "localhost:8443"
|
|
}
|
|
|
|
// REQ-026: if --ca-fingerprint is set, verify the on-disk CA
|
|
// matches the pinned value before we touch the registry. This
|
|
// prevents typos in the operator-supplied fingerprint from
|
|
// silently degrading to "no pin" and accepting any cert.
|
|
if joinCAFinger != "" {
|
|
fp, err := security.Fingerprint(certpaths.CACertPath())
|
|
if err != nil {
|
|
return fmt.Errorf("--ca-fingerprint set but local CA is missing: %w (run `orca cert ca-init` first)", err)
|
|
}
|
|
if fp != joinCAFinger {
|
|
return fmt.Errorf(
|
|
"CA fingerprint mismatch: on-disk=%s, pinned=%s — refusing to join (REQ-026)",
|
|
fp, joinCAFinger,
|
|
)
|
|
}
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(cmd.Context(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
registry, closer, err := nodeRegistry()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer closer()
|
|
|
|
node := &model.Node{
|
|
ID: uuid.NewString(),
|
|
Name: joinName,
|
|
Address: joinAddr,
|
|
State: model.NodeStateReady,
|
|
JoinedAt: time.Now().UTC(),
|
|
LastSeen: time.Now().UTC(),
|
|
}
|
|
if err := registry.Join(ctx, node); err != nil {
|
|
return err
|
|
}
|
|
if jsonOutput {
|
|
return printJSON(node)
|
|
}
|
|
fmt.Fprintf(cmd.OutOrStdout(), "✓ Node joined: %s (%s) at %s\n", node.ID, node.Name, node.Address)
|
|
return nil
|
|
},
|
|
}
|
|
|
|
var nodeLeaveCmd = &cobra.Command{
|
|
Use: "leave [node-id]",
|
|
Short: "Remove a node from the orca registry",
|
|
Long: "Mark a node as left. Use --id to specify, or pass as argument.",
|
|
Args: cobra.MaximumNArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
id := leaveID
|
|
if id == "" && len(args) > 0 {
|
|
id = args[0]
|
|
}
|
|
if id == "" {
|
|
return fmt.Errorf("node id required (use --id or pass as argument)")
|
|
}
|
|
ctx, cancel := context.WithTimeout(cmd.Context(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
registry, closer, err := nodeRegistry()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer closer()
|
|
|
|
if err := registry.Leave(ctx, id); err != nil {
|
|
return err
|
|
}
|
|
if jsonOutput {
|
|
return printJSON(map[string]string{"id": id, "state": "left"})
|
|
}
|
|
fmt.Fprintf(cmd.OutOrStdout(), "✓ Node left: %s\n", id)
|
|
return nil
|
|
},
|
|
}
|
|
|
|
var nodeListCmd = &cobra.Command{
|
|
Use: "list",
|
|
Short: "List all nodes in the orca registry",
|
|
Long: "Display all registered nodes and their state. Use --watch to stream updates until Ctrl-C.",
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
if nodeWatch {
|
|
return watchNodes(cmd)
|
|
}
|
|
ctx, cancel := context.WithTimeout(cmd.Context(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
registry, closer, err := nodeRegistry()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer closer()
|
|
|
|
nodes, err := registry.List(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if jsonOutput {
|
|
return printJSON(nodes)
|
|
}
|
|
if len(nodes) == 0 {
|
|
fmt.Fprintln(cmd.OutOrStdout(), "No nodes registered. Use 'orca node join' to add one.")
|
|
return nil
|
|
}
|
|
fmt.Fprintf(cmd.OutOrStdout(), "%-36s %-20s %-22s %-10s\n", "ID", "NAME", "ADDRESS", "STATE")
|
|
for _, n := range nodes {
|
|
fmt.Fprintf(cmd.OutOrStdout(), "%-36s %-20s %-22s %-10s\n", n.ID, n.Name, n.Address, n.State)
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
|
|
func watchNodes(cmd *cobra.Command) error {
|
|
ctx, cancel := signal.NotifyContext(cmd.Context(), os.Interrupt, syscall.SIGTERM)
|
|
defer cancel()
|
|
return watchNodesCtx(cmd, ctx)
|
|
}
|
|
|
|
func watchNodesCtx(cmd *cobra.Command, ctx context.Context) error {
|
|
db, closer, err := openDB()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer closer()
|
|
|
|
out := cmd.OutOrStdout()
|
|
|
|
if jsonOutput {
|
|
seen := make(map[string]string)
|
|
for snapshot := range store.NewNodeRepo(db).Watch(ctx) {
|
|
current := make(map[string]bool, len(snapshot))
|
|
for _, n := range snapshot {
|
|
current[n.ID] = true
|
|
compact, _ := json.Marshal(n)
|
|
key := string(compact)
|
|
if prev, ok := seen[n.ID]; !ok || prev != key {
|
|
event := "init"
|
|
if ok {
|
|
event = "update"
|
|
}
|
|
line, _ := json.Marshal(map[string]any{"event": event, "node": n})
|
|
fmt.Fprintln(out, string(line))
|
|
seen[n.ID] = key
|
|
}
|
|
}
|
|
for id := range seen {
|
|
if !current[id] {
|
|
line, _ := json.Marshal(map[string]any{"event": "delete", "id": id})
|
|
fmt.Fprintln(out, string(line))
|
|
delete(seen, id)
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
prevTable := ""
|
|
for snapshot := range store.NewNodeRepo(db).Watch(ctx) {
|
|
table := renderNodeTable(snapshot)
|
|
if table != prevTable {
|
|
fmt.Fprint(out, "\033[2J\033[H")
|
|
fmt.Fprint(out, table)
|
|
prevTable = table
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func renderNodeTable(nodes []*model.Node) string {
|
|
if len(nodes) == 0 {
|
|
return "No nodes registered.\n"
|
|
}
|
|
out := fmt.Sprintf("%-36s %-20s %-22s %-10s\n", "ID", "NAME", "ADDRESS", "STATE")
|
|
for _, n := range nodes {
|
|
out += fmt.Sprintf("%-36s %-20s %-22s %-10s\n", n.ID, n.Name, n.Address, n.State)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func init() {
|
|
nodeJoinCmd.Flags().StringVar(&joinName, "name", "", "node name (required)")
|
|
nodeJoinCmd.Flags().StringVar(&joinAddr, "addr", "", "node address (default localhost:8443)")
|
|
nodeJoinCmd.Flags().StringVar(&joinCAFinger, "ca-fingerprint", "", "pin CA cert SHA-256 (REQ-026); fails if on-disk CA doesn't match")
|
|
nodeLeaveCmd.Flags().StringVar(&leaveID, "id", "", "node id")
|
|
nodeListCmd.Flags().BoolVar(&nodeWatch, "watch", false, "stream nodes until Ctrl-C (table refresh or --json per-event)")
|
|
|
|
nodeCmd.AddCommand(nodeJoinCmd)
|
|
nodeCmd.AddCommand(nodeLeaveCmd)
|
|
nodeCmd.AddCommand(nodeListCmd)
|
|
rootCmd.AddCommand(nodeCmd)
|
|
}
|