// Package cli: txn.go implements the `orca txn` subcommand family // (P10a, v0.11; REQ-075, REQ-079; gates C-09, C-23). Subcommands: // // orca txn apply --lead [--force --i-understand-the-risk | --namespace ] [--timeout 5m] // orca txn list // orca txn show // orca txn rollback --lead // // `apply` runs an already-staged txn on the lead peer via the txn // package. Cluster-wide txns (no --namespace) require --force + // --i-understand-the-risk (or --yes); namespace-scoped txns only // touch the given namespace (C-23). package cli import ( "context" "encoding/json" "errors" "fmt" "os" "path/filepath" "time" "github.com/spf13/cobra" "git.cloudinit.dev/coreci/orca/internal/certpaths" "git.cloudinit.dev/coreci/orca/internal/paths" "git.cloudinit.dev/coreci/orca/internal/sshpush" "git.cloudinit.dev/coreci/orca/internal/txn" ) var ( txnApplyForce bool txnApplyAckRisk bool txnApplyYes bool txnApplyNamespace string txnApplyTimeout time.Duration txnApplyLead string txnRollbackLead string ) // txnTransport is the SSH-push surface the txn CLI needs. *sshpush.Transport // satisfies it; tests substitute a mock (same pattern as drain.go / // logs.go). type txnTransport interface { WriteFileIdempotent(ctx context.Context, peer string, path string, content []byte, mode os.FileMode) (bool, error) Exec(ctx context.Context, peer string, cmd string) ([]byte, error) } // txnTransportOverride is the package-level seam. When non-nil it // replaces the production transport; tests set it and restore nil. var txnTransportOverride txnTransport func txnTransportFromCtx() (txnTransport, error) { if txnTransportOverride != nil { return txnTransportOverride, nil } keyPath := certpaths.SSHKeyPath() khPath := certpaths.KnownHostsPath() return sshpush.NewTransport(keyPath, khPath), nil } var txnCmd = &cobra.Command{ Use: "txn", Short: "Manage control-plane transactions (apply/list/show/rollback)", Long: `Manage orca's transactional control-plane updates (P10a). A transaction (txn) is a content-addressed desired-state bundle (apply.sh + verify.sh + rollback.sh + signed manifest) staged to the lead peer and applied idempotently. Cluster-wide txns require explicit operator acknowledgement (--force + --i-understand-the-risk, or --yes); namespace-scoped txns only touch the given namespace (C-23).`, } var txnApplyCmd = &cobra.Command{ Use: "apply ", Short: "Apply a staged txn on the lead peer", Long: `Apply a staged txn on the lead peer (idempotent; C-09 failure contract). The txn must already be staged on the lead under /run/orca/txns//. Cluster-wide txns (no --namespace) require --force + --i-understand-the-risk (or --yes for non-interactive). Namespace- scoped txns (--namespace ) only touch that namespace.`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { id := txn.TxnID(args[0]) transport, err := txnTransportFromCtx() if err != nil { return fmt.Errorf("ssh transport: %w", err) } opts := txn.ApplyOptions{ Force: txnApplyForce, AcknowledgeRisk: txnApplyAckRisk, Yes: txnApplyYes, Namespace: txnApplyNamespace, Timeout: txnApplyTimeout, } ctx := cmd.Context() if err := txn.Apply(ctx, id, txnApplyLead, transport, opts); err != nil { if errors.Is(err, txn.ErrAlreadyApplied) { printResult(fmt.Sprintf("✓ Txn %s already applied (no-op)", id), map[string]any{ "txn_id": id, "status": "already-applied", }) return nil } if errors.Is(err, txn.ErrClusterWideRequiresForce) { return fmt.Errorf("cluster-wide txn requires --force (C-23)") } if errors.Is(err, txn.ErrClusterWideRequiresAck) { return fmt.Errorf("cluster-wide --force requires --i-understand-the-risk (or --yes)") } return err } printResult(fmt.Sprintf("✓ Txn %s applied", id), map[string]any{ "txn_id": id, "status": "applied", "namespace": txnApplyNamespace, "lead": txnApplyLead, }) return nil }, } // txnListEntry is one row in `orca txn list` output. type txnListEntry struct { ID string `json:"txn_id"` Status string `json:"status"` } var txnListCmd = &cobra.Command{ Use: "list", Short: "List staged + applied transactions", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { dir := paths.TxnDir() entries, err := os.ReadDir(dir) if err != nil { if os.IsNotExist(err) { printResult("No transactions.", []txnListEntry{}) return nil } return fmt.Errorf("read txn dir: %w", err) } var rows []txnListEntry for _, e := range entries { if !e.IsDir() { continue } id := e.Name() status := "staged" if _, err := os.Stat(filepath.Join(dir, id, ".applied")); err == nil { status = "applied" } rows = append(rows, txnListEntry{ID: id, Status: status}) } if jsonOutput { return printJSON(rows) } out := cmd.OutOrStdout() if len(rows) == 0 { fmt.Fprintln(out, "No transactions.") return nil } fmt.Fprintf(out, "%-20s %s\n", "TXN-ID", "STATUS") for _, r := range rows { fmt.Fprintf(out, "%-20s %s\n", r.ID, r.Status) } return nil }, } var txnShowCmd = &cobra.Command{ Use: "show ", Short: "Show txn details (desired state, manifest, status)", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { id := args[0] dir := filepath.Join(paths.TxnDir(), id) manifestPath := filepath.Join(dir, "manifest.json") desiredPath := filepath.Join(dir, "desired-state.json") manifest, err := os.ReadFile(manifestPath) if err != nil { return fmt.Errorf("read manifest for %s: %w", id, err) } desired, err := os.ReadFile(desiredPath) if err != nil { return fmt.Errorf("read desired-state for %s: %w", id, err) } status := "staged" if _, err := os.Stat(filepath.Join(dir, ".applied")); err == nil { status = "applied" } var m txn.Manifest _ = json.Unmarshal(manifest, &m) result := map[string]any{ "txn_id": id, "status": status, "manifest": json.RawMessage(manifest), "desired_state": json.RawMessage(desired), } if jsonOutput { return printJSON(result) } out := cmd.OutOrStdout() fmt.Fprintf(out, "Txn: %s\n", id) fmt.Fprintf(out, "Status: %s\n", status) if m.Timestamp != "" { fmt.Fprintf(out, "Timestamp: %s\n", m.Timestamp) } fmt.Fprintln(out, "Files:") for _, f := range m.Files { short := f.SHA256 if len(short) > 16 { short = short[:16] } fmt.Fprintf(out, " %s %s\n", f.Name, short) } fmt.Fprintln(out, "Desired state:") fmt.Fprintln(out, string(desired)) return nil }, } var txnRollbackCmd = &cobra.Command{ Use: "rollback ", Short: "Manually rollback a txn on the lead peer", Long: `Run rollback.sh for a staged txn on the lead peer. This is the manual rollback path; orca-pull.sh runs rollback automatically on verify failure.`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { id := txn.TxnID(args[0]) transport, err := txnTransportFromCtx() if err != nil { return fmt.Errorf("ssh transport: %w", err) } ctx := cmd.Context() dir := "/run/orca/txns/" + string(id) cmdStr := fmt.Sprintf("bash %s/rollback.sh", dir) out, err := transport.Exec(ctx, txnRollbackLead, cmdStr) if err != nil { return fmt.Errorf("rollback %s on %s: %w (output: %s)", id, txnRollbackLead, err, string(out)) } printResult(fmt.Sprintf("✓ Txn %s rolled back", id), map[string]any{ "txn_id": id, "status": "rolled-back", "lead": txnRollbackLead, "output": string(out), }) return nil }, } func init() { txnApplyCmd.Flags().BoolVar(&txnApplyForce, "force", false, "override pre-flight checks (required for cluster-wide txns)") txnApplyCmd.Flags().BoolVar(&txnApplyAckRisk, "i-understand-the-risk", false, "acknowledge the risk of a cluster-wide --force txn") txnApplyCmd.Flags().BoolVar(&txnApplyYes, "yes", false, "non-interactive acknowledgement (equivalent to --i-understand-the-risk)") txnApplyCmd.Flags().StringVar(&txnApplyNamespace, "namespace", "", "namespace scope (empty = cluster-wide; requires --force + ack)") txnApplyCmd.Flags().DurationVar(&txnApplyTimeout, "timeout", 5*time.Minute, "apply+verify timeout") txnApplyCmd.Flags().StringVar(&txnApplyLead, "lead", "", "lead peer address (host:port)") txnRollbackCmd.Flags().StringVar(&txnRollbackLead, "lead", "", "lead peer address (host:port)") txnCmd.AddCommand(txnApplyCmd) txnCmd.AddCommand(txnListCmd) txnCmd.AddCommand(txnShowCmd) txnCmd.AddCommand(txnRollbackCmd) rootCmd.AddCommand(txnCmd) }