Orchestrator.RolloutAll wendet eine geordnete Liste von Migrationen auf JEDE registrierte Tenant-Datenbank an (LoadMigrations liest *.up.sql aus einem Verzeichnis nach der bestehenden 000N_name-Namenskonvention). Jeder Tenant laeuft unabhaengig in eigener Verbindung — ein Fehlschlag bei einem Mandanten bricht nur dessen eigenen Rollout ab (spaetere Migrationen bauen typischerweise auf frueheren auf) und blockiert die uebrigen Tenants nicht. schema_migrations-Tabelle pro Tenant-Datenbank (version PK, applied_at, success, error) haelt den Stand pro Version einzeln nachvollziehbar fest. Bereits erfolgreiche Versionen werden bei einem erneuten Rollout uebersprungen (isAlreadySuccessful-Check vor jeder Anwendung), fehlgeschlagene werden beim naechsten Versuch automatisch erneut probiert (kein manuelles Zuruecksetzen noetig) — ON CONFLICT DO UPDATE haelt jeweils nur den letzten Versuch fest. Bewusst ohne Abhaengigkeit von TEN-06 (Router): Migrations-Rollouts sind seltene Batch-Vorgaenge, ein kurzlebiger Pool pro Tenant und Lauf reicht. Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS, go test ./... mit -p 1 noetig da mehrere Pakete die geteilte Registry-Tabelle auf derselben Postgres-Instanz nutzen — siehe scripts/reset-test-env.sh): 1. Rollout gegen 3 Test-Tenants, einer absichtlich inkompatibel (Tabellen- Konflikt bei Migration 2) — TestRolloutAll_IsolatesFailurePerTenant: die anderen beiden erhalten beide Migrationen, der inkompatible bekommt Migration 1 trotzdem, scheitert nur an Migration 2, faellt nicht die anderen um. PASS. 2. Migrationsstand-Abfrage liefert korrekten Stand pro Tenant — TestStatus_ReflectsPerTenantState: Version 1 success=true, Version 2 success=false mit Fehlertext. PASS. 3. Wiederholter Rollout fuer fehlgeschlagene Migration moeglich, ohne bereits erfolgreiche erneut anzuwenden — TestRolloutAll_RetryDoesNotReapplySuccessful: Migration 1 nutzt bewusst kein IF NOT EXISTS, ein Reapply haette den zweiten Lauf scheitern lassen; zweiter Lauf ist fehlerfrei und wendet nur die zuvor fehlgeschlagene Version an. PASS. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
239 lines
7.5 KiB
Go
239 lines
7.5 KiB
Go
package migrate
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
|
|
)
|
|
|
|
func TestLoadMigrations_SortsByFilename(t *testing.T) {
|
|
dir := t.TempDir()
|
|
writeFile(t, dir, "0002_second.up.sql", "SELECT 2;")
|
|
writeFile(t, dir, "0001_first.up.sql", "SELECT 1;")
|
|
writeFile(t, dir, "0001_first.down.sql", "SELECT 'ignored';") // muss ignoriert werden
|
|
|
|
migrations, err := LoadMigrations(dir)
|
|
if err != nil {
|
|
t.Fatalf("load: %v", err)
|
|
}
|
|
if len(migrations) != 2 {
|
|
t.Fatalf("erwartet 2 migrationen, habe %d", len(migrations))
|
|
}
|
|
if migrations[0].Version != "0001_first" || migrations[1].Version != "0002_second" {
|
|
t.Fatalf("unerwartete reihenfolge: %+v", migrations)
|
|
}
|
|
}
|
|
|
|
func writeFile(t *testing.T, dir, name, content string) {
|
|
t.Helper()
|
|
if err := os.WriteFile(dir+"/"+name, []byte(content), 0o644); err != nil {
|
|
t.Fatalf("write %s: %v", name, err)
|
|
}
|
|
}
|
|
|
|
func setupOrchestratorTest(t *testing.T, tenantCount int) (*Orchestrator, []tenant.Tenant, *pgxpool.Pool, func()) {
|
|
t.Helper()
|
|
adminDSN := os.Getenv("TEST_ADMIN_DSN")
|
|
if adminDSN == "" {
|
|
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
|
|
}
|
|
ctx := context.Background()
|
|
|
|
adminPool, err := pgxpool.New(ctx, adminDSN)
|
|
if err != nil {
|
|
t.Fatalf("admin pool: %v", err)
|
|
}
|
|
registryPool, err := pgxpool.New(ctx, adminDSN)
|
|
if err != nil {
|
|
t.Fatalf("registry pool: %v", err)
|
|
}
|
|
if _, err := registryPool.Exec(ctx, `
|
|
CREATE TABLE IF NOT EXISTS tenants (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
slug TEXT NOT NULL UNIQUE,
|
|
name TEXT NOT NULL,
|
|
db_name TEXT NOT NULL UNIQUE,
|
|
db_dsn TEXT NOT NULL,
|
|
status TEXT NOT NULL DEFAULT 'active',
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
)`); err != nil {
|
|
t.Fatalf("registry-schema: %v", err)
|
|
}
|
|
|
|
registry := tenant.NewRegistry(registryPool)
|
|
dsnTemplate := strings.Replace(adminDSN, "/postgres?", "/%s?", 1)
|
|
provisioner := tenant.NewProvisioner(adminPool, registry, dsnTemplate)
|
|
|
|
var tenants []tenant.Tenant
|
|
var slugs []string
|
|
for i := 0; i < tenantCount; i++ {
|
|
slug := fmt.Sprintf("mig_t%d", i)
|
|
slugs = append(slugs, slug)
|
|
tn, err := provisioner.Provision(ctx, slug, slug)
|
|
if err != nil {
|
|
t.Fatalf("provision %s: %v", slug, err)
|
|
}
|
|
tenants = append(tenants, tn)
|
|
}
|
|
|
|
orchestrator := NewOrchestrator(registry)
|
|
|
|
cleanup := func() {
|
|
for _, slug := range slugs {
|
|
_, _ = adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, "tenant_"+slug))
|
|
}
|
|
_, _ = registryPool.Exec(ctx, `DELETE FROM tenants WHERE slug = ANY($1)`, slugs)
|
|
registryPool.Close()
|
|
adminPool.Close()
|
|
}
|
|
return orchestrator, tenants, adminPool, cleanup
|
|
}
|
|
|
|
// Akzeptanzkriterium 1 + 2 + Pruefung 1: Rollout gegen mehrere Tenants, einer
|
|
// davon absichtlich inkompatibel — die anderen laufen trotzdem durch.
|
|
func TestRolloutAll_IsolatesFailurePerTenant(t *testing.T) {
|
|
orchestrator, tenants, _, cleanup := setupOrchestratorTest(t, 3)
|
|
defer cleanup()
|
|
ctx := context.Background()
|
|
|
|
migrations := []Migration{
|
|
{Version: "0001_demo_a", SQL: "CREATE TABLE demo_a (id INT);"},
|
|
{Version: "0002_demo_b", SQL: "CREATE TABLE demo_b (id INT);"},
|
|
}
|
|
|
|
// tenants[1] absichtlich inkompatibel machen: demo_b existiert schon,
|
|
// migration 0002 schlaegt dort mit "already exists" fehl.
|
|
badTenant := tenants[1]
|
|
badPool, err := pgxpool.New(ctx, badTenant.DBDSN)
|
|
if err != nil {
|
|
t.Fatalf("connect bad tenant: %v", err)
|
|
}
|
|
if _, err := badPool.Exec(ctx, "CREATE TABLE demo_b (id INT);"); err != nil {
|
|
t.Fatalf("inkompatiblen zustand vorbereiten: %v", err)
|
|
}
|
|
badPool.Close()
|
|
|
|
results, err := orchestrator.RolloutAll(ctx, migrations)
|
|
if err != nil {
|
|
t.Fatalf("rollout: %v", err)
|
|
}
|
|
if len(results) != 3 {
|
|
t.Fatalf("erwartet 3 ergebnisse, habe %d", len(results))
|
|
}
|
|
|
|
byslug := map[string]TenantResult{}
|
|
for _, r := range results {
|
|
byslug[r.TenantSlug] = r
|
|
}
|
|
|
|
good0 := byslug[tenants[0].Slug]
|
|
if good0.Err != nil || len(good0.Applied) != 2 {
|
|
t.Fatalf("tenant[0] sollte beide migrationen erhalten, habe %+v", good0)
|
|
}
|
|
good2 := byslug[tenants[2].Slug]
|
|
if good2.Err != nil || len(good2.Applied) != 2 {
|
|
t.Fatalf("tenant[2] sollte beide migrationen erhalten, habe %+v", good2)
|
|
}
|
|
|
|
bad := byslug[badTenant.Slug]
|
|
if bad.Err == nil {
|
|
t.Fatal("erwartet fehler fuer den inkompatiblen tenant")
|
|
}
|
|
if bad.FailedAt != "0002_demo_b" {
|
|
t.Fatalf("failedAt = %q, want 0002_demo_b", bad.FailedAt)
|
|
}
|
|
if len(bad.Applied) != 1 || bad.Applied[0] != "0001_demo_a" {
|
|
t.Fatalf("erwartet dass 0001_demo_a trotzdem erfolgreich war, habe %+v", bad.Applied)
|
|
}
|
|
}
|
|
|
|
// Akzeptanzkriterium 3 + Pruefung 2: Migrationsstand-Abfrage liefert fuer
|
|
// jeden Tenant den korrekten, unabhaengigen Stand.
|
|
func TestStatus_ReflectsPerTenantState(t *testing.T) {
|
|
orchestrator, tenants, _, cleanup := setupOrchestratorTest(t, 1)
|
|
defer cleanup()
|
|
ctx := context.Background()
|
|
|
|
migrations := []Migration{
|
|
{Version: "0001_ok", SQL: "CREATE TABLE ok_table (id INT);"},
|
|
{Version: "0002_fail", SQL: "SELECT this_column_does_not_exist FROM ok_table;"},
|
|
}
|
|
|
|
if _, err := orchestrator.RolloutAll(ctx, migrations); err != nil {
|
|
t.Fatalf("rollout: %v", err)
|
|
}
|
|
|
|
status, err := orchestrator.Status(ctx, tenants[0].Slug)
|
|
if err != nil {
|
|
t.Fatalf("status: %v", err)
|
|
}
|
|
if len(status) != 2 {
|
|
t.Fatalf("erwartet 2 status-eintraege, habe %d", len(status))
|
|
}
|
|
if !status[0].Success || status[0].Version != "0001_ok" {
|
|
t.Fatalf("status[0] unerwartet: %+v", status[0])
|
|
}
|
|
if status[1].Success || status[1].Version != "0002_fail" || status[1].Error == "" {
|
|
t.Fatalf("status[1] sollte fehlgeschlagen sein mit fehlertext: %+v", status[1])
|
|
}
|
|
}
|
|
|
|
// Pruefung 3: wiederholter Rollout-Versuch wendet bereits erfolgreiche
|
|
// Migrationen NICHT erneut an und kann die zuvor fehlgeschlagene nachholen,
|
|
// sobald die Ursache behoben ist.
|
|
func TestRolloutAll_RetryDoesNotReapplySuccessful(t *testing.T) {
|
|
orchestrator, tenants, _, cleanup := setupOrchestratorTest(t, 1)
|
|
defer cleanup()
|
|
ctx := context.Background()
|
|
|
|
// 0002 schlaegt beim ersten Versuch fehl, weil demo_conflict schon
|
|
// existiert (wir legen sie vorher an, um den Fehlschlag zu erzwingen).
|
|
pool, err := pgxpool.New(ctx, tenants[0].DBDSN)
|
|
if err != nil {
|
|
t.Fatalf("connect: %v", err)
|
|
}
|
|
if _, err := pool.Exec(ctx, "CREATE TABLE demo_conflict (id INT);"); err != nil {
|
|
t.Fatalf("vorbedingung: %v", err)
|
|
}
|
|
|
|
migrations := []Migration{
|
|
{Version: "0001_ok", SQL: "CREATE TABLE demo_first (id INT);"}, // OHNE IF NOT EXISTS,
|
|
// damit ein erneutes Anwenden nachweislich fehlschlagen wuerde.
|
|
{Version: "0002_conflict", SQL: "CREATE TABLE demo_conflict (id INT);"},
|
|
}
|
|
|
|
firstRun, err := orchestrator.RolloutAll(ctx, migrations)
|
|
if err != nil {
|
|
t.Fatalf("rollout 1: %v", err)
|
|
}
|
|
if firstRun[0].FailedAt != "0002_conflict" {
|
|
t.Fatalf("erwartet fehlschlag bei 0002_conflict im ersten lauf, habe %+v", firstRun[0])
|
|
}
|
|
|
|
// Ursache beheben.
|
|
if _, err := pool.Exec(ctx, "DROP TABLE demo_conflict;"); err != nil {
|
|
t.Fatalf("ursache beheben: %v", err)
|
|
}
|
|
pool.Close()
|
|
|
|
secondRun, err := orchestrator.RolloutAll(ctx, migrations)
|
|
if err != nil {
|
|
t.Fatalf("rollout 2: %v", err)
|
|
}
|
|
// Waere 0001_ok erneut angewendet worden ("CREATE TABLE demo_first" ohne
|
|
// IF NOT EXISTS), haette das einen Fehler erzeugt statt eines sauberen
|
|
// Applied-Eintrags fuer 0002_conflict.
|
|
if secondRun[0].Err != nil {
|
|
t.Fatalf("zweiter lauf sollte fehlerfrei sein, habe %+v", secondRun[0])
|
|
}
|
|
if len(secondRun[0].Applied) != 1 || secondRun[0].Applied[0] != "0002_conflict" {
|
|
t.Fatalf("erwartet nur 0002_conflict im zweiten lauf angewendet, habe %+v", secondRun[0].Applied)
|
|
}
|
|
}
|