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>
227 lines
7.2 KiB
Go
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
|
|
}
|