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---
This commit is contained in:
Jon Chery
2026-08-07 08:00:10 +00:00
parent 97b88a703c
commit 9a28dc907b
5 changed files with 1361 additions and 0 deletions
+11
View File
@@ -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
+398
View File
@@ -0,0 +1,398 @@
// Package cli: upgrade.go implements the `orca upgrade --to <version>`
// 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 <version> 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"
}
+341
View File
@@ -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)
}
}
+300
View File
@@ -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
}
+311
View File
@@ -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)
}
}