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>
634 lines
26 KiB
Go
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
|
|
}
|