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