Files
orca/internal/cli/upgrade_test.go
T
Jon Chery 9a28dc907b feat(P14a): v0.8→v1.0 data migration (REQ-066, C-07) + orca upgrade (REQ-115, C-25, C-27)
internal/migration/migrate.go: Migratev08tov11 (flat→multi-ns, schema
migration, CA import to step-ca, config.hcl preserve). internal/cli/
upgrade.go: orca upgrade --to (thin wrapper, R-017 binding cutover
with C-25 post-verify+rollback, C-27 orca user creation, --import-ca,
--dry-run). Tests: detect/migrate/dry-run/idempotent, cutover verify/
rollback, user creation.

---ci---
project: orca
phase: 14a
milestone: v0.11
status: execute
---/ci---
2026-08-07 08:00:10 +00:00

342 lines
8.6 KiB
Go

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)
}
}