Files
orca/internal/cli/txn.go
T
Jon Chery 3a3ea74d76 fix(P08): transport + SSH safety — typed errors, IPv6, timeouts, signal (REQ-157)
- transport.IsTransient: typed sentinels (ErrTransient/ErrPermanent) +
  standard net.Error/io errors.Is; substring matching removed
- sshpush.isTransient: same typed-error classification
- rotateSSHKeys: 2-phase atomic swap (stage peers -> swap local ->
  verify -> cleanup old); no more partial-result window
- known_hosts: dial() reads stored field (was reading v0.8 path directly)
- IPv6: net.JoinHostPort in proxmox SSH dial + drain splitHostPort
- SSH timeouts: context.WithTimeout on peer-setup, drift, txn rollback,
  job restart (default 2m)
- verifyCutover: orca CA pool TLS config (was default http.Client)
- OIDC callback: ReadHeaderTimeout 5s (slowloris defense)
- root Execute: signal.NotifyContext for SIGINT/SIGTERM (clean exit
  for non-watch commands)

Tests: typed-error classification table, IPv6 JoinHostPort, signal
handler context cancellation.

---ci---
project: orca
phase: 8
milestone: v0.13
status: complete
requirements:
  covered: [157]
---/ci---
2026-08-10 13:11:07 +00:00

314 lines
10 KiB
Go

// 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 <txn-id> --lead <peer> [--force --i-understand-the-risk | --namespace <ns>] [--timeout 5m]
// orca txn list
// orca txn show <txn-id>
// orca txn rollback <txn-id> --lead <peer>
//
// `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/identity"
"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
txnRollbackTimeout time.Duration
)
// 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
// txnAuthorizeOverride is the package-level seam for the OIDC auth
// hook (P04, T4). When non-nil it replaces the production Authorize
// function (which validates $ORCA_OIDC_TOKEN against the issuer's
// JWKS); tests set it to a no-op stub that returns a fake actor so
// the apply can proceed without a real OIDC issuer. Production code
// leaves this nil so the real auth hook runs.
var txnAuthorizeOverride func(ctx context.Context) (string, error)
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 <txn-id>",
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/<txn-id>/.
Cluster-wide txns (no --namespace) require --force +
--i-understand-the-risk (or --yes for non-interactive). Namespace-
scoped txns (--namespace <ns>) only touch that namespace.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
// F4: validate txn ID before interpolation into a remote shell
// command and filesystem path.
if !validTxnID(args[0]) {
return fmt.Errorf("txn apply: invalid txn id %q (expected T-[0-9a-f]{16})", args[0])
}
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,
// P04 (C-44): validate $ORCA_OIDC_TOKEN against the issuer's
// JWKS before applying. The verified sub is threaded into
// the audit actor field (T5). When oidc.issuer is unset,
// the hook returns an error and the apply is refused.
Authorize: func(ctx context.Context) (string, error) {
if txnAuthorizeOverride != nil {
return txnAuthorizeOverride(ctx)
}
cfg, err := loadOIDCConfig()
if err != nil {
return "", fmt.Errorf("load oidc config: %w", err)
}
claims, err := identity.VerifyOperatorToken(ctx, cfg.Issuer, cfg.ClientID)
if err != nil {
return "", err
}
return identity.OperatorActor(claims), nil
},
}
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 <txn-id>",
Short: "Show txn details (desired state, manifest, status)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
// F4: validate txn ID before interpolation into a filesystem path.
if !validTxnID(args[0]) {
return fmt.Errorf("txn show: invalid txn id %q (expected T-[0-9a-f]{16})", args[0])
}
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 <txn-id>",
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 {
// F4: validate txn ID before interpolation into a remote shell
// command (bash <dir>/rollback.sh) and filesystem path.
if !validTxnID(args[0]) {
return fmt.Errorf("txn rollback: invalid txn id %q (expected T-[0-9a-f]{16})", args[0])
}
id := txn.TxnID(args[0])
transport, err := txnTransportFromCtx()
if err != nil {
return fmt.Errorf("ssh transport: %w", err)
}
ctx, cancel := sshCmdCtx(cmd.Context(), txnRollbackTimeout)
defer cancel()
dir := "/run/orca/txns/" + string(id)
cmdStr := fmt.Sprintf("bash %s/rollback.sh", shellQuote(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)")
txnRollbackCmd.Flags().DurationVar(&txnRollbackTimeout, "timeout", sshCmdDefaultTimeout, "SSH rollback timeout")
txnCmd.AddCommand(txnApplyCmd)
txnCmd.AddCommand(txnListCmd)
txnCmd.AddCommand(txnShowCmd)
txnCmd.AddCommand(txnRollbackCmd)
rootCmd.AddCommand(txnCmd)
}