Files
orca/internal/cli/job_verify_test.go
T
Jon Chery 020aa01623 feat(P11,P12): orca job lint (REQ-084) + orca job verify (dry-run txn)
P11: orca job lint <spec.md> — schema/CEL/body/migration/best-practice
checks; --explain, --format json; exit 0/1 by errors found.
P12: orca job verify <spec.md> — dry-run txn (render + stage + verify
without apply); reports planned allocs/files/units; no side effects;
--namespace, --json.

---ci---
project: orca
phase: 11
milestone: v0.11
status: execute
---/ci---
2026-08-07 07:29:44 +00:00

247 lines
7.1 KiB
Go

package cli
import (
"bytes"
"context"
"encoding/json"
"os"
"strings"
"testing"
"git.cloudinit.dev/coreci/orca/internal/paths"
"git.cloudinit.dev/coreci/orca/internal/secrets"
)
// mockVerifyTransport is a record-and-replay mock of jobVerifyTransport.
type mockVerifyTransport struct {
writes []mockWriteCall
execs []string
execOut []byte
execErr error
}
type mockWriteCall struct {
peer string
path string
content []byte
mode os.FileMode
}
func (m *mockVerifyTransport) WriteFileIdempotent(_ context.Context, peer string, path string, content []byte, mode os.FileMode) (bool, error) {
m.writes = append(m.writes, mockWriteCall{peer, path, content, mode})
return true, nil
}
func (m *mockVerifyTransport) Exec(_ context.Context, _ string, cmd string) ([]byte, error) {
m.execs = append(m.execs, cmd)
return m.execOut, m.execErr
}
// setupVerifyEnv sets ORCA_HOME to a temp dir and writes a master key
// (txn.RenderBundle requires it).
func setupVerifyEnv(t *testing.T) string {
t.Helper()
dir := t.TempDir()
t.Setenv("ORCA_HOME", dir)
mk, err := secrets.GenerateMasterKey()
if err != nil {
t.Fatalf("GenerateMasterKey: %v", err)
}
if err := secrets.SaveMasterKey(paths.MasterKeyPath(), mk); err != nil {
t.Fatalf("SaveMasterKey: %v", err)
}
return dir
}
func TestJobVerifyCmdRegistered(t *testing.T) {
for _, c := range jobCmd.Commands() {
if c.Name() == "verify" {
return
}
}
t.Fatal("job verify command not registered on jobCmd")
}
func TestJobVerifyValidSpec(t *testing.T) {
setupVerifyEnv(t)
mt := &mockVerifyTransport{execOut: []byte("verified\n")}
jobVerifyTransportOverride = mt
defer func() { jobVerifyTransportOverride = nil }()
resetRootFlags(t)
spec := writeMDSpec(t, validJobMD)
var buf bytes.Buffer
rootCmd.SetOut(&buf)
rootCmd.SetErr(&buf)
rootCmd.SetArgs([]string{"job", "verify", spec, "--lead", "lead:22"})
if err := rootCmd.Execute(); err != nil {
t.Fatalf("job verify valid: %v", err)
}
out := buf.String()
if !strings.Contains(out, "Planned allocations") {
t.Errorf("expected planned allocs in output: %s", out)
}
if !strings.Contains(out, "systemd") && !strings.Contains(out, "unit") {
t.Errorf("expected systemd unit in planned files: %s", out)
}
if len(mt.writes) == 0 {
t.Errorf("expected bundle to be staged (writes), got 0")
}
if len(mt.execs) != 1 {
t.Errorf("expected 1 exec (verify.sh), got %d", len(mt.execs))
}
}
func TestJobVerifyInvalidSpecFails(t *testing.T) {
setupVerifyEnv(t)
mt := &mockVerifyTransport{}
jobVerifyTransportOverride = mt
defer func() { jobVerifyTransportOverride = nil }()
resetRootFlags(t)
spec := writeMDSpec(t, serviceMissingPortsMD)
var buf bytes.Buffer
rootCmd.SetOut(&buf)
rootCmd.SetErr(&buf)
rootCmd.SetArgs([]string{"job", "verify", spec, "--lead", "lead:22"})
if err := rootCmd.Execute(); err == nil {
t.Fatal("expected error for invalid spec, got nil")
}
if len(mt.writes) != 0 {
t.Errorf("should not stage bundle for invalid spec, got %d writes", len(mt.writes))
}
}
func TestJobVerifyJSONOutput(t *testing.T) {
setupVerifyEnv(t)
mt := &mockVerifyTransport{execOut: []byte("verified\n")}
jobVerifyTransportOverride = mt
defer func() { jobVerifyTransportOverride = nil }()
resetRootFlags(t)
spec := writeMDSpec(t, validJobMD)
var buf bytes.Buffer
rootCmd.SetOut(&buf)
rootCmd.SetErr(&buf)
rootCmd.SetArgs([]string{"job", "verify", spec, "--lead", "lead:22", "--json"})
if err := rootCmd.Execute(); err != nil {
t.Fatalf("job verify --json: %v", err)
}
var report verifyReport
if err := json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &report); err != nil {
t.Fatalf("unmarshal verify json: %v\n%s", err, buf.String())
}
if report.Name != "my-job" {
t.Errorf("report.Name = %q, want my-job", report.Name)
}
if len(report.PlannedFiles) == 0 {
t.Errorf("expected planned files, got 0")
}
if report.TxnID == "" {
t.Errorf("expected txn id, got empty")
}
}
func TestJobVerifyNamespaceScoping(t *testing.T) {
setupVerifyEnv(t)
mt := &mockVerifyTransport{execOut: []byte("verified\n")}
jobVerifyTransportOverride = mt
defer func() { jobVerifyTransportOverride = nil }()
resetRootFlags(t)
spec := writeMDSpec(t, validJobMD)
var buf bytes.Buffer
rootCmd.SetOut(&buf)
rootCmd.SetErr(&buf)
rootCmd.SetArgs([]string{"job", "verify", spec, "--lead", "lead:22", "--namespace", "prod"})
if err := rootCmd.Execute(); err != nil {
t.Fatalf("job verify --namespace: %v", err)
}
out := buf.String()
if !strings.Contains(out, "Namespace: prod") {
t.Errorf("expected namespace in output: %s", out)
}
}
func TestJobVerifyNoSideEffects(t *testing.T) {
setupVerifyEnv(t)
mt := &mockVerifyTransport{execOut: []byte("verified\n")}
jobVerifyTransportOverride = mt
defer func() { jobVerifyTransportOverride = nil }()
resetRootFlags(t)
spec := writeMDSpec(t, validJobMD)
var buf bytes.Buffer
rootCmd.SetOut(&buf)
rootCmd.SetErr(&buf)
rootCmd.SetArgs([]string{"job", "verify", spec, "--lead", "lead:22"})
if err := rootCmd.Execute(); err != nil {
t.Fatalf("job verify: %v", err)
}
// Staging writes the bundle files but apply is NOT run.
for _, w := range mt.writes {
if strings.Contains(w.path, ".applied") {
t.Errorf("verify staged a .applied marker (side effect): %s", w.path)
}
}
for _, c := range mt.execs {
if strings.Contains(c, "apply") {
t.Errorf("verify ran apply (side effect): %s", c)
}
if strings.Contains(c, "orca-pull.sh") {
t.Errorf("verify ran orca-pull.sh (side effect): %s", c)
}
}
}
func TestJobVerifyPreflightDriftReported(t *testing.T) {
setupVerifyEnv(t)
mt := &mockVerifyTransport{execOut: []byte("verify: drift /etc/systemd/system/orca-v1-my-job.service has drifted from last apply\n")}
jobVerifyTransportOverride = mt
defer func() { jobVerifyTransportOverride = nil }()
resetRootFlags(t)
spec := writeMDSpec(t, validJobMD)
var buf bytes.Buffer
rootCmd.SetOut(&buf)
rootCmd.SetErr(&buf)
rootCmd.SetArgs([]string{"job", "verify", spec, "--lead", "lead:22"})
// verify.sh exit 1 from drift is treated as a verify-failure by
// the mock (execErr). But here execOut is set and execErr is nil,
// so verify "passes" and drift is reported. We assert drift is
// surfaced in the output and does NOT fail the dry-run.
if err := rootCmd.Execute(); err != nil {
t.Fatalf("job verify with drift should not fail dry-run: %v", err)
}
out := buf.String()
if !strings.Contains(strings.ToLower(out), "drift") {
t.Errorf("expected drift in output: %s", out)
}
}
func TestJobVerifyMissingLeadFails(t *testing.T) {
setupVerifyEnv(t)
resetRootFlags(t)
spec := writeMDSpec(t, validJobMD)
var buf bytes.Buffer
rootCmd.SetOut(&buf)
rootCmd.SetErr(&buf)
rootCmd.SetArgs([]string{"job", "verify", spec})
if err := rootCmd.Execute(); err == nil {
t.Fatal("expected error for missing --lead, got nil")
}
}
func TestJobVerifyMissingMasterKeyFails(t *testing.T) {
t.Setenv("ORCA_HOME", t.TempDir())
resetRootFlags(t)
spec := writeMDSpec(t, validJobMD)
var buf bytes.Buffer
rootCmd.SetOut(&buf)
rootCmd.SetErr(&buf)
rootCmd.SetArgs([]string{"job", "verify", spec, "--lead", "lead:22"})
if err := rootCmd.Execute(); err == nil {
t.Fatal("expected error for missing master key, got nil")
}
}