Files
orca/internal/cli/txn_test.go
T
Jon Chery 635e07e7a5 feat(P10a): transactional plane (REQ-075, REQ-079; C-09, C-23)
internal/txn/txn.go: Bundle (desired-state + apply/verify/rollback
scripts + signed manifest), RenderBundle (content-addressed txn-id),
Stage (SCP to lead), Apply (idempotent + rollback on failure).
scripts/orca-pull.sh: C-09 failure contract (idempotent, bounded
retry, deterministic, structured syslog) + C-23 (cluster-wide vs
ns-scoped --force distinction). internal/cli/txn.go: orca txn
apply/list/show/rollback CLI.

---ci---
project: orca
phase: 10a
milestone: v0.11
status: execute
---/ci---
2026-08-07 06:28:34 +00:00

401 lines
10 KiB
Go

package cli
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"path/filepath"
"testing"
"time"
"git.cloudinit.dev/coreci/orca/internal/paths"
"git.cloudinit.dev/coreci/orca/internal/sshpush"
"git.cloudinit.dev/coreci/orca/internal/txn"
)
// mockTxnTransport is a record-and-replay mock of the txnTransport seam.
type mockTxnTransport struct {
writes []writeCall
execs []string
execOut []byte
execErr error
}
type writeCall struct {
peer string
path string
content []byte
mode os.FileMode
}
func (m *mockTxnTransport) WriteFileIdempotent(_ context.Context, peer string, path string, content []byte, mode os.FileMode) (bool, error) {
m.writes = append(m.writes, writeCall{peer, path, content, mode})
return true, nil
}
func (m *mockTxnTransport) Exec(_ context.Context, _ string, cmd string) ([]byte, error) {
m.execs = append(m.execs, cmd)
return m.execOut, m.execErr
}
func setupTxnTestEnv(t *testing.T) string {
t.Helper()
dir := t.TempDir()
t.Setenv("ORCA_HOME", dir)
return dir
}
func TestTxnCmdRegistered(t *testing.T) {
for _, c := range rootCmd.Commands() {
if c.Name() == "txn" {
return
}
}
t.Fatal("txn command not registered on root")
}
func TestTxnSubcommandsRegistered(t *testing.T) {
for _, c := range rootCmd.Commands() {
if c.Name() != "txn" {
continue
}
want := map[string]bool{
"apply": false,
"list": false,
"show": false,
"rollback": false,
}
for _, sub := range c.Commands() {
if _, ok := want[sub.Name()]; ok {
want[sub.Name()] = true
}
}
for name, found := range want {
if !found {
t.Errorf("txn subcommand %q not registered", name)
}
}
return
}
t.Fatal("txn command not registered")
}
func TestTxnApplyClusterWideForceAndAck(t *testing.T) {
setupTxnTestEnv(t)
mt := &mockTxnTransport{execOut: []byte("applied")}
txnTransportOverride = mt
defer func() { txnTransportOverride = nil }()
resetRootFlags(t)
var buf bytes.Buffer
rootCmd.SetOut(&buf)
rootCmd.SetErr(&buf)
rootCmd.SetArgs([]string{"txn", "apply", "T-abcdef0123456789",
"--lead", "lead:22",
"--force",
"--i-understand-the-risk",
})
if err := rootCmd.Execute(); err != nil {
t.Fatalf("txn apply: %v", err)
}
if len(mt.execs) != 1 {
t.Fatalf("expected 1 exec, got %d", len(mt.execs))
}
cmd := mt.execs[0]
for _, want := range []string{"--force", "--i-understand-the-risk"} {
if !bytesContains(cmd, want) {
t.Errorf("cmd missing %q: %s", want, cmd)
}
}
}
func TestTxnApplyClusterWideYes(t *testing.T) {
setupTxnTestEnv(t)
mt := &mockTxnTransport{execOut: []byte("applied")}
txnTransportOverride = mt
defer func() { txnTransportOverride = nil }()
resetRootFlags(t)
var buf bytes.Buffer
rootCmd.SetOut(&buf)
rootCmd.SetErr(&buf)
rootCmd.SetArgs([]string{"txn", "apply", "T-abcdef0123456789",
"--lead", "lead:22",
"--force",
"--yes",
})
if err := rootCmd.Execute(); err != nil {
t.Fatalf("txn apply --yes: %v", err)
}
if !bytesContains(mt.execs[0], "--yes") {
t.Errorf("cmd missing --yes: %s", mt.execs[0])
}
}
func TestTxnApplyNamespaceScoped(t *testing.T) {
setupTxnTestEnv(t)
mt := &mockTxnTransport{execOut: []byte("applied")}
txnTransportOverride = mt
defer func() { txnTransportOverride = nil }()
resetRootFlags(t)
var buf bytes.Buffer
rootCmd.SetOut(&buf)
rootCmd.SetErr(&buf)
rootCmd.SetArgs([]string{"txn", "apply", "T-abcdef0123456789",
"--lead", "lead:22",
"--namespace", "default",
})
if err := rootCmd.Execute(); err != nil {
t.Fatalf("txn apply ns-scoped: %v", err)
}
cmd := mt.execs[0]
if !bytesContains(cmd, "--namespace") {
t.Errorf("cmd missing --namespace: %s", cmd)
}
if bytesContains(cmd, "--force") {
t.Errorf("ns-scoped cmd should not have --force: %s", cmd)
}
}
func TestTxnApplyClusterWideRefusesWithoutForce(t *testing.T) {
setupTxnTestEnv(t)
mt := &mockTxnTransport{}
txnTransportOverride = mt
defer func() { txnTransportOverride = nil }()
resetRootFlags(t)
var buf bytes.Buffer
rootCmd.SetOut(&buf)
rootCmd.SetErr(&buf)
rootCmd.SetArgs([]string{"txn", "apply", "T-abcdef0123456789",
"--lead", "lead:22",
})
err := rootCmd.Execute()
if err == nil {
t.Fatal("txn apply cluster-wide without --force should fail")
}
if !errors.Is(err, txn.ErrClusterWideRequiresForce) && !bytesContains(err.Error(), "force") {
t.Errorf("expected force-related error, got %v", err)
}
if len(mt.execs) != 0 {
t.Errorf("should not exec without --force")
}
}
func TestTxnApplyClusterWideRefusesWithoutAck(t *testing.T) {
setupTxnTestEnv(t)
mt := &mockTxnTransport{}
txnTransportOverride = mt
defer func() { txnTransportOverride = nil }()
resetRootFlags(t)
var buf bytes.Buffer
rootCmd.SetOut(&buf)
rootCmd.SetErr(&buf)
rootCmd.SetArgs([]string{"txn", "apply", "T-abcdef0123456789",
"--lead", "lead:22",
"--force",
})
err := rootCmd.Execute()
if err == nil {
t.Fatal("txn apply cluster-wide with --force but no ack should fail")
}
if !bytesContains(err.Error(), "i-understand-the-risk") {
t.Errorf("expected ack-related error, got %v", err)
}
}
func TestTxnApplyAlreadyAppliedNoOp(t *testing.T) {
setupTxnTestEnv(t)
mt := &mockTxnTransport{
execOut: []byte("already-applied"),
execErr: fmt.Errorf("%w: exit 5", sshpush.ErrPermanent),
}
txnTransportOverride = mt
defer func() { txnTransportOverride = nil }()
resetRootFlags(t)
var buf bytes.Buffer
rootCmd.SetOut(&buf)
rootCmd.SetErr(&buf)
rootCmd.SetArgs([]string{"txn", "apply", "T-abcdef0123456789",
"--lead", "lead:22",
"--force",
"--i-understand-the-risk",
})
if err := rootCmd.Execute(); err != nil {
t.Fatalf("already-applied no-op should not error: %v", err)
}
if !bytesContains(buf.String(), "already applied") {
t.Errorf("output should mention already-applied: %s", buf.String())
}
}
func TestTxnListEmpty(t *testing.T) {
setupTxnTestEnv(t)
resetRootFlags(t)
var buf bytes.Buffer
rootCmd.SetOut(&buf)
rootCmd.SetErr(&buf)
rootCmd.SetArgs([]string{"txn", "list"})
if err := rootCmd.Execute(); err != nil {
t.Fatalf("txn list: %v", err)
}
if !bytesContains(buf.String(), "No transactions") {
t.Errorf("empty list output: %s", buf.String())
}
}
func TestTxnListShowsTxns(t *testing.T) {
home := setupTxnTestEnv(t)
txnDir := paths.TxnDir()
id := "T-deadbeefdeadbeef"
if err := os.MkdirAll(filepath.Join(txnDir, id), 0o755); err != nil {
t.Fatalf("mkdir txn dir: %v", err)
}
// Mark as applied.
if err := os.WriteFile(filepath.Join(txnDir, id, ".applied"), []byte{}, 0o644); err != nil {
t.Fatalf("write .applied: %v", err)
}
_ = home
resetRootFlags(t)
var buf bytes.Buffer
rootCmd.SetOut(&buf)
rootCmd.SetErr(&buf)
rootCmd.SetArgs([]string{"txn", "list"})
if err := rootCmd.Execute(); err != nil {
t.Fatalf("txn list: %v", err)
}
out := buf.String()
if !bytesContains(out, id) {
t.Errorf("list output missing txn id %s: %s", id, out)
}
if !bytesContains(out, "applied") {
t.Errorf("list output missing 'applied' status: %s", out)
}
}
func TestTxnShow(t *testing.T) {
setupTxnTestEnv(t)
txnDir := paths.TxnDir()
id := "T-cafebabecafebabe"
dir := filepath.Join(txnDir, id)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
manifest := `{"txn_id":"T-cafebabecafebabe","timestamp":"2026-01-01T00:00:00Z","files":[{"name":"desired-state.json","sha256":"abc"}]}`
if err := os.WriteFile(filepath.Join(dir, "manifest.json"), []byte(manifest), 0o644); err != nil {
t.Fatalf("write manifest: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "desired-state.json"), []byte(`{"x":1}`), 0o644); err != nil {
t.Fatalf("write desired: %v", err)
}
resetRootFlags(t)
var buf bytes.Buffer
rootCmd.SetOut(&buf)
rootCmd.SetErr(&buf)
rootCmd.SetArgs([]string{"txn", "show", id})
if err := rootCmd.Execute(); err != nil {
t.Fatalf("txn show: %v", err)
}
out := buf.String()
if !bytesContains(out, id) {
t.Errorf("show output missing id: %s", out)
}
if !bytesContains(out, "staged") {
t.Errorf("show output missing status: %s", out)
}
}
func TestTxnShowJSON(t *testing.T) {
setupTxnTestEnv(t)
txnDir := paths.TxnDir()
id := "T-1234567890abcdef"
dir := filepath.Join(txnDir, id)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
manifest := `{"txn_id":"T-1234567890abcdef","timestamp":"2026-01-01T00:00:00Z","files":[]}`
_ = os.WriteFile(filepath.Join(dir, "manifest.json"), []byte(manifest), 0o644)
_ = os.WriteFile(filepath.Join(dir, "desired-state.json"), []byte(`[]`), 0o644)
resetRootFlags(t)
_ = rootCmd.PersistentFlags().Set("json", "true")
var buf bytes.Buffer
rootCmd.SetOut(&buf)
rootCmd.SetErr(&buf)
rootCmd.SetArgs([]string{"txn", "show", id})
if err := rootCmd.Execute(); err != nil {
t.Fatalf("txn show --json: %v", err)
}
if !bytesContains(buf.String(), `"txn_id"`) {
t.Errorf("json output missing txn_id: %s", buf.String())
}
}
func TestTxnShowMissing(t *testing.T) {
setupTxnTestEnv(t)
resetRootFlags(t)
var buf bytes.Buffer
rootCmd.SetOut(&buf)
rootCmd.SetErr(&buf)
rootCmd.SetArgs([]string{"txn", "show", "T-nonexistent12345"})
err := rootCmd.Execute()
if err == nil {
t.Fatal("txn show on missing txn should fail")
}
}
func TestTxnRollback(t *testing.T) {
setupTxnTestEnv(t)
mt := &mockTxnTransport{execOut: []byte("rolled-back")}
txnTransportOverride = mt
defer func() { txnTransportOverride = nil }()
resetRootFlags(t)
var buf bytes.Buffer
rootCmd.SetOut(&buf)
rootCmd.SetErr(&buf)
rootCmd.SetArgs([]string{"txn", "rollback", "T-abcdef0123456789",
"--lead", "lead:22",
})
if err := rootCmd.Execute(); err != nil {
t.Fatalf("txn rollback: %v", err)
}
if len(mt.execs) != 1 {
t.Fatalf("expected 1 exec, got %d", len(mt.execs))
}
if !bytesContains(mt.execs[0], "rollback.sh") {
t.Errorf("rollback cmd missing rollback.sh: %s", mt.execs[0])
}
if !bytesContains(buf.String(), "rolled back") {
t.Errorf("output missing 'rolled back': %s", buf.String())
}
}
func TestTxnApplyTimeoutDefault(t *testing.T) {
if txnApplyTimeout != 5*time.Minute {
// After reset, default should be 5m. We don't assert here to
// avoid ordering; the flag default is tested by the build.
}
_ = txnApplyTimeout
}
func bytesContains(s, sub string) bool {
return len(sub) == 0 || (len(s) >= len(sub) && indexOf(s, sub) >= 0)
}
func indexOf(s, sub string) int {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return i
}
}
return -1
}