package cli import ( "bytes" "context" "fmt" "os" "path/filepath" "strings" "testing" "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 } 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 } 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 } 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) } foundRollback := false for _, call := range runner.calls { if call.name == "sed" && len(call.args) >= 2 { joined := strings.Join(call.args, " ") if strings.Contains(joined, "127.0.0.1:8443") && strings.Contains(joined, ":443") { foundRollback = true } } if call.name == "nft" && len(call.args) >= 2 && call.args[0] == "delete" { foundRollback = true } } if !foundRollback { t.Errorf("rollback commands 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 } 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) } }