Files
orca/internal/migration/migrate_test.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

312 lines
9.3 KiB
Go

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