6f5705fe02
---ci--- project: orca phase: 22 milestone: v0.12 status: execute ---/ci--- copyFile now atomic (temp + rename; was os.WriteFile which could leave a partial DB on crash). migrateDBSchema now opens with foreign_keys(ON) (was journal_mode only). REQ-137/F19. Build + tests green. The --accept-identity-migration gate is enforced in the upgrade CLI (P07 password removal; documented in the migration guide).
307 lines
9.8 KiB
Go
307 lines
9.8 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)&_pragma=foreign_keys(ON)")
|
|
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.
|
|
// copyFile copies src to dst atomically (temp + rename). REQ-137/F19:
|
|
// a crash mid-copy must not leave a partial DB file.
|
|
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
|
|
}
|
|
tmp := dst + ".tmp"
|
|
if err := os.WriteFile(tmp, data, info.Mode().Perm()); err != nil {
|
|
return fmt.Errorf("copyFile: write tmp: %w", err)
|
|
}
|
|
return os.Rename(tmp, dst)
|
|
}
|
|
|
|
// 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
|
|
}
|