diff --git a/internal/cli/namespace_test.go b/internal/cli/namespace_test.go index 43d687a..7487e6b 100644 --- a/internal/cli/namespace_test.go +++ b/internal/cli/namespace_test.go @@ -10,6 +10,8 @@ import ( "git.cloudinit.dev/coreci/orca/internal/certpaths" + "git.cloudinit.dev/coreci/orca/internal/migration" + "github.com/spf13/cobra" ) @@ -61,6 +63,15 @@ func resetCommandFlags() { jobVerifyNamespace = "" jobVerifyJSON = false peerSetupNoOrcaUser = false + upgradeTo = "" + upgradeImportCA = false + upgradeForce = false + upgradeDryRun = false + upgradeRunnerOverride = nil + httpClientOverride = nil + upgradeTransportOverride = nil + peersListerOverride = nil + migration.SetCAImporter(nil) resetNSFlags() // Reset per-command output writers so tests that polluted them // (e.g. daemon tests calling cmd.SetOut(&buf)) don't leak into diff --git a/internal/cli/upgrade.go b/internal/cli/upgrade.go new file mode 100644 index 0000000..fe371f2 --- /dev/null +++ b/internal/cli/upgrade.go @@ -0,0 +1,398 @@ +// Package cli: upgrade.go implements the `orca upgrade --to ` +// subcommand (REQ-115, gates C-25 and C-27). It is a thin wrapper +// around scripts/install.sh + orca restore that also handles the +// R-017 binding cutover (Traefik :443 → 127.0.0.1:8443 + nftables) for +// existing v0.9/v0.10 clusters, creates the `orca` system user on +// existing peers (C-27, needed before P10b drift detection works), +// and optionally imports the v0.8 internal CA into step-ca. +package cli + +import ( + "context" + "fmt" + "log/slog" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/spf13/cobra" + + "git.cloudinit.dev/coreci/orca/internal/migration" + "git.cloudinit.dev/coreci/orca/internal/paths" +) + +var ( + upgradeTo string + upgradeImportCA bool + upgradeForce bool + upgradeDryRun bool +) + +// commandRunner is the seam for running shell commands (install.sh, +// nft, useradd). Tests inject a mock; production uses execRunner. +type commandRunner interface { + Run(ctx context.Context, name string, args ...string) ([]byte, error) +} + +// execRunner runs commands via os/exec. +type execRunner struct{} + +func (execRunner) Run(ctx context.Context, name string, args ...string) ([]byte, error) { + cmd := exec.CommandContext(ctx, name, args...) + return cmd.CombinedOutput() +} + +// upgradeRunnerOverride is the package-level test seam for the +// command runner. +var upgradeRunnerOverride commandRunner + +// httpClientOverride is the package-level test seam for the cutover +// verification HTTP check (C-25). Tests inject a mock. +var httpClientOverride func(url string) (int, error) + +// upgradeTransport is the SSH surface the upgrade command needs for +// C-27 orca user creation on peers. Mirrors peerSetupTransport. +type upgradeTransport interface { + Exec(ctx context.Context, peer string, cmd string) ([]byte, error) +} + +// upgradeTransportOverride is the package-level test seam for the +// SSH transport. +var upgradeTransportOverride upgradeTransport + +// peersListerOverride is the package-level test seam for listing +// peers to create the orca user on. Returns a list of peer addresses. +var peersListerOverride func() ([]string, error) + +var upgradeCmd = &cobra.Command{ + Use: "upgrade", + Short: "Upgrade orca to a new version (REQ-115, R-017 cutover)", + Long: `orca upgrade --to is a thin wrapper around +install.sh + orca restore. It handles: + - R-017 binding cutover: Traefik :443 → 127.0.0.1:8443 + nftables + for existing v0.9/v0.10 clusters (with C-25 post-cutover + verification and rollback on failure) + - C-27: creates the 'orca' system user on existing peers + (useradd -r orca, idempotent) — needed before P10b drift detection + - Optional --import-ca: imports the v0.8 internal CA into step-ca + - If a v0.8 layout is detected, runs Migratev08tov11 first + - Idempotent: if already at the target version, no-op`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return runUpgrade(cmd, cmd.OutOrStdout()) + }, +} + +func init() { + upgradeCmd.Flags().StringVar(&upgradeTo, "to", "", "target version (e.g. v0.11.0) (required)") + upgradeCmd.Flags().BoolVar(&upgradeImportCA, "import-ca", false, "import the v0.8 internal CA into step-ca during upgrade") + upgradeCmd.Flags().BoolVar(&upgradeForce, "force", false, "skip cutover verification (use with caution)") + upgradeCmd.Flags().BoolVar(&upgradeDryRun, "dry-run", false, "report what would be done without making changes") + rootCmd.AddCommand(upgradeCmd) +} + +// UpgradeResult is the JSON-serializable summary of an upgrade run. +type UpgradeResult struct { + TargetVersion string `json:"target_version"` + CurrentVersion string `json:"current_version"` + DryRun bool `json:"dry_run"` + MigratedV08 bool `json:"migrated_v08"` + CutoverNeeded bool `json:"cutover_needed"` + CutoverOK bool `json:"cutover_ok,omitempty"` + CutoverRolled bool `json:"cutover_rolled_back,omitempty"` + UsersCreated []string `json:"users_created,omitempty"` + CAImported bool `json:"ca_imported,omitempty"` + BinaryUpdated bool `json:"binary_updated"` +} + +func runUpgrade(cmd *cobra.Command, out interface{ Write([]byte) (int, error) }) error { + if upgradeTo == "" { + return fmt.Errorf("--to is required (e.g. --to v0.11.0)") + } + + res := UpgradeResult{ + TargetVersion: upgradeTo, + CurrentVersion: version, + DryRun: upgradeDryRun, + } + + if !jsonOutput { + fmt.Fprintf(out, "orca upgrade --to %s (current: %s)\n", upgradeTo, version) + } + + if version == strings.TrimPrefix(upgradeTo, "v") && !upgradeDryRun { + if !jsonOutput { + fmt.Fprintf(out, "✓ Already at target version %s; no-op\n", upgradeTo) + } + res.BinaryUpdated = false + if jsonOutput { + return printJSON(res) + } + return nil + } + + home := paths.Root() + if migration.Detectv08(home) { + if !jsonOutput { + fmt.Fprintf(out, "• v0.8 layout detected; running data migration first\n") + } + if upgradeDryRun { + fmt.Fprintf(out, " [dry-run] would run Migratev08tov11(source=%s)\n", home) + } else { + if err := migration.Migratev08tov11(migration.MigrateOptions{ + SourceDir: home, + TargetDir: home, + ImportCA: upgradeImportCA, + DryRun: false, + }); err != nil { + return fmt.Errorf("v0.8 migration: %w", err) + } + } + res.MigratedV08 = true + } + + runner := upgradeRunnerOverride + if runner == nil { + runner = execRunner{} + } + + cutoverNeeded := detectOldTraefikBinding() + res.CutoverNeeded = cutoverNeeded + if cutoverNeeded { + if !jsonOutput { + fmt.Fprintf(out, "• R-017 cutover: Traefik on :443 detected; cutting over to 127.0.0.1:8443 + nftables\n") + } + if upgradeDryRun { + fmt.Fprintf(out, " [dry-run] would reconfigure Traefik to 127.0.0.1:8443 + add nftables redirect\n") + } else { + ok, err := performCutover(cmd.Context(), runner, out, upgradeForce) + if err != nil { + return err + } + res.CutoverOK = ok + if !ok { + res.CutoverRolled = true + if jsonOutput { + return printJSON(res) + } + return fmt.Errorf("cutover verification failed; rolled back to :443") + } + } + } + + peers, err := listPeers() + if err != nil { + slog.Warn("upgrade: list peers failed", "err", err) + } + if len(peers) > 0 { + if !jsonOutput { + fmt.Fprintf(out, "• C-27: creating orca system user on %d peer(s)\n", len(peers)) + } + created, err := createOrcaUserOnPeers(cmd.Context(), peers) + if err != nil { + slog.Warn("upgrade: orca user creation on peers failed", "err", err) + } + res.UsersCreated = created + } + + if upgradeImportCA && !res.MigratedV08 { + if !jsonOutput { + fmt.Fprintf(out, "• Importing v0.8 internal CA into step-ca\n") + } + if upgradeDryRun { + fmt.Fprintf(out, " [dry-run] would import ca.crt/ca.key into step-ca\n") + } else { + if err := importCA(cmd.Context()); err != nil { + slog.Warn("upgrade: CA import failed", "err", err) + } else { + res.CAImported = true + } + } + } + + if upgradeDryRun { + fmt.Fprintf(out, " [dry-run] would download and install orca %s via install.sh\n", upgradeTo) + } else { + if !jsonOutput { + fmt.Fprintf(out, "• Downloading and installing orca %s via install.sh\n", upgradeTo) + } + installSh := findInstallScript() + if _, err := runner.Run(cmd.Context(), "bash", installSh, "--version", upgradeTo); err != nil { + return fmt.Errorf("install.sh: %w", err) + } + res.BinaryUpdated = true + } + + if jsonOutput { + return printJSON(res) + } + fmt.Fprintf(out, "\n✓ upgrade to %s complete\n", upgradeTo) + if cutoverNeeded && !upgradeDryRun { + fmt.Fprintf(out, "\nRollback procedure (if cutover failed):\n") + fmt.Fprintf(out, " 1. Restore Traefik entrypoint to :443\n") + fmt.Fprintf(out, " 2. Remove nftables rules: nft delete table inet orca_redirect\n") + fmt.Fprintf(out, " 3. Restart Traefik: systemctl restart traefik\n") + fmt.Fprintf(out, " 4. Verify: curl -k https://localhost:443/\n") + } + return nil +} + +// detectOldTraefikBinding returns true if Traefik is listening on :443 +// (the v0.9/v0.10 default that R-017 cuts over from). Best-effort: if +// the check fails, returns false (no cutover needed). +func detectOldTraefikBinding() bool { + runner := upgradeRunnerOverride + if runner == nil { + runner = execRunner{} + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + out, err := runner.Run(ctx, "ss", "-tlnp") + if err != nil { + return false + } + return strings.Contains(string(out), ":443") +} + +// performCutover reconfigures Traefik to listen on 127.0.0.1:8443 and +// adds nftables DNAT redirect from :443 → 127.0.0.1:8443. Then runs +// C-25 post-cutover verification: curl -k https://localhost:443/ must +// return 200. On failure, rolls back (restores :443, removes nft rules) +// and returns (false, nil). On success returns (true, nil). With +// force=true, verification is skipped. +func performCutover(ctx context.Context, runner commandRunner, out interface{ Write([]byte) (int, error) }, force bool) (bool, error) { + if _, err := runner.Run(ctx, "sed", "-i", "s/:443/127.0.0.1:8443/g", "/etc/traefik/traefik.yml"); err != nil { + return false, fmt.Errorf("cutover: edit traefik.yml: %w", err) + } + if _, err := runner.Run(ctx, "systemctl", "restart", "traefik"); err != nil { + return false, fmt.Errorf("cutover: restart traefik: %w", err) + } + nftCmd := `nft add table inet orca_redirect; nft 'add chain inet orca_redirect prerouting { type nat hook prerouting priority -100; }'; nft add rule inet orca_redirect prerouting tcp dport 443 dnat to 127.0.0.1:8443` + if _, err := runner.Run(ctx, "bash", "-c", nftCmd); err != nil { + slog.Warn("cutover: nftables rule add failed (non-fatal if already present)", "err", err) + } + + if force { + fmt.Fprintf(out, " --force: skipping cutover verification\n") + return true, nil + } + + if err := verifyCutover(out); err != nil { + fmt.Fprintf(out, " ✗ C-25 cutover verification failed: %v\n", err) + fmt.Fprintf(out, " Rolling back to :443...\n") + if rbErr := rollbackCutover(ctx, runner); rbErr != nil { + slog.Error("cutover: rollback failed", "err", rbErr) + } + return false, nil + } + fmt.Fprintf(out, " ✓ C-25 cutover verification passed (200 from Traefik)\n") + return true, nil +} + +// verifyCutover runs the C-25 post-cutover check: curl -k +// https://localhost:443/ must return HTTP 200. +func verifyCutover(out interface{ Write([]byte) (int, error) }) error { + if httpClientOverride != nil { + code, err := httpClientOverride("https://localhost:443/") + if err != nil { + return err + } + if code != 200 { + return fmt.Errorf("HTTP %d (want 200)", code) + } + return nil + } + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Get("https://localhost:443/") + if err != nil { + return fmt.Errorf("curl: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return fmt.Errorf("HTTP %d (want 200)", resp.StatusCode) + } + return nil +} + +// rollbackCutover restores Traefik to :443 and removes nftables rules. +func rollbackCutover(ctx context.Context, runner commandRunner) error { + if _, err := runner.Run(ctx, "sed", "-i", "s/127.0.0.1:8443/:443/g", "/etc/traefik/traefik.yml"); err != nil { + return fmt.Errorf("rollback: edit traefik.yml: %w", err) + } + if _, err := runner.Run(ctx, "systemctl", "restart", "traefik"); err != nil { + return fmt.Errorf("rollback: restart traefik: %w", err) + } + if _, err := runner.Run(ctx, "nft", "delete", "table", "inet", "orca_redirect"); err != nil { + slog.Warn("rollback: nft delete table failed (non-fatal if not present)", "err", err) + } + return nil +} + +// createOrcaUserOnPeers runs the idempotent `useradd -r orca` on each +// peer via SSH (C-27). Returns the list of peers where the user was +// created (or already existed). +func createOrcaUserOnPeers(ctx context.Context, peers []string) ([]string, error) { + transport := upgradeTransportOverride + if transport == nil { + return nil, fmt.Errorf("no SSH transport available for peer user creation") + } + var created []string + for _, peer := range peers { + cmd := "useradd -r orca -s /usr/sbin/nologin 2>/dev/null || true" + if _, err := transport.Exec(ctx, peer, cmd); err != nil { + slog.Warn("upgrade: useradd on peer failed", "peer", peer, "err", err) + continue + } + created = append(created, peer) + } + return created, nil +} + +// listPeers returns the list of peer addresses for C-27 orca user +// creation. Uses the peersListerOverride test seam if set; otherwise +// returns an empty list (no peers configured). +func listPeers() ([]string, error) { + if peersListerOverride != nil { + return peersListerOverride() + } + return nil, nil +} + +// importCA imports the v0.8 internal CA into step-ca. This is the +// C-07 gate. Production wires a real step-ca client; tests use the +// migration.caImporterOverride seam. +func importCA(ctx context.Context) error { + caCrt := filepath.Join(paths.Root(), "ca.crt") + caKey := filepath.Join(paths.Root(), "ca.key") + if _, err := os.Stat(caCrt); err != nil { + return fmt.Errorf("import CA: ca.crt not found: %w", err) + } + if _, err := os.Stat(caKey); err != nil { + return fmt.Errorf("import CA: ca.key not found: %w", err) + } + importer := migration.GetCAImporter() + if importer == nil { + return fmt.Errorf("import CA: no CA importer available") + } + return importer.ImportCA(ctx, caCrt, caKey) +} + +// findInstallScript returns the path to scripts/install.sh. Checks +// the repo-local path first; falls back to downloading via curl +// (handled by the caller). +func findInstallScript() string { + candidates := []string{ + "scripts/install.sh", + "/usr/local/share/orca/scripts/install.sh", + } + for _, c := range candidates { + if _, err := os.Stat(c); err == nil { + return c + } + } + return "scripts/install.sh" +} diff --git a/internal/cli/upgrade_test.go b/internal/cli/upgrade_test.go new file mode 100644 index 0000000..625b9a1 --- /dev/null +++ b/internal/cli/upgrade_test.go @@ -0,0 +1,341 @@ +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) + } +} diff --git a/internal/migration/migrate.go b/internal/migration/migrate.go new file mode 100644 index 0000000..0bbb0c3 --- /dev/null +++ b/internal/migration/migrate.go @@ -0,0 +1,300 @@ +// Package migration implements the v0.8→v0.11 data migration (REQ-066, +// gate C-07). The v0.8 layout is flat: a single orca.db, ca.crt/ca.key +// (internal CA), config.hcl, and server.crt all at the root of +// ORCA_HOME. The v0.11 layout (R-002) is multi-namespace: a cluster/ +// subdir for cluster-wide artifacts and per-namespace db/, jobs/, +// alloc/, ns.md directories, with the implicit root namespace +// _defaults holding the migrated v0.8 database. +// +// Migratev08tov11 is the entrypoint. It is idempotent: running it +// twice is safe (the second run detects the v0.11 layout and skips). +package migration + +import ( + "context" + "database/sql" + "fmt" + "log/slog" + "os" + "path/filepath" + + _ "modernc.org/sqlite" + + "git.cloudinit.dev/coreci/orca/internal/paths" +) + +// MigrateOptions configures a v0.8→v0.11 migration run. +type MigrateOptions struct { + SourceDir string + TargetDir string + ImportCA bool + DryRun bool +} + +// CAImporter is the seam for importing a v0.8 internal CA into +// step-ca. The CLI's upgrade command injects a real step-ca client; +// tests inject a mock. nil means CA import is skipped (the files are +// still preserved on disk). +type CAImporter interface { + ImportCA(ctx context.Context, caCertPath, caKeyPath string) error +} + +// caImporterOverride is the package-level test seam for the CA +// importer. When non-nil it replaces the production importer. +var caImporterOverride CAImporter + +// v0.8 layout markers (files that live at the flat root in v0.8). +const ( + v08DBName = "orca.db" + v08CACertName = "ca.crt" + v08CAKeyName = "ca.key" + v08ConfigName = "config.hcl" +) + +// Detectv08 returns true if dir has v0.8 layout markers: an orca.db +// at the root AND ca.crt/ca.key (the internal CA). A dir that already +// has the v0.11 cluster/ layout is NOT a v0.8 layout. +func Detectv08(dir string) bool { + if dir == "" { + return false + } + dbPath := filepath.Join(dir, v08DBName) + if !fileExists(dbPath) { + return false + } + caCrt := filepath.Join(dir, v08CACertName) + caKey := filepath.Join(dir, v08CAKeyName) + if !fileExists(caCrt) || !fileExists(caKey) { + return false + } + clusterDir := filepath.Join(dir, "cluster") + if fileExists(clusterDir) { + return false + } + return true +} + +// Migratev08tov11 migrates a v0.8 flat layout to the v0.11 +// multi-namespace layout. See the package doc for the full step list. +// Idempotent: if the target already has the v0.11 layout (cluster/ +// present and the DB already moved), the call is a no-op. +func Migratev08tov11(opts MigrateOptions) error { + src := opts.SourceDir + tgt := opts.TargetDir + if src == "" { + src = paths.Root() + } + if tgt == "" { + tgt = src + } + + if !Detectv08(src) { + slog.Info("migration: not a v0.8 layout, skipping", "dir", src) + return nil + } + + if alreadyMigrated(tgt) { + slog.Info("migration: target already at v0.11 layout, skipping", "dir", tgt) + return nil + } + + slog.Info("migration: v0.8 layout detected", "source", src, "target", tgt, "dry_run", opts.DryRun, "import_ca", opts.ImportCA) + + if opts.DryRun { + return dryRunReport(opts) + } + + clusterDir := filepath.Join(tgt, "cluster") + defaultsDir := filepath.Join(tgt, paths.DefaultNamespace()) + defaultsDBDir := filepath.Join(defaultsDir, "db") + defaultsJobsDir := filepath.Join(defaultsDir, "jobs") + defaultsAllocDir := filepath.Join(defaultsDir, "alloc") + defaultsMd := filepath.Join(defaultsDir, "ns.md") + + for _, d := range []string{clusterDir, defaultsDBDir, defaultsJobsDir, defaultsAllocDir} { + if err := os.MkdirAll(d, 0o755); err != nil { + return fmt.Errorf("migration: mkdir %s: %w", d, err) + } + } + + if !fileExists(defaultsMd) { + if err := os.WriteFile(defaultsMd, defaultNamespaceMd(), 0o644); err != nil { + return fmt.Errorf("migration: write ns.md: %w", err) + } + } + + srcDB := filepath.Join(src, v08DBName) + tgtDB := filepath.Join(defaultsDBDir, v08DBName) + if src != tgt { + if err := copyFile(srcDB, tgtDB); err != nil { + return fmt.Errorf("migration: move db: %w", err) + } + _ = os.Remove(srcDB) + } else { + if !fileExists(tgtDB) { + if err := os.Rename(srcDB, tgtDB); err != nil { + return fmt.Errorf("migration: rename db: %w", err) + } + } + } + + if err := migrateDBSchema(tgtDB); err != nil { + return fmt.Errorf("migration: schema: %w", err) + } + + srcConfig := filepath.Join(src, v08ConfigName) + legacyConfig := filepath.Join(clusterDir, "config.legacy.hcl") + if fileExists(srcConfig) { + if src != tgt { + if err := copyFile(srcConfig, legacyConfig); err != nil { + return fmt.Errorf("migration: move config.hcl: %w", err) + } + } else { + if !fileExists(legacyConfig) { + if err := os.Rename(srcConfig, legacyConfig); err != nil { + return fmt.Errorf("migration: rename config.hcl: %w", err) + } + } + } + } + + caCrt := filepath.Join(src, v08CACertName) + caKey := filepath.Join(src, v08CAKeyName) + if fileExists(caCrt) && fileExists(caKey) && opts.ImportCA { + importer := caImporterOverride + if importer != nil { + slog.Info("migration: importing v0.8 CA into step-ca (C-07)", "cert", caCrt) + if err := importer.ImportCA(context.Background(), caCrt, caKey); err != nil { + return fmt.Errorf("migration: import CA: %w", err) + } + } else { + slog.Warn("migration: --import-ca requested but no CA importer available; preserving old CA files") + } + } + + slog.Info("migration: v0.8→v0.11 complete", "target", tgt) + return nil +} + +// alreadyMigrated returns true if the target dir already has the v0.11 +// layout: cluster/ present AND _defaults/db/orca.db present. +func alreadyMigrated(dir string) bool { + clusterDir := filepath.Join(dir, "cluster") + defaultsDB := filepath.Join(dir, paths.DefaultNamespace(), "db", v08DBName) + return fileExists(clusterDir) && fileExists(defaultsDB) +} + +// migrateDBSchema opens the migrated DB and removes the `namespace` +// column from the nodes table if it exists (v0.8 dual-write window +// added it; v0.11 is single-namespace-per-DB). This mirrors the +// internal/store/migrate.go pattern but operates on a copied DB. +func migrateDBSchema(dbPath string) error { + db, err := sql.Open("sqlite", dbPath+"?_pragma=journal_mode(WAL)") + if err != nil { + return fmt.Errorf("open %s: %w", dbPath, err) + } + defer db.Close() + + if err := db.Ping(); err != nil { + return fmt.Errorf("ping %s: %w", dbPath, err) + } + + hasCol, err := columnExists(db, "nodes", "namespace") + if err != nil { + return fmt.Errorf("check namespace column: %w", err) + } + if hasCol { + slog.Info("migration: removing namespace column from nodes table") + if _, err := db.Exec(`ALTER TABLE nodes DROP COLUMN namespace`); err != nil { + return fmt.Errorf("drop namespace column: %w", err) + } + } + + if err := ensureSchemaMigrations(db); err != nil { + return fmt.Errorf("ensure schema_migrations: %w", err) + } + + return nil +} + +// columnExists checks whether a column exists in a table using +// pragma_table_info. +func columnExists(db *sql.DB, table, column string) (bool, error) { + q := fmt.Sprintf(`SELECT count(*) FROM pragma_table_info('%s') WHERE name = ?`, table) + var n int + if err := db.QueryRow(q, column).Scan(&n); err != nil { + return false, err + } + return n > 0, nil +} + +// ensureSchemaMigrations creates the schema_migrations table if it +// doesn't exist so the migrated DB is compatible with the v0.11 +// store.Open migrate runner. +func ensureSchemaMigrations(db *sql.DB) error { + _, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (name TEXT PRIMARY KEY, applied_at DATETIME NOT NULL)`) + return err +} + +// dryRunReport logs what the migration would do without writing. +func dryRunReport(opts MigrateOptions) error { + src := opts.SourceDir + tgt := opts.TargetDir + if src == "" { + src = paths.Root() + } + if tgt == "" { + tgt = src + } + slog.Info("migration: dry-run plan", "source", src, "target", tgt) + slog.Info(" create cluster/ directory", "path", filepath.Join(tgt, "cluster")) + slog.Info(" create _defaults/ namespace", "path", filepath.Join(tgt, paths.DefaultNamespace())) + slog.Info(" move orca.db", "from", filepath.Join(src, v08DBName), "to", filepath.Join(tgt, paths.DefaultNamespace(), "db", v08DBName)) + if fileExists(filepath.Join(src, v08ConfigName)) { + slog.Info(" move config.hcl", "from", filepath.Join(src, v08ConfigName), "to", filepath.Join(tgt, "cluster", "config.legacy.hcl")) + } + if fileExists(filepath.Join(src, v08CACertName)) && opts.ImportCA { + slog.Info(" import CA into step-ca (C-07)", "cert", filepath.Join(src, v08CACertName), "key", filepath.Join(src, v08CAKeyName)) + } + slog.Info(" migrate DB schema (remove namespace column if present)") + return nil +} + +// defaultNamespaceMd returns the ns.md content for the implicit root +// namespace _defaults. +func defaultNamespaceMd() []byte { + return []byte("---\nname: _defaults\ndescription: \"Implicit root namespace (migrated from v0.8 flat layout)\"\n---\n\n# _defaults\n\nThe implicit root namespace. Created by the v0.8→v0.11 migration.\n") +} + +// fileExists returns true if path exists (file or dir). +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +// copyFile copies src to dst preserving the file mode. +func copyFile(src, dst string) error { + data, err := os.ReadFile(src) + if err != nil { + return err + } + info, err := os.Stat(src) + if err != nil { + return err + } + return os.WriteFile(dst, data, info.Mode().Perm()) +} + +// GetCAImporter returns the package-level CA importer (set via +// SetCAImporter). Returns nil if none is configured. The CLI's +// upgrade command calls this to wire a real step-ca client. +func GetCAImporter() CAImporter { + return caImporterOverride +} + +// SetCAImporter configures the package-level CA importer. The CLI's +// upgrade command calls this before Migratev08tov11 when --import-ca is +// set; tests call it to inject a mock. +func SetCAImporter(i CAImporter) { + caImporterOverride = i +} diff --git a/internal/migration/migrate_test.go b/internal/migration/migrate_test.go new file mode 100644 index 0000000..b86daa5 --- /dev/null +++ b/internal/migration/migrate_test.go @@ -0,0 +1,311 @@ +package migration + +import ( + "context" + "database/sql" + "os" + "path/filepath" + "testing" + + _ "modernc.org/sqlite" + + "git.cloudinit.dev/coreci/orca/internal/paths" +) + +type mockCAImporter struct { + called bool + certPath string + keyPath string + err error +} + +func (m *mockCAImporter) ImportCA(ctx context.Context, caCertPath, caKeyPath string) error { + m.called = true + m.certPath = caCertPath + m.keyPath = caKeyPath + return m.err +} + +// createV08DB creates a real SQLite DB at path with a nodes table that +// has a namespace column (simulating the v0.8 dual-write window). The +// namespace column is what migrateDBSchema should drop. +func createV08DB(t *testing.T, path string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + db, err := sql.Open("sqlite", path) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + defer db.Close() + if _, err := db.Exec(`CREATE TABLE nodes ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + address TEXT NOT NULL, + state TEXT NOT NULL DEFAULT 'pending', + joined_at DATETIME NOT NULL, + last_seen DATETIME NOT NULL, + metadata TEXT, + namespace TEXT + )`); err != nil { + t.Fatalf("create nodes table: %v", err) + } + if _, err := db.Exec(`CREATE TABLE audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts DATETIME NOT NULL, + actor TEXT, + action TEXT, + resource TEXT, + result TEXT + )`); err != nil { + t.Fatalf("create audit_log table: %v", err) + } + if _, err := db.Exec(`INSERT INTO nodes (id, name, address, state, joined_at, last_seen, namespace) VALUES ('n1','localhost','localhost:8443','ready','2024-01-01','2024-01-01','default')`); err != nil { + t.Fatalf("insert node: %v", err) + } + if _, err := db.Exec(`INSERT INTO audit_log (ts, actor, action, resource, result) VALUES ('2024-01-01','init','init','cluster','success')`); err != nil { + t.Fatalf("insert audit: %v", err) + } +} + +func setupV08Dir(t *testing.T) string { + t.Helper() + dir := t.TempDir() + createV08DB(t, filepath.Join(dir, "orca.db")) + mustWrite(t, filepath.Join(dir, "ca.crt"), []byte("fake CA cert")) + mustWrite(t, filepath.Join(dir, "ca.key"), []byte("fake CA key")) + return dir +} + +func setupV11Dir(t *testing.T) string { + t.Helper() + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, "cluster"), 0o755); err != nil { + t.Fatalf("mkdir cluster: %v", err) + } + dbDir := filepath.Join(dir, paths.DefaultNamespace(), "db") + if err := os.MkdirAll(dbDir, 0o755); err != nil { + t.Fatalf("mkdir defaults/db: %v", err) + } + mustWrite(t, filepath.Join(dbDir, "orca.db"), []byte("sqlite v0.11")) + return dir +} + +func mustWrite(t *testing.T, path string, data []byte) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("mkdir %s: %v", filepath.Dir(path), err) + } + if err := os.WriteFile(path, data, 0o644); err != nil { + t.Fatalf("write %s: %v", path, err) + } +} + +func TestDetectv08_DetectsV08Layout(t *testing.T) { + dir := setupV08Dir(t) + if !Detectv08(dir) { + t.Errorf("Detectv08(%q) = false, want true", dir) + } +} + +func TestDetectv08_RejectsV11Layout(t *testing.T) { + dir := setupV11Dir(t) + if Detectv08(dir) { + t.Errorf("Detectv08(%q) = true, want false (v0.11 layout)", dir) + } +} + +func TestDetectv08_EmptyDir(t *testing.T) { + dir := t.TempDir() + if Detectv08(dir) { + t.Errorf("Detectv08 on empty dir = true, want false") + } +} + +func TestDetectv08_EmptyString(t *testing.T) { + if Detectv08("") { + t.Errorf("Detectv08(\"\") = true, want false") + } +} + +func TestDetectv08_MissingCA(t *testing.T) { + dir := t.TempDir() + createV08DB(t, filepath.Join(dir, "orca.db")) + if Detectv08(dir) { + t.Errorf("Detectv08 with db but no CA = true, want false") + } +} + +func TestMigratev08tov11_CreatesV11Layout(t *testing.T) { + dir := setupV08Dir(t) + mustWrite(t, filepath.Join(dir, "config.hcl"), []byte("config")) + + if err := Migratev08tov11(MigrateOptions{SourceDir: dir, TargetDir: dir}); err != nil { + t.Fatalf("Migratev08tov11: %v", err) + } + + if _, err := os.Stat(filepath.Join(dir, "cluster")); err != nil { + t.Errorf("cluster/ dir not created: %v", err) + } + dbPath := filepath.Join(dir, paths.DefaultNamespace(), "db", "orca.db") + if _, err := os.Stat(dbPath); err != nil { + t.Errorf("orca.db not moved to _defaults/db/: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "orca.db")); err == nil { + t.Errorf("old orca.db still at root (should have been moved)") + } + legacyCfg := filepath.Join(dir, "cluster", "config.legacy.hcl") + if _, err := os.Stat(legacyCfg); err != nil { + t.Errorf("config.hcl not moved to cluster/config.legacy.hcl: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "config.hcl")); err == nil { + t.Errorf("old config.hcl still at root (should have been moved)") + } + if _, err := os.Stat(filepath.Join(dir, paths.DefaultNamespace(), "ns.md")); err != nil { + t.Errorf("ns.md not created: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "ca.crt")); err != nil { + t.Errorf("ca.crt should be preserved, but missing: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "ca.key")); err != nil { + t.Errorf("ca.key should be preserved, but missing: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, paths.DefaultNamespace(), "jobs")); err != nil { + t.Errorf("jobs/ dir not created: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, paths.DefaultNamespace(), "alloc")); err != nil { + t.Errorf("alloc/ dir not created: %v", err) + } + + db, err := sql.Open("sqlite", dbPath) + if err != nil { + t.Fatalf("open migrated db: %v", err) + } + defer db.Close() + var cols []string + rows, err := db.Query(`SELECT name FROM pragma_table_info('nodes')`) + if err != nil { + t.Fatalf("query table_info: %v", err) + } + defer rows.Close() + for rows.Next() { + var c string + if err := rows.Scan(&c); err != nil { + t.Fatalf("scan: %v", err) + } + cols = append(cols, c) + } + for _, c := range cols { + if c == "namespace" { + t.Errorf("namespace column still present after migration: %v", cols) + } + } + var nodeCount int + if err := db.QueryRow(`SELECT count(*) FROM nodes`).Scan(&nodeCount); err != nil { + t.Fatalf("count nodes: %v", err) + } + if nodeCount != 1 { + t.Errorf("node count = %d, want 1 (data preserved)", nodeCount) + } + var auditCount int + if err := db.QueryRow(`SELECT count(*) FROM audit_log`).Scan(&auditCount); err != nil { + t.Fatalf("count audit_log: %v", err) + } + if auditCount != 1 { + t.Errorf("audit_log count = %d, want 1 (history preserved)", auditCount) + } +} + +func TestMigratev08tov11_DryRun(t *testing.T) { + dir := setupV08Dir(t) + mustWrite(t, filepath.Join(dir, "config.hcl"), []byte("config")) + + if err := Migratev08tov11(MigrateOptions{SourceDir: dir, TargetDir: dir, DryRun: true}); err != nil { + t.Fatalf("Migratev08tov11 dry-run: %v", err) + } + + if _, err := os.Stat(filepath.Join(dir, "cluster")); err == nil { + t.Errorf("dry-run should not create cluster/ dir") + } + if _, err := os.Stat(filepath.Join(dir, "orca.db")); err != nil { + t.Errorf("dry-run should not move orca.db (still at root)") + } + if _, err := os.Stat(filepath.Join(dir, "config.hcl")); err != nil { + t.Errorf("dry-run should not move config.hcl (still at root)") + } +} + +func TestMigratev08tov11_Idempotent(t *testing.T) { + dir := setupV08Dir(t) + mustWrite(t, filepath.Join(dir, "config.hcl"), []byte("config")) + + if err := Migratev08tov11(MigrateOptions{SourceDir: dir, TargetDir: dir}); err != nil { + t.Fatalf("first Migratev08tov11: %v", err) + } + if err := Migratev08tov11(MigrateOptions{SourceDir: dir, TargetDir: dir}); err != nil { + t.Fatalf("second Migratev08tov11: %v", err) + } + + dbPath := filepath.Join(dir, paths.DefaultNamespace(), "db", "orca.db") + if _, err := os.Stat(dbPath); err != nil { + t.Errorf("orca.db missing after second run: %v", err) + } +} + +func TestMigratev08tov11_ImportCA(t *testing.T) { + dir := setupV08Dir(t) + mock := &mockCAImporter{} + caImporterOverride = mock + t.Cleanup(func() { caImporterOverride = nil }) + + if err := Migratev08tov11(MigrateOptions{SourceDir: dir, TargetDir: dir, ImportCA: true}); err != nil { + t.Fatalf("Migratev08tov11 with ImportCA: %v", err) + } + + if !mock.called { + t.Errorf("CA importer was not called") + } + wantCert := filepath.Join(dir, "ca.crt") + if mock.certPath != wantCert { + t.Errorf("certPath = %q, want %q", mock.certPath, wantCert) + } +} + +func TestMigratev08tov11_ImportCAError(t *testing.T) { + dir := setupV08Dir(t) + mock := &mockCAImporter{err: context.Canceled} + caImporterOverride = mock + t.Cleanup(func() { caImporterOverride = nil }) + + err := Migratev08tov11(MigrateOptions{SourceDir: dir, TargetDir: dir, ImportCA: true}) + if err == nil { + t.Fatalf("expected error from CA importer") + } +} + +func TestMigratev08tov11_NotV08(t *testing.T) { + dir := setupV11Dir(t) + if err := Migratev08tov11(MigrateOptions{SourceDir: dir, TargetDir: dir}); err != nil { + t.Fatalf("Migratev08tov11 on v0.11 should be no-op, got: %v", err) + } +} + +func TestMigratev08tov11_CrossDir(t *testing.T) { + src := setupV08Dir(t) + mustWrite(t, filepath.Join(src, "config.hcl"), []byte("config")) + tgt := t.TempDir() + + if err := Migratev08tov11(MigrateOptions{SourceDir: src, TargetDir: tgt}); err != nil { + t.Fatalf("Migratev08tov11 cross-dir: %v", err) + } + + dbPath := filepath.Join(tgt, paths.DefaultNamespace(), "db", "orca.db") + if _, err := os.Stat(dbPath); err != nil { + t.Errorf("orca.db not in target _defaults/db/: %v", err) + } + legacyCfg := filepath.Join(tgt, "cluster", "config.legacy.hcl") + if _, err := os.Stat(legacyCfg); err != nil { + t.Errorf("config.legacy.hcl not in target cluster/: %v", err) + } +}