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