package database import ( "context" "errors" "fmt" "net/url" "os" "strings" "testing" "time" "github.com/golang-migrate/migrate/v4" "github.com/jackc/pgx/v5" "github.com/syncova/syncova/migrations" ) // seedSchemaVersion ist der Schemastand, mit dem der Upgrade-Test beginnt. // // Version 4 ist der erste Stand, in dem die tragenden Tabellen stehen: Benutzer, // Rollen, Repositories, Aufträge, Läufe. Frueher anzusetzen ergaebe eine // Datenbank, in die sich nichts Sinnvolles schreiben laesst — und ein // Upgrade-Test ohne Daten prueft nur, ob SQL laeuft. const seedSchemaVersion = 4 // TestUpgradeFromOlderSchemaKeepsData ist der Upgrade-Test (Phase 22). // // **Der einzige Test dieser Anlage, der nie gefahren wurde.** Bisher wurde jedes // Schema von Grund auf angelegt; damit prueft man ausschliesslich die // Neuinstallation. Ein Kunde macht aber etwas anderes: Er hat Daten und spielt // eine neue Version darueber. // // Der Unterschied ist nicht theoretisch. Eine Migration, die eine Spalte mit // NOT NULL ohne Vorgabewert ergaenzt, laeuft auf einer leeren Tabelle // einwandfrei durch und scheitert auf einer gefuellten. Dasselbe gilt fuer jede // nachtraegliche CHECK-Regel: Sie prueft auch die Zeilen, die schon da sind. // // Der Ablauf hier ist der einer echten Aktualisierung: alter Stand, Daten // hinein, neue Migrationen darueber, danach nachsehen. func TestUpgradeFromOlderSchemaKeepsData(testInstance *testing.T) { upgradeConnectionString := prepareScratchDatabase(testInstance, "syncova_upgrade_test") migrateToVersionForTest(testInstance, upgradeConnectionString, seedSchemaVersion) seededCounts := seedRealisticData(testInstance, upgradeConnectionString) // Ein Vergleich "vorher gleich nachher" ist auf leeren Tabellen immer // erfüllt. Ohne diese Absicherung wäre der ganze Test ein Placebo — er // bliebe grün, während eine Migration den gesamten Bestand löscht. if seededCounts.Users == 0 || seededCounts.Jobs == 0 || seededCounts.JobSources == 0 || seededCounts.JobRuns == 0 || seededCounts.Backups == 0 { testInstance.Fatalf("der Ausgangsbestand ist leer: %+v", seededCounts) } // Der eigentliche Vorgang: die neue Programmversion wendet ihre Migrationen // auf eine gefüllte Datenbank an. appliedMigrations, upgradeError := MigrateUp(migrations.FS, upgradeConnectionString) if upgradeError != nil { testInstance.Fatalf("die Aktualisierung einer gefüllten Datenbank schlug fehl: %v\n"+ " Genau das passiert einem Kunden beim Einspielen der neuen Version.", upgradeError) } if !appliedMigrations { testInstance.Fatal("es wurde keine einzige Migration angewandt — der Ausgangsstand war schon aktuell") } finalState, stateError := CurrentState(migrations.FS, upgradeConnectionString) if stateError != nil { testInstance.Fatalf("der Schemastand ließ sich nicht lesen: %v", stateError) } if finalState.IsDirty { testInstance.Fatal("die Datenbank ist nach der Aktualisierung als unsauber vermerkt") } if finalState.Version <= seedSchemaVersion { testInstance.Fatalf("der Schemastand ist %d und damit nicht fortgeschritten", finalState.Version) } verifySeededDataSurvived(testInstance, upgradeConnectionString, seededCounts) // Nach der Aktualisierung muss der Dienst starten dürfen. Diese Prüfung ist // das, was `syncova-api` beim Start tut — bricht sie, startet der Dienst // nicht, und der Kunde steht vor einer Anlage, die nach dem Update schweigt. if verifyError := VerifySchemaIsUpToDate(migrations.FS, upgradeConnectionString); verifyError != nil { testInstance.Errorf("der Dienst würde nach der Aktualisierung den Start verweigern: %v", verifyError) } } // TestUpgradeIsRepeatable prueft den zweiten Lauf. // // Ein zweiter Aufruf von `syncova-migrate up` ist der Normalfall: Er steht in // jedem Installationsskript und laeuft bei jedem Neustart eines Containers. // Er darf nichts tun und muss ohne Fehler enden. func TestUpgradeIsRepeatable(testInstance *testing.T) { repeatConnectionString := prepareScratchDatabase(testInstance, "syncova_repeat_test") if _, firstError := MigrateUp(migrations.FS, repeatConnectionString); firstError != nil { testInstance.Fatalf("der erste Lauf schlug fehl: %v", firstError) } appliedAgain, secondError := MigrateUp(migrations.FS, repeatConnectionString) if secondError != nil { testInstance.Fatalf("der zweite Lauf schlug fehl: %v", secondError) } if appliedAgain { testInstance.Error("der zweite Lauf hat Migrationen angewandt, obwohl das Schema aktuell war") } } // seededCounts haelt die Zeilenzahlen des Ausgangsstands. type seededCounts struct { // Users ist die Zahl angelegter Benutzer. Users int // Repositories ist die Zahl angelegter Repositories. Repositories int // Jobs ist die Zahl angelegter Sicherungsaufträge. Jobs int // JobSources ist die Zahl angelegter Quellen. JobSources int // JobRuns ist die Zahl angelegter Läufe. JobRuns int // Backups ist die Zahl vermerkter Backups. Backups int } // seedRealisticData legt Daten an, wie sie eine benutzte Anlage hat. // // Bewusst kein Minimalbeispiel: Eine Migration scheitert an dem, was schon da // ist, und eine leere Tabelle hat nichts, woran sie scheitern koennte. func seedRealisticData(testInstance *testing.T, connectionString string) seededCounts { testInstance.Helper() databaseConnection := openDirectConnection(testInstance, connectionString) executeSeedStatement(testInstance, databaseConnection, ` INSERT INTO users (username, email, password_hash, status) VALUES ('bestandsnutzer', 'bestand@syncova.local', '$argon2id$v=19$m=65536,t=3,p=2$YWJj$ZGVm', 'active')`) executeSeedStatement(testInstance, databaseConnection, ` INSERT INTO user_roles (user_id, role_id) SELECT u.id, r.id FROM users u, roles r WHERE u.username = 'bestandsnutzer' AND r.name = 'backup_operator'`) executeSeedStatement(testInstance, databaseConnection, ` INSERT INTO repositories (name, location) VALUES ('Bestandsziel', '/daten/repository')`) executeSeedStatement(testInstance, databaseConnection, ` INSERT INTO backup_jobs (name, schedule_type, repository_id) SELECT 'Bestandsauftrag', 'daily', id FROM repositories WHERE name = 'Bestandsziel'`) // Zwei Quellen, davon eine mit Ausschlussmustern: Die Muster liegen als // jsonb, und genau daran ist im Nachweis der Phase 18 schon einmal etwas // gescheitert. executeSeedStatement(testInstance, databaseConnection, ` INSERT INTO backup_job_sources (job_id, source_type, source_id, source_name, exclude_patterns) SELECT id, 'filesystem', '/daten', 'Dateiserver', '["*.tmp"]'::jsonb FROM backup_jobs WHERE name = 'Bestandsauftrag'`) executeSeedStatement(testInstance, databaseConnection, ` INSERT INTO backup_job_sources (job_id, source_type, source_id, source_name) SELECT id, 'linux_system', 'srv-01', 'Linux-Server' FROM backup_jobs WHERE name = 'Bestandsauftrag'`) executeSeedStatement(testInstance, databaseConnection, ` INSERT INTO backup_job_runs (job_id, status, correlation_id, started_at, completed_at) SELECT id, 'succeeded', gen_random_uuid(), NOW() - INTERVAL '2 hours', NOW() - INTERVAL '1 hour' FROM backup_jobs WHERE name = 'Bestandsauftrag'`) // Ein abgeschlossenes Backup samt Kette — die Zeilen, deren Verlust ein // Kunde sofort merkt. executeSeedStatement(testInstance, databaseConnection, ` INSERT INTO backup_chains (repository_id, source_reference) SELECT id, 'filesystem:/daten' FROM repositories WHERE name = 'Bestandsziel'`) executeSeedStatement(testInstance, databaseConnection, ` INSERT INTO backups (chain_id, repository_id, job_run_id, backup_id_in_repository, backup_type, status, consistency_level, manifest_ref, logical_bytes, unique_bytes, started_at, completed_at) SELECT c.id, r.id, run.id, 'bestand-backup-1', 'full', 'complete', 'crash_consistent', 'manifests/bestand-backup-1.json', 1048576, 524288, NOW() - INTERVAL '2 hours', NOW() - INTERVAL '1 hour' FROM backup_chains c, repositories r, backup_job_runs run WHERE r.name = 'Bestandsziel' AND c.repository_id = r.id LIMIT 1`) return seededCounts{ Users: countRows(testInstance, databaseConnection, "users"), Repositories: countRows(testInstance, databaseConnection, "repositories"), Jobs: countRows(testInstance, databaseConnection, "backup_jobs"), JobSources: countRows(testInstance, databaseConnection, "backup_job_sources"), JobRuns: countRows(testInstance, databaseConnection, "backup_job_runs"), Backups: countRows(testInstance, databaseConnection, "backups"), } } // verifySeededDataSurvived prueft den Bestand nach der Aktualisierung. func verifySeededDataSurvived(testInstance *testing.T, connectionString string, expectedCounts seededCounts) { testInstance.Helper() databaseConnection := openDirectConnection(testInstance, connectionString) actualCounts := seededCounts{ Users: countRows(testInstance, databaseConnection, "users"), Repositories: countRows(testInstance, databaseConnection, "repositories"), Jobs: countRows(testInstance, databaseConnection, "backup_jobs"), JobSources: countRows(testInstance, databaseConnection, "backup_job_sources"), JobRuns: countRows(testInstance, databaseConnection, "backup_job_runs"), Backups: countRows(testInstance, databaseConnection, "backups"), } if actualCounts != expectedCounts { testInstance.Errorf("die Aktualisierung hat Daten verändert:\n vorher: %+v\n nachher: %+v", expectedCounts, actualCounts) } // Stichprobe auf den Inhalt, nicht nur auf die Zahl: Eine Migration, die // jede Zeile behält und dabei ein Feld leert, bliebe sonst unbemerkt. var ( backupIdentifier string logicalBytes int64 consistencyLevel string ) scanError := databaseConnection.QueryRow(context.Background(), `SELECT backup_id_in_repository, logical_bytes, consistency_level FROM backups WHERE backup_id_in_repository = 'bestand-backup-1'`).Scan( &backupIdentifier, &logicalBytes, &consistencyLevel) if scanError != nil { testInstance.Fatalf("das Bestandsbackup ist nach der Aktualisierung nicht mehr lesbar: %v", scanError) } if logicalBytes != 1048576 || consistencyLevel != "crash_consistent" { testInstance.Errorf("die Angaben des Bestandsbackups haben sich verändert: %d Byte, %q", logicalBytes, consistencyLevel) } // Die Ausschlussmuster liegen als jsonb — die Stelle, an der schon einmal // etwas zu NULL geworden ist. var excludePatterns string if patternError := databaseConnection.QueryRow(context.Background(), `SELECT exclude_patterns::text FROM backup_job_sources WHERE source_id = '/daten'`).Scan( &excludePatterns); patternError != nil { testInstance.Fatalf("die Ausschlussmuster sind nicht mehr lesbar: %v", patternError) } if !strings.Contains(excludePatterns, "*.tmp") { testInstance.Errorf("die Ausschlussmuster gingen verloren: %q", excludePatterns) } // Die in Phase 7 ergänzte Spalte muss für Bestandszeilen leer sein — und // darf sie nicht ungültig machen: Eine Dateisystemquelle braucht keinen // Verbund. var sourcesWithCluster int if countError := databaseConnection.QueryRow(context.Background(), `SELECT COUNT(*) FROM backup_job_sources WHERE cluster_id IS NOT NULL`).Scan( &sourcesWithCluster); countError != nil { testInstance.Fatalf("die neue Spalte ließ sich nicht lesen: %v", countError) } if sourcesWithCluster != 0 { testInstance.Errorf("%d Bestandsquellen haben einen Verbund bekommen, den es nie gab", sourcesWithCluster) } } // migrateToVersionForTest bringt die Datenbank auf einen bestimmten Stand. // // Schrittweise statt in einem Sprung, weil golang-migrate nur so den Weg geht, // den auch eine echte Installation nimmt. func migrateToVersionForTest(testInstance *testing.T, connectionString string, targetVersion int) { testInstance.Helper() migrator, migratorError := newMigrator(migrations.FS, connectionString) if migratorError != nil { testInstance.Fatalf("der Migrator ließ sich nicht bauen: %v", migratorError) } defer func() { _, _ = migrator.Close() }() for stepNumber := 0; stepNumber < targetVersion; stepNumber++ { if stepError := migrator.Steps(1); stepError != nil { if errors.Is(stepError, migrate.ErrNoChange) { break } testInstance.Fatalf("der Schritt auf Version %d schlug fehl: %v", stepNumber+1, stepError) } } currentVersion, isDirty, versionError := migrator.Version() if versionError != nil { testInstance.Fatalf("die Version ließ sich nicht lesen: %v", versionError) } if isDirty { testInstance.Fatalf("der Ausgangsstand %d ist unsauber", currentVersion) } if int(currentVersion) != targetVersion { testInstance.Fatalf("der Ausgangsstand ist %d statt %d", currentVersion, targetVersion) } } // prepareScratchDatabase legt eine leere Datenbank fuer den Test an. // // Eine eigene Datenbank und nicht die Entwicklungsdatenbank: Der Test raeumt // sie vollstaendig ab, und ein `DROP DATABASE` auf der falschen Datenbank waere // der teuerste Fehler, den ein Test machen kann. func prepareScratchDatabase(testInstance *testing.T, databaseName string) string { testInstance.Helper() baseConnectionString := os.Getenv("SYNCOVA_TEST_DATABASE_URL") if baseConnectionString == "" { testInstance.Skip("SYNCOVA_TEST_DATABASE_URL ist nicht gesetzt; der Upgrade-Test wird übersprungen") } parsedConnectionURL, parseError := url.Parse(baseConnectionString) if parseError != nil { testInstance.Fatalf("die Verbindungszeichenkette ist unbrauchbar: %v", parseError) } // Für CREATE/DROP DATABASE wird eine Verbindung zu einer anderen Datenbank // gebraucht: Man kann die Datenbank nicht entfernen, in der man sitzt. administrativeURL := *parsedConnectionURL administrativeURL.Path = "/postgres" adminConnectContext, cancelAdminConnect := context.WithTimeout(context.Background(), 10*time.Second) defer cancelAdminConnect() adminConnection, connectError := pgx.Connect(adminConnectContext, administrativeURL.String()) if connectError != nil { testInstance.Skipf("die Verwaltungsverbindung kam nicht zustande: %v", connectError) } defer func() { _ = adminConnection.Close(context.Background()) }() dropAndCreateDatabase(testInstance, adminConnection, databaseName) testInstance.Cleanup(func() { cleanupContext, cancelCleanup := context.WithTimeout(context.Background(), 30*time.Second) defer cancelCleanup() cleanupConnection, cleanupError := pgx.Connect(cleanupContext, administrativeURL.String()) if cleanupError != nil { return } defer func() { _ = cleanupConnection.Close(context.Background()) }() _, _ = cleanupConnection.Exec(cleanupContext, fmt.Sprintf(`DROP DATABASE IF EXISTS %s WITH (FORCE)`, pgx.Identifier{databaseName}.Sanitize())) }) scratchURL := *parsedConnectionURL scratchURL.Path = "/" + databaseName return scratchURL.String() } // dropAndCreateDatabase legt die Testdatenbank frisch an. func dropAndCreateDatabase(testInstance *testing.T, adminConnection *pgx.Conn, databaseName string) { testInstance.Helper() executeContext, cancelExecute := context.WithTimeout(context.Background(), 30*time.Second) defer cancelExecute() quotedName := pgx.Identifier{databaseName}.Sanitize() if _, dropError := adminConnection.Exec(executeContext, fmt.Sprintf(`DROP DATABASE IF EXISTS %s WITH (FORCE)`, quotedName)); dropError != nil { testInstance.Fatalf("die Testdatenbank ließ sich nicht entfernen: %v", dropError) } if _, createError := adminConnection.Exec(executeContext, fmt.Sprintf(`CREATE DATABASE %s`, quotedName)); createError != nil { testInstance.Fatalf("die Testdatenbank ließ sich nicht anlegen: %v", createError) } } // openDirectConnection oeffnet eine Einzelverbindung zur Testdatenbank. func openDirectConnection(testInstance *testing.T, connectionString string) *pgx.Conn { testInstance.Helper() connectContext, cancelConnect := context.WithTimeout(context.Background(), 10*time.Second) defer cancelConnect() databaseConnection, connectError := pgx.Connect(connectContext, connectionString) if connectError != nil { testInstance.Fatalf("die Verbindung zur Testdatenbank kam nicht zustande: %v", connectError) } testInstance.Cleanup(func() { _ = databaseConnection.Close(context.Background()) }) return databaseConnection } // executeSeedStatement fuehrt eine einzelne Anweisung des Ausgangsbestands aus. // // Einzeln, weil pgx ueber das erweiterte Protokoll mehrere durch Semikolon // getrennte Anweisungen **nicht** ausfuehrt — der Fund aus Phase 8, dort blieben // Zeilen unbemerkt liegen. func executeSeedStatement(testInstance *testing.T, databaseConnection *pgx.Conn, statement string) { testInstance.Helper() executeContext, cancelExecute := context.WithTimeout(context.Background(), 10*time.Second) defer cancelExecute() if _, executeError := databaseConnection.Exec(executeContext, statement); executeError != nil { testInstance.Fatalf("der Ausgangsbestand ließ sich nicht anlegen: %v\n Anweisung: %s", executeError, strings.TrimSpace(statement)) } } // countRows zaehlt die Zeilen einer Tabelle. func countRows(testInstance *testing.T, databaseConnection *pgx.Conn, tableName string) int { testInstance.Helper() countContext, cancelCount := context.WithTimeout(context.Background(), 10*time.Second) defer cancelCount() var rowCount int if scanError := databaseConnection.QueryRow(countContext, fmt.Sprintf(`SELECT COUNT(*) FROM %s`, pgx.Identifier{tableName}.Sanitize())).Scan( &rowCount); scanError != nil { testInstance.Fatalf("die Zeilen von %s ließen sich nicht zählen: %v", tableName, scanError) } return rowCount } // TestRollbackRemovesOnlyTheLastStep ist der Rollback-Test (Phase 22). // // Ein Rollback ist die unangenehmste Zusage einer Datenbank: Er nimmt eine // Schemaaenderung zurueck, und alles, was ausschliesslich in der neuen Struktur // stand, ist danach weg. Das laesst sich nicht verhindern — wohl aber // **benennen**, und genau darum geht es hier. // // Geprueft wird, dass ein Rueckschritt // // 1. genau **eine** Migration zurueckninmt (nicht mehr), // 2. die Daten der aelteren Staende unberuehrt laesst, // 3. und die Anlage danach wieder vorwaerts laeuft. // // Was er **nicht** kann, steht in docs/release-candidate.md: Daten in Tabellen, // die es vorher nicht gab, sind nach dem Rueckschritt verloren. Ein Rollback // ersetzt keine Sicherung der Datenbank. func TestRollbackRemovesOnlyTheLastStep(testInstance *testing.T) { rollbackConnectionString := prepareScratchDatabase(testInstance, "syncova_rollback_test") if _, upError := MigrateUp(migrations.FS, rollbackConnectionString); upError != nil { testInstance.Fatalf("die Erstinstallation schlug fehl: %v", upError) } stateBefore, beforeError := CurrentState(migrations.FS, rollbackConnectionString) if beforeError != nil { testInstance.Fatalf("der Schemastand ließ sich nicht lesen: %v", beforeError) } seededCounts := seedRealisticData(testInstance, rollbackConnectionString) if seededCounts.Backups == 0 { testInstance.Fatal("der Ausgangsbestand ist leer") } // Der Bestand der letzten Migration wird ausdrücklich mitgeschrieben: Er // ist das, was der Rückschritt vernichtet, und das gehört gezeigt. databaseConnection := openDirectConnection(testInstance, rollbackConnectionString) executeSeedStatement(testInstance, databaseConnection, ` INSERT INTO proxmox_clusters (name, api_endpoint, api_token_id, api_token_ciphertext, api_token_key_version, backup_storage_id, archive_transport, archive_mount_roots) VALUES ('verbund-vor-rollback', 'https://pve.example:8006', 'syncova@pve!backup', '\x00', 'v1', 'local', 'local', '{"local":"/var/lib/vz"}')`) if clusterCount := countRows(testInstance, databaseConnection, "proxmox_clusters"); clusterCount != 1 { testInstance.Fatalf("der Verbund wurde nicht angelegt (%d Zeilen)", clusterCount) } if downError := MigrateDownOneStep(migrations.FS, rollbackConnectionString); downError != nil { testInstance.Fatalf("der Rückschritt schlug fehl: %v", downError) } stateAfterDown, downStateError := CurrentState(migrations.FS, rollbackConnectionString) if downStateError != nil { testInstance.Fatalf("der Schemastand ließ sich nicht lesen: %v", downStateError) } // Genau ein Schritt. Ein versehentlicher Rücklauf auf Version 0 löschte die // gesamte Control Plane — deshalb geht `down` immer nur einen Schritt. if stateAfterDown.Version != stateBefore.Version-1 { testInstance.Fatalf("der Rückschritt ging von %d auf %d statt auf %d", stateBefore.Version, stateAfterDown.Version, stateBefore.Version-1) } if stateAfterDown.IsDirty { testInstance.Fatal("die Datenbank ist nach dem Rückschritt als unsauber vermerkt") } // Die Daten der älteren Stände müssen unberührt sein. Ein Rückschritt, der // nebenbei Aufträge oder Backups mitnimmt, wäre ein Datenverlust ohne // Ansage. verifyConnection := openDirectConnection(testInstance, rollbackConnectionString) countsAfterDown := seededCounts countsAfterDown.Users = countRows(testInstance, verifyConnection, "users") countsAfterDown.Repositories = countRows(testInstance, verifyConnection, "repositories") countsAfterDown.Jobs = countRows(testInstance, verifyConnection, "backup_jobs") countsAfterDown.JobSources = countRows(testInstance, verifyConnection, "backup_job_sources") countsAfterDown.JobRuns = countRows(testInstance, verifyConnection, "backup_job_runs") countsAfterDown.Backups = countRows(testInstance, verifyConnection, "backups") if countsAfterDown != seededCounts { testInstance.Errorf("der Rückschritt hat Daten älterer Stände mitgenommen:\n"+ " vorher: %+v\n nachher: %+v", seededCounts, countsAfterDown) } // Weiter zurück, bis die Tabelle der Verbünde verschwindet. // // Bewusst gesucht statt auf „die letzte Migration" gesetzt: Welche Migration // zuletzt kommt, ändert sich mit jeder neuen. Ein Test, der das unterstellt, // bricht beim nächsten Mal und behauptet einen Fehler, wo keiner ist — // genau das ist hier beim Hinzufügen von 000013 passiert. stepsUntilTableGone := 1 for tableStillExists(testInstance, verifyConnection, "proxmox_clusters") { if stepsUntilTableGone > int(stateBefore.Version) { testInstance.Fatal("die Tabelle der Verbünde verschwand auch nach vollständigem Rückbau nicht") } if downError := MigrateDownOneStep(migrations.FS, rollbackConnectionString); downError != nil { testInstance.Fatalf("der weitere Rückschritt schlug fehl: %v", downError) } stepsUntilTableGone++ verifyConnection = openDirectConnection(testInstance, rollbackConnectionString) } // Und der Weg zurück nach vorn muss offen bleiben: Ein Rollback, nach dem // sich nicht mehr aktualisieren lässt, wäre eine Sackgasse. if _, upAgainError := MigrateUp(migrations.FS, rollbackConnectionString); upAgainError != nil { testInstance.Fatalf("nach dem Rückschritt ließ sich nicht wieder aktualisieren: %v", upAgainError) } stateAfterUp, upStateError := CurrentState(migrations.FS, rollbackConnectionString) if upStateError != nil { testInstance.Fatalf("der Schemastand ließ sich nicht lesen: %v", upStateError) } if stateAfterUp.Version != stateBefore.Version { testInstance.Errorf("nach dem erneuten Vorlauf ist der Stand %d statt %d", stateAfterUp.Version, stateBefore.Version) } // Die Tabelle ist wieder da — aber leer. Das ist die Eigenschaft eines // Rollbacks, die man kennen muss: Wer den Verbund braucht, richtet ihn neu // ein; die Zugangsdaten sind nicht wiederherstellbar. Ein Rollback ersetzt // keine Sicherung der Datenbank. restoredConnection := openDirectConnection(testInstance, rollbackConnectionString) if clusterCount := countRows(testInstance, restoredConnection, "proxmox_clusters"); clusterCount != 0 { testInstance.Errorf("nach dem Wiederaufbau stehen %d Verbünde da — erwartet war eine leere Tabelle", clusterCount) } } // TestEveryDownMigrationRunsOnAFilledDatabase prueft alle Rueckrichtungen. // // Eine down-Migration wird typischerweise nie ausgefuehrt — bis zu dem Tag, an // dem sie gebraucht wird. Dann steht sie unter Zeitdruck und auf einer // gefuellten Datenbank, und ein Fremdschluessel, an den niemand gedacht hat, // laesst sie scheitern. // // Hier laufen **alle** Rueckrichtungen bis auf Version 0 durch, und zwar auf // einer Datenbank mit Daten. func TestEveryDownMigrationRunsOnAFilledDatabase(testInstance *testing.T) { downConnectionString := prepareScratchDatabase(testInstance, "syncova_down_test") if _, upError := MigrateUp(migrations.FS, downConnectionString); upError != nil { testInstance.Fatalf("die Erstinstallation schlug fehl: %v", upError) } initialState, stateError := CurrentState(migrations.FS, downConnectionString) if stateError != nil { testInstance.Fatalf("der Schemastand ließ sich nicht lesen: %v", stateError) } seedRealisticData(testInstance, downConnectionString) for remainingSteps := int(initialState.Version); remainingSteps > 0; remainingSteps-- { if downError := MigrateDownOneStep(migrations.FS, downConnectionString); downError != nil { testInstance.Fatalf("die Rückrichtung von Version %d schlug auf einer gefüllten "+ "Datenbank fehl: %v\n"+ " Genau so trifft es einen Betreiber: unter Zeitdruck und mit Daten.", remainingSteps, downError) } } finalState, finalError := CurrentState(migrations.FS, downConnectionString) if finalError != nil { testInstance.Fatalf("der Schemastand ließ sich nicht lesen: %v", finalError) } if finalState.HasAnyMigration && finalState.Version != 0 { testInstance.Errorf("nach allen Rückschritten steht die Version bei %d statt 0", finalState.Version) } // Und wieder hoch: Eine Anlage, die sich zurücknehmen, aber nicht wieder // aufbauen lässt, ist verloren. if _, upError := MigrateUp(migrations.FS, downConnectionString); upError != nil { testInstance.Fatalf("der Wiederaufbau nach vollständigem Rückbau schlug fehl: %v", upError) } } // tableStillExists meldet, ob eine Tabelle im Schema liegt. func tableStillExists(testInstance *testing.T, databaseConnection *pgx.Conn, tableName string) bool { testInstance.Helper() var tableExists bool if scanError := databaseConnection.QueryRow(context.Background(), `SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = $1)`, tableName).Scan(&tableExists); scanError != nil { testInstance.Fatalf("das Schema ließ sich nicht abfragen: %v", scanError) } return tableExists }