Files
orca/internal/txn/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

397 lines
11 KiB
Go

package txn
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"strings"
"sync"
"testing"
"git.cloudinit.dev/coreci/orca/internal/sshpush"
)
func testKey() []byte {
return []byte("0123456789abcdef0123456789abcdef")
}
func mustRender(t *testing.T, desired any) *Bundle {
t.Helper()
b, err := RenderBundle(desired, testKey())
if err != nil {
t.Fatalf("RenderBundle: %v", err)
}
return b
}
func TestRenderBundleValidIDAndSignature(t *testing.T) {
desired := []map[string]any{
{"path": "/etc/orca/x.conf", "content": "hello", "mode": "0644"},
}
b := mustRender(t, desired)
if !strings.HasPrefix(string(b.ID), "T-") {
t.Fatalf("ID %q missing T- prefix", b.ID)
}
if len(strings.TrimPrefix(string(b.ID), "T-")) != txnIDHexLen {
t.Fatalf("ID %q suffix not %d hex chars", b.ID, txnIDHexLen)
}
// Deterministic: same input => same ID.
b2 := mustRender(t, desired)
if b.ID != b2.ID {
t.Fatalf("non-deterministic ID: %s vs %s", b.ID, b2.ID)
}
// Manifest signature verifies.
if err := VerifyManifestSignature(b.Manifest, b.ManifestSig, testKey()); err != nil {
t.Fatalf("VerifyManifestSignature: %v", err)
}
// Manifest lists all four bundle files + desired-state.
var m Manifest
if err := json.Unmarshal(b.Manifest, &m); err != nil {
t.Fatalf("unmarshal manifest: %v", err)
}
if m.TxnID != b.ID {
t.Errorf("manifest TxnID %q != bundle ID %q", m.TxnID, b.ID)
}
names := map[string]bool{}
for _, e := range m.Files {
names[e.Name] = true
if e.SHA256 == "" {
t.Errorf("manifest entry %s missing sha256", e.Name)
}
}
for _, want := range []string{fileDesiredState, fileApply, fileVerify, fileRollback} {
if !names[want] {
t.Errorf("manifest missing file %s", want)
}
}
// Scripts are bash.
if !strings.HasPrefix(string(b.ApplyScript), "#!/usr/bin/env bash") {
t.Errorf("apply.sh not bash")
}
if !strings.HasPrefix(string(b.VerifyScript), "#!/usr/bin/env bash") {
t.Errorf("verify.sh not bash")
}
if !strings.HasPrefix(string(b.RollbackScript), "#!/usr/bin/env bash") {
t.Errorf("rollback.sh not bash")
}
}
func TestRenderBundleContentAddressed(t *testing.T) {
a := mustRender(t, []map[string]any{{"path": "/a"}})
b := mustRender(t, []map[string]any{{"path": "/b"}})
if a.ID == b.ID {
t.Fatalf("distinct desired states yielded same TxnID %s", a.ID)
}
}
func TestRenderBundleRejectsEmptyKey(t *testing.T) {
_, err := RenderBundle([]string{"x"}, nil)
if err == nil {
t.Fatal("RenderBundle with empty key should fail")
}
}
func TestVerifyManifestSignatureTampered(t *testing.T) {
b := mustRender(t, []map[string]any{{"path": "/x"}})
// Tamper the manifest content.
tampered := make([]byte, len(b.Manifest))
copy(tampered, b.Manifest)
tampered[0] ^= 0xff
err := VerifyManifestSignature(tampered, b.ManifestSig, testKey())
if !errors.Is(err, ErrSignatureMismatch) {
t.Fatalf("tampered manifest: got %v, want ErrSignatureMismatch", err)
}
// Tamper the signature (flip a hex char so the digest changes
// but stays valid hex).
badSig := make([]byte, len(b.ManifestSig))
copy(badSig, b.ManifestSig)
if len(badSig) > 0 {
// Flip the first hex char between 0 and 1.
if badSig[0] == '0' {
badSig[0] = '1'
} else {
badSig[0] = '0'
}
}
err = VerifyManifestSignature(b.Manifest, badSig, testKey())
if !errors.Is(err, ErrSignatureMismatch) {
t.Fatalf("tampered sig: got %v, want ErrSignatureMismatch", err)
}
// Wrong key.
wrongKey := []byte("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
err = VerifyManifestSignature(b.Manifest, b.ManifestSig, wrongKey)
if !errors.Is(err, ErrSignatureMismatch) {
t.Fatalf("wrong key: got %v, want ErrSignatureMismatch", err)
}
}
// mockTransport records all operations against an in-memory file map.
type mockTransport struct {
mu sync.Mutex
files map[string][]byte
execOut map[string][]byte
execErr map[string]error
writes []string
execs []string
}
func newMockTransport() *mockTransport {
return &mockTransport{
files: make(map[string][]byte),
execOut: make(map[string][]byte),
execErr: make(map[string]error),
}
}
func (m *mockTransport) WriteFileIdempotent(_ context.Context, _ string, path string, content []byte, _ os.FileMode) (bool, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.writes = append(m.writes, path)
if existing, ok := m.files[path]; ok && string(existing) == string(content) {
return false, nil
}
cp := make([]byte, len(content))
copy(cp, content)
m.files[path] = cp
return true, nil
}
func (m *mockTransport) Exec(_ context.Context, _ string, cmd string) ([]byte, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.execs = append(m.execs, cmd)
// Match by substring key in execOut/execErr maps.
for key, out := range m.execOut {
if strings.Contains(cmd, key) {
if err, ok := m.execErr[key]; ok {
return out, err
}
return out, nil
}
}
for key, err := range m.execErr {
if strings.Contains(cmd, key) {
return m.execOut[key], err
}
}
return nil, nil
}
func (m *mockTransport) hasFile(path string) bool {
m.mu.Lock()
defer m.mu.Unlock()
_, ok := m.files[path]
return ok
}
func TestStageWritesBundleFiles(t *testing.T) {
b := mustRender(t, []map[string]any{{"path": "/etc/orca/x.conf", "content": "hello"}})
mt := newMockTransport()
if err := Stage(b, "lead:22", mt); err != nil {
t.Fatalf("Stage: %v", err)
}
dir := remoteTxnDir(b.ID)
for _, name := range []string{fileDesiredState, fileApply, fileVerify, fileRollback, fileManifest, fileManifestSig} {
if !mt.hasFile(dir + "/" + name) {
t.Errorf("staged file missing: %s", name)
}
}
}
func TestStageNilBundle(t *testing.T) {
mt := newMockTransport()
if err := Stage(nil, "lead:22", mt); err == nil {
t.Fatal("Stage(nil) should fail")
}
}
func TestStageNilTransport(t *testing.T) {
b := mustRender(t, []map[string]any{{"path": "/x"}})
if err := Stage(b, "lead:22", nil); err == nil {
t.Fatal("Stage with nil transport should fail")
}
}
func TestApplyClusterWideSuccess(t *testing.T) {
b := mustRender(t, []map[string]any{{"path": "/etc/orca/x.conf", "content": "hello"}})
mt := newMockTransport()
if err := Stage(b, "lead:22", mt); err != nil {
t.Fatalf("Stage: %v", err)
}
mt.execOut["orca-pull.sh"] = []byte("applied")
err := Apply(context.Background(), b.ID, "lead:22", mt, ApplyOptions{
Force: true,
AcknowledgeRisk: true,
})
if err != nil {
t.Fatalf("Apply: %v", err)
}
if len(mt.execs) != 1 {
t.Fatalf("expected 1 exec, got %d", len(mt.execs))
}
cmd := mt.execs[0]
if !strings.Contains(cmd, "--force") {
t.Errorf("cmd missing --force: %s", cmd)
}
if !strings.Contains(cmd, "--i-understand-the-risk") {
t.Errorf("cmd missing --i-understand-the-risk: %s", cmd)
}
if strings.Contains(cmd, "--namespace") {
t.Errorf("cluster-wide cmd should not have --namespace: %s", cmd)
}
}
func TestApplyClusterWideYes(t *testing.T) {
b := mustRender(t, []map[string]any{{"path": "/x"}})
mt := newMockTransport()
_ = Stage(b, "lead:22", mt)
mt.execOut["orca-pull.sh"] = []byte("applied")
err := Apply(context.Background(), b.ID, "lead:22", mt, ApplyOptions{
Force: true,
Yes: true,
})
if err != nil {
t.Fatalf("Apply --yes: %v", err)
}
if !strings.Contains(mt.execs[0], "--yes") {
t.Errorf("cmd missing --yes: %s", mt.execs[0])
}
}
func TestApplyNamespaceScopedSuccess(t *testing.T) {
b := mustRender(t, []map[string]any{{"path": "/x"}})
mt := newMockTransport()
_ = Stage(b, "lead:22", mt)
mt.execOut["orca-pull.sh"] = []byte("applied")
err := Apply(context.Background(), b.ID, "lead:22", mt, ApplyOptions{
Namespace: "default",
})
if err != nil {
t.Fatalf("Apply ns-scoped: %v", err)
}
cmd := mt.execs[0]
if !strings.Contains(cmd, "--namespace") {
t.Errorf("ns-scoped cmd missing --namespace: %s", cmd)
}
if strings.Contains(cmd, "--force") {
t.Errorf("ns-scoped cmd should not have --force: %s", cmd)
}
}
func TestApplyClusterWideRefusesWithoutForce(t *testing.T) {
b := mustRender(t, []map[string]any{{"path": "/x"}})
mt := newMockTransport()
_ = Stage(b, "lead:22", mt)
err := Apply(context.Background(), b.ID, "lead:22", mt, ApplyOptions{})
if !errors.Is(err, ErrClusterWideRequiresForce) {
t.Fatalf("expected ErrClusterWideRequiresForce, got %v", err)
}
if len(mt.execs) != 0 {
t.Errorf("should not exec when --force missing")
}
}
func TestApplyClusterWideRefusesWithoutAck(t *testing.T) {
b := mustRender(t, []map[string]any{{"path": "/x"}})
mt := newMockTransport()
_ = Stage(b, "lead:22", mt)
err := Apply(context.Background(), b.ID, "lead:22", mt, ApplyOptions{Force: true})
if !errors.Is(err, ErrClusterWideRequiresAck) {
t.Fatalf("expected ErrClusterWideRequiresAck, got %v", err)
}
}
func TestApplyIdempotentNoOp(t *testing.T) {
b := mustRender(t, []map[string]any{{"path": "/x"}})
mt := newMockTransport()
_ = Stage(b, "lead:22", mt)
// Lead returns exit 5 = already-applied no-op.
mt.execErr["orca-pull.sh"] = fmt.Errorf("%w: exit 5", sshpush.ErrPermanent)
mt.execOut["orca-pull.sh"] = []byte("already-applied")
err := Apply(context.Background(), b.ID, "lead:22", mt, ApplyOptions{
Force: true,
AcknowledgeRisk: true,
})
if !errors.Is(err, ErrAlreadyApplied) {
t.Fatalf("expected ErrAlreadyApplied, got %v", err)
}
}
func TestApplyFailureReturnsError(t *testing.T) {
b := mustRender(t, []map[string]any{{"path": "/x"}})
mt := newMockTransport()
_ = Stage(b, "lead:22", mt)
mt.execErr["orca-pull.sh"] = fmt.Errorf("%w: exit 1", sshpush.ErrPermanent)
mt.execOut["orca-pull.sh"] = []byte("apply failure")
err := Apply(context.Background(), b.ID, "lead:22", mt, ApplyOptions{
Force: true,
AcknowledgeRisk: true,
})
if err == nil {
t.Fatal("Apply should fail on exit 1")
}
if errors.Is(err, ErrAlreadyApplied) {
t.Fatal("exit 1 should not be treated as already-applied")
}
}
func TestApplyNilTransport(t *testing.T) {
b := mustRender(t, []map[string]any{{"path": "/x"}})
err := Apply(context.Background(), b.ID, "lead:22", nil, ApplyOptions{
Force: true,
AcknowledgeRisk: true,
})
if err == nil {
t.Fatal("Apply with nil transport should fail")
}
}
func TestSignManifestDeterministic(t *testing.T) {
manifest := []byte(`{"txn_id":"T-abc"}`)
sig := signManifest(manifest, testKey())
mac := hmac.New(sha256.New, testKey())
mac.Write(manifest)
want := hex.EncodeToString(mac.Sum(nil))
if string(sig) != want {
t.Fatalf("signManifest: got %q want %q", sig, want)
}
}
func TestComputeTxnIDStable(t *testing.T) {
data := []byte(`{"x":1}`)
id, err := computeTxnID(data)
if err != nil {
t.Fatalf("computeTxnID: %v", err)
}
sum := sha256.Sum256(data)
h := hex.EncodeToString(sum[:])
want := TxnID("T-" + h[:txnIDHexLen])
if id != want {
t.Fatalf("computeTxnID: got %q want %q", id, want)
}
}