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

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

301 lines
9.6 KiB
Go

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