9a28dc907b
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---
312 lines
9.3 KiB
Go
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)
|
|
}
|
|
}
|