package cli import ( "context" "database/sql" "encoding/json" "fmt" "log/slog" "os" "os/signal" "path/filepath" "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 dbPath() string { if p := os.Getenv("ORCA_DB"); p != "" { return p } home, _ := os.UserHomeDir() return filepath.Join(home, ".orca", "orca.db") } func openDB() (*sql.DB, func() error, error) { db, err := store.Open(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) }