020aa01623
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---
247 lines
7.1 KiB
Go
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")
|
|
}
|
|
}
|