635e07e7a5
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---
401 lines
10 KiB
Go
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
|
|
}
|