syncova-backup/packages/platform/database/upgrade_test.go
Jerrit Fritzsche 610719c316
Some checks failed
CI / Backend (Go) (push) Failing after 3m7s
CI / Frontend (React/TypeScript) (push) Successful in 37s
CI / Sicherheitsprüfungen (push) Successful in 44s
Syncova Backups V1
Enterprise-Backup-, Recovery-, Verification-, Security- und
Monitoring-Plattform fuer Proxmox VE, Windows, Linux und Dateisysteme.

Der Leitsatz, der fast jede Entscheidung erklaert: Ein Backup gilt erst als
vertrauenswuerdig, wenn Integritaet geprueft und Wiederherstellbarkeit
nachgewiesen wurde. Deshalb steigt ein Wiederherstellungspunkt erst nach einem
tatsaechlich durchgefuehrten Restore-Test auf "recoverable", und Unbekanntes
geht in keine Bewertung als "gut" ein.

Umfang (Phasen 0-23):

- Repository Engine: inhaltsadressierte Bloecke, atomares Commit-Protokoll,
  Katalogaufbau allein aus den Manifesten — ohne Datenbank
- Backup Engine: inhaltsabhaengiges Chunking, Deduplizierung trotz
  Verschluesselung, zstd, AES-256-GCM, Streaming mit Gegendruck
- Agenten fuer Windows und Linux mit Auftragsabholung (Pull-Modell)
- Proxmox-Provider mit beiden Zugriffswegen auf die Sicherungsarchive
- Scheduler, Recovery Engine mit Pruefpunkt, Verification, Unveraenderlichkeit
- Weboberflaeche, Kennzahlen, Meldungen, Berichte, Security Center,
  Ransomware-Heuristik (meldet, handelt nie)
- Disaster Recovery, Haertung, Leistungsmessung, Chaos Testing
- Eingefrorene Vertraege fuer API, Migrationen, Backup-Format und Repository
- Auslieferungspaket fuer linux/amd64, linux/arm64 und windows/amd64

Nicht enthalten und als solches gekennzeichnet: Kapazitaetsprognose, Backup
Copy, Changed Block Tracking bei Proxmox, erweiterte Attribute und ACLs.

Gebaut, aber nie auf echter Hardware gefahren: der Windows-Dienst, die
systemd-Einheit und der verpflichtende Proxmox-Meilenstein — ob eine
wiederhergestellte VM startet, ist ungeprueft. Einzelheiten in CHANGELOG.md
und docs/release-candidate.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 09:10:54 +02:00

634 lines
26 KiB
Go

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
}