4b70e31cf4
Critical fixes:
- logs --job: validate ^[A-Za-z0-9_-]+$ + shellQuote (was %q backtick RCE)
- pprof: isLoopback treats empty host as bind-all (was :6060 bypass)
- backup restore: filepath.Rel containment check (was tar-slip via a/../..)
- WebAuthn reg auth deferred to P04 (requires session infra)
High fixes:
- txn rollback/show/apply: validate ^T-[0-9a-f]{16}$ + shellQuote
- nft diff --against: validate txn ID before filepath.Join
- drain stopAlloc: validate allocID ^[A-Za-z0-9_-]+$
- cluster_compat: shellQuote peer dir name
- podman image: shellQuote (was %q backtick injection)
- nft TrustedProbes: net.ParseIP/CIDR validation + split v4/v6 sets
- sudoers: validate --proxmox-user/--proxmox-role ^[a-zA-Z_][a-zA-Z0-9_-]{0,31}$
fixed path /etc/sudoers.d/orca; shellQuote pveum/useradd; validateSudoers
checks actual file
- nft country block: validate ^[A-Z]{2}$ (was len==2 only)
New file: internal/cli/validate.go (shared validators + shellQuote)
All 38 Go test packages pass. go vet + gofmt clean.
---ci---
project: orca
phase: 2
milestone: v0.13
status: complete
requirements:
covered: [150]
---/ci---
284 lines
9.2 KiB
Go
284 lines
9.2 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/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 <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,
|
|
}
|
|
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 := cmd.Context()
|
|
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)")
|
|
|
|
txnCmd.AddCommand(txnApplyCmd)
|
|
txnCmd.AddCommand(txnListCmd)
|
|
txnCmd.AddCommand(txnShowCmd)
|
|
txnCmd.AddCommand(txnRollbackCmd)
|
|
rootCmd.AddCommand(txnCmd)
|
|
}
|