syncova-backup/migrations/contract_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

227 lines
7.2 KiB
Go

package migrations
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"testing"
)
// checksumFileName ist die eingefrorene Prüfsummenliste.
const checksumFileName = "checksums.txt"
// TestReleasedMigrationsAreImmutable haelt das Migrationsverfahren fest (Phase 22).
//
// **Eine ausgelieferte Migration darf sich nie wieder aendern.** Der Grund ist
// unangenehm einfach: Datenbanken, die sie bereits angewandt haben, fuehren sie
// nicht erneut aus — golang-migrate merkt sich nur die Versionsnummer. Eine
// nachtraeglich geaenderte Migration wirkt deshalb ausschliesslich auf
// **neue** Installationen. Das Ergebnis sind zwei Schemata mit derselben
// Versionsnummer, und der Unterschied faellt erst auf, wenn eine Abfrage auf
// einer der beiden Anlagen scheitert.
//
// Wer etwas aendern will, schreibt eine neue Migration. Immer.
//
// Aendert sich eine Zeile hier, ist die Frage nicht "wie mache ich den Test
// gruen", sondern: Hat diese Migration schon jemand angewandt? Wenn ja, ist die
// Aenderung falsch, egal wie klein sie ist.
func TestReleasedMigrationsAreImmutable(testInstance *testing.T) {
currentChecksums, computeError := computeMigrationChecksums()
if computeError != nil {
testInstance.Fatalf("die Prüfsummen ließen sich nicht bilden: %v", computeError)
}
frozenContent, readError := os.ReadFile(checksumFileName)
if readError != nil {
testInstance.Fatalf("die eingefrorene Prüfsummenliste ließ sich nicht lesen: %v", readError)
}
frozenChecksums := parseChecksumFile(string(frozenContent))
for fileName, frozenDigest := range frozenChecksums {
currentDigest, isPresent := currentChecksums[fileName]
if !isPresent {
testInstance.Errorf("die ausgelieferte Migration %s fehlt.\n"+
" Anlagen, die sie angewandt haben, lassen sich damit nicht mehr zurücknehmen.", fileName)
continue
}
if currentDigest != frozenDigest {
testInstance.Errorf("die ausgelieferte Migration %s wurde verändert.\n"+
" Datenbanken, die sie bereits angewandt haben, führen sie nicht erneut aus — "+
"die Änderung wirkt nur auf neue Installationen.\n"+
" Schreiben Sie stattdessen eine neue Migration.", fileName)
}
}
// Neue Migrationen sind der Normalfall und kein Fehler; sie müssen aber
// eingetragen werden, sonst schützt die Liste ab morgen nichts mehr.
for fileName := range currentChecksums {
if _, isFrozen := frozenChecksums[fileName]; !isFrozen {
testInstance.Errorf("die neue Migration %s ist nicht in %s eingetragen.\n"+
" Ohne Eintrag bleibt sie auf Dauer ungeschützt.", fileName, checksumFileName)
}
}
}
// TestEveryMigrationHasBothDirections prueft die Vollstaendigkeit.
//
// Eine Migration ohne Gegenstueck laesst sich nicht zurueckziehen. Das faellt
// genau dann auf, wenn man es braucht: nach einem missglueckten Upgrade in der
// Produktion.
func TestEveryMigrationHasBothDirections(testInstance *testing.T) {
directoryEntries, readError := os.ReadDir(".")
if readError != nil {
testInstance.Fatalf("das Migrationsverzeichnis ließ sich nicht lesen: %v", readError)
}
directionsByVersion := make(map[string]map[string]bool)
for _, singleEntry := range directoryEntries {
if !strings.HasSuffix(singleEntry.Name(), ".sql") {
continue
}
versionPart, directionPart, isValid := splitMigrationName(singleEntry.Name())
if !isValid {
testInstance.Errorf("die Datei %q folgt nicht dem Namensschema "+
"<version>_<beschreibung>.<up|down>.sql", singleEntry.Name())
continue
}
if directionsByVersion[versionPart] == nil {
directionsByVersion[versionPart] = make(map[string]bool)
}
directionsByVersion[versionPart][directionPart] = true
}
if len(directionsByVersion) == 0 {
testInstance.Fatal("es wurde keine einzige Migration gefunden")
}
for versionPart, foundDirections := range directionsByVersion {
if !foundDirections["up"] {
testInstance.Errorf("zur Migration %s fehlt die Aufwärtsrichtung", versionPart)
}
if !foundDirections["down"] {
testInstance.Errorf("zur Migration %s fehlt die Abwärtsrichtung; "+
"sie ließe sich nicht zurücknehmen", versionPart)
}
}
// Lückenlose Nummerierung: Eine fehlende Nummer bedeutet entweder eine
// verlorene Migration oder eine, die jemand aus dem Verzeichnis genommen
// hat, nachdem sie schon lief.
versionNumbers := make([]int, 0, len(directionsByVersion))
for versionPart := range directionsByVersion {
parsedNumber, parseError := strconv.Atoi(versionPart)
if parseError != nil {
testInstance.Errorf("die Version %q ist keine Zahl", versionPart)
continue
}
versionNumbers = append(versionNumbers, parsedNumber)
}
sort.Ints(versionNumbers)
for numberIndex, versionNumber := range versionNumbers {
if versionNumber != numberIndex+1 {
testInstance.Errorf("die Nummerierung hat eine Lücke: erwartet %d, gefunden %d",
numberIndex+1, versionNumber)
break
}
}
}
// computeMigrationChecksums bildet die Pruefsumme jeder Migrationsdatei.
//
// Ueber das eingebettete Dateisystem und nicht ueber die Festplatte: Genau
// dieser Inhalt landet im Binary, und nur er wird jemals ausgefuehrt.
func computeMigrationChecksums() (map[string]string, error) {
directoryEntries, readError := FS.ReadDir(".")
if readError != nil {
return nil, readError
}
checksumsByName := make(map[string]string, len(directoryEntries))
for _, singleEntry := range directoryEntries {
if !strings.HasSuffix(singleEntry.Name(), ".sql") {
continue
}
fileContent, contentError := FS.ReadFile(singleEntry.Name())
if contentError != nil {
return nil, fmt.Errorf("%s: %w", singleEntry.Name(), contentError)
}
contentDigest := sha256.Sum256(fileContent)
checksumsByName[singleEntry.Name()] = hex.EncodeToString(contentDigest[:])
}
return checksumsByName, nil
}
// parseChecksumFile liest die eingefrorene Liste.
func parseChecksumFile(fileContent string) map[string]string {
parsedChecksums := make(map[string]string, 32)
for _, currentLine := range strings.Split(fileContent, "\n") {
trimmedLine := strings.TrimSpace(currentLine)
if trimmedLine == "" || strings.HasPrefix(trimmedLine, "#") {
continue
}
digestPart, namePart, hasSeparator := strings.Cut(trimmedLine, " ")
if !hasSeparator {
continue
}
parsedChecksums[strings.TrimSpace(namePart)] = strings.TrimSpace(digestPart)
}
return parsedChecksums
}
// splitMigrationName zerlegt einen Migrationsdateinamen.
func splitMigrationName(fileName string) (version string, direction string, isValid bool) {
baseName := strings.TrimSuffix(filepath.Base(fileName), ".sql")
remainingName, directionPart, hasDirection := cutLast(baseName, ".")
if !hasDirection || (directionPart != "up" && directionPart != "down") {
return "", "", false
}
versionPart, _, hasDescription := strings.Cut(remainingName, "_")
if !hasDescription || versionPart == "" {
return "", "", false
}
return versionPart, directionPart, true
}
// cutLast trennt an der letzten Fundstelle.
func cutLast(fullText string, separator string) (before string, after string, found bool) {
separatorIndex := strings.LastIndex(fullText, separator)
if separatorIndex < 0 {
return fullText, "", false
}
return fullText[:separatorIndex], fullText[separatorIndex+len(separator):], true
}