package cli import ( "bytes" "context" "fmt" "os" "path/filepath" "strings" "testing" "time" "git.cloudinit.dev/coreci/orca/internal/migration" "git.cloudinit.dev/coreci/orca/internal/paths" ) type mockUpgradeRunner struct { calls []mockCall outputs map[string][]byte errs map[string]error fallback []byte } type mockCall struct { name string args []string } func (m *mockUpgradeRunner) Run(ctx context.Context, name string, args ...string) ([]byte, error) { m.calls = append(m.calls, mockCall{name: name, args: append([]string(nil), args...)}) key := name + " " + strings.Join(args, " ") if m.errs != nil { if err, ok := m.errs[key]; ok { return nil, err } } if m.outputs != nil { if out, ok := m.outputs[key]; ok { return out, nil } } return m.fallback, nil } type mockUpgradeTransport struct { calls []mockSSHDial errs map[string]error } type mockSSHDial struct { peer string cmd string } func (m *mockUpgradeTransport) Exec(ctx context.Context, peer string, cmd string) ([]byte, error) { m.calls = append(m.calls, mockSSHDial{peer: peer, cmd: cmd}) if m.errs != nil { if err, ok := m.errs[peer]; ok { return nil, err } } return []byte(""), nil } // mockCutoverFS is an in-memory cutoverFS for testing performCutover / // rollbackCutover without touching /etc/traefik (REQ-158, P09 T5). type mockCutoverFS struct { files map[string][]byte errs map[string]error // keyed by operation: "read:", "write:", "rename:", "stat:" } func newMockCutoverFS() *mockCutoverFS { return &mockCutoverFS{ files: make(map[string][]byte), errs: make(map[string]error), } } func (m *mockCutoverFS) ReadFile(path string) ([]byte, error) { if err, ok := m.errs["read:"+path]; ok { return nil, err } if data, ok := m.files[path]; ok { return data, nil } return nil, fmt.Errorf("mock: %s not found", path) } func (m *mockCutoverFS) WriteFile(path string, content []byte, mode os.FileMode) error { if err, ok := m.errs["write:"+path]; ok { return err } cp := make([]byte, len(content)) copy(cp, content) m.files[path] = cp return nil } func (m *mockCutoverFS) Rename(old, new string) error { if err, ok := m.errs["rename:"+old]; ok { return err } data, ok := m.files[old] if !ok { return fmt.Errorf("mock: rename source %s not found", old) } m.files[new] = data delete(m.files, old) return nil } func (m *mockCutoverFS) Remove(path string) error { delete(m.files, path) return nil } func (m *mockCutoverFS) Stat(path string) (os.FileInfo, error) { if err, ok := m.errs["stat:"+path]; ok { return nil, err } if _, ok := m.files[path]; ok { return mockFileInfo{name: path}, nil } return nil, fmt.Errorf("mock: %s not found", path) } type mockFileInfo struct{ name string } func (m mockFileInfo) Name() string { return m.name } func (m mockFileInfo) Size() int64 { return 0 } func (m mockFileInfo) Mode() os.FileMode { return 0o644 } func (m mockFileInfo) ModTime() time.Time { return time.Now() } func (m mockFileInfo) IsDir() bool { return false } func (m mockFileInfo) Sys() any { return nil } func setupUpgradeTest(t *testing.T) { t.Helper() t.Setenv("ORCA_HOME", t.TempDir()) } func resetUpgradeFlags() { upgradeTo = "" upgradeImportCA = false upgradeForce = false upgradeDryRun = false upgradeRunnerOverride = nil httpClientOverride = nil upgradeTransportOverride = nil peersListerOverride = nil migration.SetCAImporter(nil) } // setupUpgradeTestWithMocks calls resetRootFlags first (which resets // all package globals including upgrade overrides), then lets the // caller set mocks. Returns a buffer wired to rootCmd's output. func setupUpgradeTestWithMocks(t *testing.T) *bytes.Buffer { t.Helper() t.Cleanup(resetUpgradeFlags) resetRootFlags(t) var buf bytes.Buffer rootCmd.SetOut(&buf) rootCmd.SetErr(&buf) return &buf } func TestUpgradeCmdRegistered(t *testing.T) { found := false for _, cmd := range rootCmd.Commands() { if cmd.Name() == "upgrade" { found = true break } } if !found { t.Fatal("upgrade command not registered on root") } } func TestUpgradeRequiresToFlag(t *testing.T) { setupUpgradeTest(t) setupUpgradeTestWithMocks(t) rootCmd.SetArgs([]string{"upgrade"}) err := rootCmd.Execute() if err == nil { t.Fatal("upgrade without --to should fail") } if !strings.Contains(err.Error(), "--to is required") { t.Errorf("unexpected error: %v", err) } } func TestUpgradeDryRun(t *testing.T) { setupUpgradeTest(t) buf := setupUpgradeTestWithMocks(t) upgradeTo = "" rootCmd.SetArgs([]string{"upgrade", "--to", "v0.11.0", "--dry-run"}) if err := rootCmd.Execute(); err != nil { t.Fatalf("upgrade dry-run: %v", err) } out := buf.String() if !strings.Contains(out, "v0.11.0") { t.Errorf("output missing version: %s", out) } if !strings.Contains(out, "dry-run") { t.Errorf("output missing dry-run mention: %s", out) } } func TestUpgradeIdempotentSameVersion(t *testing.T) { setupUpgradeTest(t) buf := setupUpgradeTestWithMocks(t) saved := version version = "0.11.0" t.Cleanup(func() { version = saved }) rootCmd.SetArgs([]string{"upgrade", "--to", "0.11.0"}) if err := rootCmd.Execute(); err != nil { t.Fatalf("upgrade same version: %v", err) } out := buf.String() if !strings.Contains(out, "no-op") { t.Errorf("expected no-op message: %s", out) } } func TestUpgradeCutoverVerificationSuccess(t *testing.T) { setupUpgradeTest(t) setupUpgradeTestWithMocks(t) runner := &mockUpgradeRunner{ outputs: map[string][]byte{ "ss -tlnp": []byte(":443"), }, } upgradeRunnerOverride = runner httpClientOverride = func(url string) (int, error) { return 200, nil } // Provide a mock Traefik config so performCutover can read it. cfs := newMockCutoverFS() cfs.files["/etc/traefik/traefik.yml"] = []byte("entrypoint: :443\n") cutoverFSOverride = cfs t.Cleanup(func() { cutoverFSOverride = nil }) rootCmd.SetArgs([]string{"upgrade", "--to", "v0.11.0", "--force"}) if err := rootCmd.Execute(); err != nil { t.Fatalf("upgrade with cutover: %v", err) } } func TestUpgradeCutoverRollback(t *testing.T) { setupUpgradeTest(t) buf := setupUpgradeTestWithMocks(t) runner := &mockUpgradeRunner{ outputs: map[string][]byte{ "ss -tlnp": []byte(":443"), }, } upgradeRunnerOverride = runner httpClientOverride = func(url string) (int, error) { return 502, nil } // Provide a mock Traefik config so performCutover can read it. cfs := newMockCutoverFS() cfs.files["/etc/traefik/traefik.yml"] = []byte("entrypoint: :443\n") cutoverFSOverride = cfs t.Cleanup(func() { cutoverFSOverride = nil }) rootCmd.SetArgs([]string{"upgrade", "--to", "v0.11.0"}) err := rootCmd.Execute() if err == nil { t.Fatal("upgrade with failed cutover should return error") } if !strings.Contains(err.Error(), "cutover verification failed") && !strings.Contains(err.Error(), "rolled back") { t.Errorf("unexpected error: %v", err) } out := buf.String() if !strings.Contains(out, "Rolling back") { t.Errorf("output should mention rollback: %s", out) } // Verify rollback: the traefik.yml content should be restored to // :443 (the backup was renamed back over the modified file). restored, ok := cfs.files["/etc/traefik/traefik.yml"] if !ok { t.Fatal("rollback: traefik.yml missing after rollback") } if !strings.Contains(string(restored), ":443") { t.Errorf("rollback: traefik.yml not restored to :443, got: %s", string(restored)) } if strings.Contains(string(restored), "127.0.0.1:8443") { t.Errorf("rollback: traefik.yml still has 127.0.0.1:8443 after rollback: %s", string(restored)) } foundRollback := false for _, call := range runner.calls { if call.name == "nft" && len(call.args) >= 2 && call.args[0] == "delete" { foundRollback = true } } if !foundRollback { t.Errorf("rollback nft delete command not detected (calls: %v)", runner.calls) } } func TestUpgradeCutoverForceSkipsVerification(t *testing.T) { setupUpgradeTest(t) setupUpgradeTestWithMocks(t) runner := &mockUpgradeRunner{ outputs: map[string][]byte{ "ss -tlnp": []byte(":443"), }, } upgradeRunnerOverride = runner verificationCalled := false httpClientOverride = func(url string) (int, error) { verificationCalled = true return 200, nil } // Provide a mock Traefik config so performCutover can read it. cfs := newMockCutoverFS() cfs.files["/etc/traefik/traefik.yml"] = []byte("entrypoint: :443\n") cutoverFSOverride = cfs t.Cleanup(func() { cutoverFSOverride = nil }) rootCmd.SetArgs([]string{"upgrade", "--to", "v0.11.0", "--force"}) if err := rootCmd.Execute(); err != nil { t.Fatalf("upgrade with --force: %v", err) } if verificationCalled { t.Errorf("verification should be skipped with --force") } } func TestUpgradeC27OrcaUserCreation(t *testing.T) { setupUpgradeTest(t) buf := setupUpgradeTestWithMocks(t) runner := &mockUpgradeRunner{} upgradeRunnerOverride = runner peersListerOverride = func() ([]string, error) { return []string{"peer1.example.com", "peer2.example.com"}, nil } transport := &mockUpgradeTransport{} upgradeTransportOverride = transport rootCmd.SetArgs([]string{"upgrade", "--to", "v0.11.0", "--force"}) if err := rootCmd.Execute(); err != nil { t.Fatalf("upgrade with peer user creation: %v", err) } var useraddCalls int for _, call := range transport.calls { if strings.Contains(call.cmd, "useradd -r orca") { useraddCalls++ } } if useraddCalls != 2 { t.Errorf("useradd called %d times, want 2 (one per peer)", useraddCalls) } out := buf.String() if !strings.Contains(out, "orca system user") { t.Errorf("output should mention orca user creation: %s", out) } } func TestUpgradeTriggersV08Migration(t *testing.T) { dir := t.TempDir() t.Setenv("ORCA_HOME", dir) buf := setupUpgradeTestWithMocks(t) createTestV08DB(t, filepath.Join(dir, "orca.db")) if err := os.WriteFile(filepath.Join(dir, "ca.crt"), []byte("cert"), 0o644); err != nil { t.Fatalf("write ca.crt: %v", err) } if err := os.WriteFile(filepath.Join(dir, "ca.key"), []byte("key"), 0o644); err != nil { t.Fatalf("write ca.key: %v", err) } runner := &mockUpgradeRunner{} upgradeRunnerOverride = runner rootCmd.SetArgs([]string{"upgrade", "--to", "v0.11.0", "--dry-run"}) if err := rootCmd.Execute(); err != nil { t.Fatalf("upgrade with v0.8 layout: %v", err) } out := buf.String() if !strings.Contains(out, "v0.8 layout detected") { t.Errorf("output should mention v0.8 detection: %s", out) } migratedDB := filepath.Join(dir, paths.DefaultNamespace(), "db", "orca.db") if _, err := os.Stat(migratedDB); err == nil { t.Errorf("dry-run should not migrate the DB, but %s exists", migratedDB) } } func createTestV08DB(t *testing.T, path string) { t.Helper() if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatalf("mkdir: %v", err) } data := fmt.Sprintf("SQLite format 3\x00") if err := os.WriteFile(path, []byte(data), 0o644); err != nil { t.Fatalf("write db: %v", err) } } func TestUpgradeFullMigration(t *testing.T) { dir := t.TempDir() t.Setenv("ORCA_HOME", dir) setupUpgradeTestWithMocks(t) if err := os.MkdirAll(filepath.Join(dir, "cluster"), 0o755); err != nil { t.Fatalf("mkdir cluster: %v", err) } runner := &mockUpgradeRunner{} upgradeRunnerOverride = runner rootCmd.SetArgs([]string{"upgrade", "--to", "v0.11.0", "--force"}) if err := rootCmd.Execute(); err != nil { t.Fatalf("upgrade: %v", err) } var installCalled bool for _, call := range runner.calls { if call.name == "bash" && len(call.args) > 0 && strings.Contains(call.args[0], "install.sh") { installCalled = true } } if !installCalled { t.Errorf("install.sh was not invoked (calls: %v)", runner.calls) } } // TestCutoverBackupRestoreOnFailure verifies that when the cutover // verification fails, the Traefik config is restored from the backup // file (REQ-158, P09 T10). This is a unit-level test that calls // performCutover directly with a mock FS. func TestCutoverBackupRestoreOnFailure(t *testing.T) { // Set up a mock FS with a Traefik config containing :443. cfs := newMockCutoverFS() original := []byte("entrypoint:\n - :443\n") cfs.files["/etc/traefik/traefik.yml"] = original cutoverFSOverride = cfs t.Cleanup(func() { cutoverFSOverride = nil }) // Mock runner that succeeds for systemctl restart. runner := &mockUpgradeRunner{ outputs: make(map[string][]byte), } // Mock HTTP check returns 502 (failure). prevHTTP := httpClientOverride httpClientOverride = func(url string) (int, error) { return 502, nil } t.Cleanup(func() { httpClientOverride = prevHTTP }) var buf bytes.Buffer ok, err := performCutover(context.Background(), runner, &buf, false) if err != nil { t.Fatalf("performCutover: %v", err) } if ok { t.Fatal("expected cutover to fail (ok=false)") } // Verify the Traefik config was restored from backup. restored, exists := cfs.files["/etc/traefik/traefik.yml"] if !exists { t.Fatal("traefik.yml missing after rollback") } if string(restored) != string(original) { t.Errorf("traefik.yml not restored to original, got: %s", string(restored)) } // Verify 127.0.0.1:8443 is NOT in the restored file. if strings.Contains(string(restored), "127.0.0.1:8443") { t.Errorf("traefik.yml still has 127.0.0.1:8443 after rollback: %s", string(restored)) } // The backup file should have been consumed by rollbackCutover's rename. if _, bakExists := cfs.files["/etc/traefik/traefik.yml.bak"]; bakExists { t.Error("backup file still exists after rollback (should have been renamed)") } } // TestCutoverAtomicRenameSuccess verifies that the cutover writes the // new config via atomic rename (temp file → original) and cleans up // the backup on success (REQ-158, P09 T10). func TestCutoverAtomicRenameSuccess(t *testing.T) { cfs := newMockCutoverFS() original := []byte("entrypoint:\n - :443\n") cfs.files["/etc/traefik/traefik.yml"] = original cutoverFSOverride = cfs t.Cleanup(func() { cutoverFSOverride = nil }) runner := &mockUpgradeRunner{ outputs: make(map[string][]byte), } prevHTTP := httpClientOverride httpClientOverride = func(url string) (int, error) { return 200, nil } t.Cleanup(func() { httpClientOverride = prevHTTP }) var buf bytes.Buffer ok, err := performCutover(context.Background(), runner, &buf, false) if err != nil { t.Fatalf("performCutover: %v", err) } if !ok { t.Fatal("expected cutover to succeed (ok=true)") } // Verify the config was updated to 127.0.0.1:8443. updated, exists := cfs.files["/etc/traefik/traefik.yml"] if !exists { t.Fatal("traefik.yml missing after cutover") } if !strings.Contains(string(updated), "127.0.0.1:8443") { t.Errorf("traefik.yml should have 127.0.0.1:8443, got: %s", string(updated)) } if strings.Contains(string(updated), ":443\n") && !strings.Contains(string(updated), "127.0.0.1:8443") { t.Errorf("traefik.yml should not have bare :443 anymore, got: %s", string(updated)) } // The temp file should not exist. if _, tmpExists := cfs.files["/etc/traefik/traefik.yml.tmp"]; tmpExists { t.Error("temp file still exists after atomic rename") } // The backup should have been cleaned up on success. if _, bakExists := cfs.files["/etc/traefik/traefik.yml.bak"]; bakExists { t.Error("backup file still exists after successful cutover (should be cleaned up)") } } // TestCutoverBackupCreated verifies that a backup file is created // before the cutover edits the config (REQ-158, P09 T10). Uses a // custom mock FS that records the sequence of operations so we can // assert the backup was written before the temp file. func TestCutoverBackupCreated(t *testing.T) { // Use a recording mock FS that fails on the rename step so the // backup write is observable before the rollback consumes it. cfs := newMockCutoverFS() original := []byte("entrypoint:\n - :443\n") cfs.files["/etc/traefik/traefik.yml"] = original // Track write order via a custom FS that records operations. var writeOrder []string recordingCFS := &recordingCutoverFS{ inner: cfs, writeOrder: &writeOrder, } // Make the rename of the temp file fail so the cutover aborts. cfs.errs["rename:/etc/traefik/traefik.yml.tmp"] = fmt.Errorf("rename failed") cutoverFSOverride = recordingCFS t.Cleanup(func() { cutoverFSOverride = nil }) runner := &mockUpgradeRunner{ outputs: make(map[string][]byte), } var buf bytes.Buffer _, err := performCutover(context.Background(), runner, &buf, false) if err == nil { t.Fatal("expected error from failed rename") } // Verify the backup was written BEFORE the temp file. // writeOrder records WriteFile calls in order. bakIdx := -1 tmpIdx := -1 for i, p := range writeOrder { if p == "/etc/traefik/traefik.yml.bak" { bakIdx = i } if p == "/etc/traefik/traefik.yml.tmp" { tmpIdx = i } } if bakIdx == -1 { t.Fatal("backup file was not written before cutover") } if tmpIdx == -1 { t.Fatal("temp file was not written") } if bakIdx > tmpIdx { t.Errorf("backup written after temp file (bakIdx=%d, tmpIdx=%d) — backup should come first", bakIdx, tmpIdx) } // The original should have been restored from backup on failure. restored, exists := cfs.files["/etc/traefik/traefik.yml"] if !exists { t.Fatal("traefik.yml missing after failed rename + restore") } if string(restored) != string(original) { t.Errorf("traefik.yml not restored to original after failed rename, got: %s", string(restored)) } } // recordingCutoverFS wraps a cutoverFS and records WriteFile call // paths so tests can assert the order of operations (REQ-158, P09 T10). type recordingCutoverFS struct { inner cutoverFS writeOrder *[]string } func (r *recordingCutoverFS) ReadFile(path string) ([]byte, error) { return r.inner.ReadFile(path) } func (r *recordingCutoverFS) WriteFile(path string, content []byte, mode os.FileMode) error { *r.writeOrder = append(*r.writeOrder, path) return r.inner.WriteFile(path, content, mode) } func (r *recordingCutoverFS) Rename(old, new string) error { return r.inner.Rename(old, new) } func (r *recordingCutoverFS) Remove(path string) error { return r.inner.Remove(path) } func (r *recordingCutoverFS) Stat(path string) (os.FileInfo, error) { return r.inner.Stat(path) } // TestCutoverNoSedDirectly verifies that the cutover does NOT use // `sed -i` (the old unsafe approach). The mock runner records all // calls; none should be `sed` (REQ-158, P09 T5). func TestCutoverNoSedDirectly(t *testing.T) { cfs := newMockCutoverFS() cfs.files["/etc/traefik/traefik.yml"] = []byte("entrypoint:\n - :443\n") cutoverFSOverride = cfs t.Cleanup(func() { cutoverFSOverride = nil }) runner := &mockUpgradeRunner{ outputs: make(map[string][]byte), } prevHTTP := httpClientOverride httpClientOverride = func(url string) (int, error) { return 200, nil } t.Cleanup(func() { httpClientOverride = prevHTTP }) var buf bytes.Buffer _, _ = performCutover(context.Background(), runner, &buf, false) for _, call := range runner.calls { if call.name == "sed" { t.Errorf("cutover should not use 'sed' (uses atomic rename now), found call: %s %v", call.name, call.args) } } }