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>
254 lines
9.1 KiB
Go
254 lines
9.1 KiB
Go
package backupformat
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/hex"
|
|
"errors"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// fixtureFileName ist der eingefrorene Beispielcontainer.
|
|
//
|
|
// Er wurde mit Formatversion 1 geschrieben und liegt als Datei im Repository.
|
|
// Das ist der Unterschied zu einem gewöhnlichen Rundlauftest: Der schreibt und
|
|
// liest mit **demselben** Code und bliebe deshalb auch dann grün, wenn sich
|
|
// beide Seiten gemeinsam ändern.
|
|
const fixtureFileName = "testdata/container-v1.syncova"
|
|
|
|
// TestFormatVersionIsFrozen haelt die Formatversion fest (Phase 22).
|
|
//
|
|
// Die Version ist ausgeliefert. Sie zu erhoehen ist erlaubt — aber niemals
|
|
// nebenbei: Ein Container traegt sie im Kopf, und ein Leser entscheidet daran,
|
|
// ob er ihn ueberhaupt anfassen darf.
|
|
func TestFormatVersionIsFrozen(testInstance *testing.T) {
|
|
if FormatVersion != 1 {
|
|
testInstance.Errorf("die geschriebene Formatversion ist %d statt 1.\n"+
|
|
" Eine neue Version verlangt einen Leser für die alte — sonst sind "+
|
|
"bestehende Backups nicht mehr lesbar.", FormatVersion)
|
|
}
|
|
|
|
if MinimumReadableVersion != 1 {
|
|
testInstance.Errorf("die älteste lesbare Version ist %d statt 1.\n"+
|
|
" Sie anzuheben macht ausgelieferte Backups unlesbar.", MinimumReadableVersion)
|
|
}
|
|
|
|
// Die Kennung am Anfang jedes Containers. Sie zu ändern hieße: Kein
|
|
// bestehender Container wird mehr als Syncova-Container erkannt.
|
|
if string(containerMagic[:]) != "SYNCOVA1" {
|
|
testInstance.Errorf("die Containerkennung ist %q statt \"SYNCOVA1\"", string(containerMagic[:]))
|
|
}
|
|
|
|
if string(footerMagic[:]) != "SYNFOOT1" {
|
|
testInstance.Errorf("die Footerkennung ist %q statt \"SYNFOOT1\"", string(footerMagic[:]))
|
|
}
|
|
}
|
|
|
|
// TestSectionTypeValuesAreFrozen haelt die Abschnittsnummern fest.
|
|
//
|
|
// Die Werte sind Teil des Formatvertrags. Eine neu belegte Nummer deutet einen
|
|
// bestehenden Container **falsch** — und zwar ohne Fehler: Der Leser findet
|
|
// einen gueltigen Abschnitt vor und haelt ihn fuer etwas anderes.
|
|
func TestSectionTypeValuesAreFrozen(testInstance *testing.T) {
|
|
frozenValues := map[SectionType]uint8{
|
|
SectionManifest: 1,
|
|
SectionChunkIndex: 2,
|
|
SectionBlockMap: 3,
|
|
SectionDataChunks: 4,
|
|
SectionSourceMetadata: 5,
|
|
SectionIntegrityInfo: 6,
|
|
}
|
|
|
|
for sectionType, expectedValue := range frozenValues {
|
|
if uint8(sectionType) != expectedValue {
|
|
testInstance.Errorf("der Abschnitt %s hat die Nummer %d statt %d.\n"+
|
|
" Eine neu belegte Nummer deutet bestehende Container falsch — ohne Fehlermeldung.",
|
|
sectionType, uint8(sectionType), expectedValue)
|
|
}
|
|
}
|
|
|
|
frozenFlags := map[string]SectionFlags{
|
|
"FlagRequired": 1,
|
|
"FlagCompressed": 2,
|
|
"FlagEncrypted": 4,
|
|
"FlagDeferredDigest": 8,
|
|
}
|
|
|
|
actualFlags := map[string]SectionFlags{
|
|
"FlagRequired": FlagRequired,
|
|
"FlagCompressed": FlagCompressed,
|
|
"FlagEncrypted": FlagEncrypted,
|
|
"FlagDeferredDigest": FlagDeferredDigest,
|
|
}
|
|
|
|
for flagName, expectedValue := range frozenFlags {
|
|
if actualFlags[flagName] != expectedValue {
|
|
testInstance.Errorf("das Kennzeichen %s hat den Wert %d statt %d",
|
|
flagName, actualFlags[flagName], expectedValue)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestFrozenContainerStaysReadable liest den eingefrorenen Beispielcontainer.
|
|
//
|
|
// Das ist der eigentliche Vertragstest des Formats: eine **Datei von gestern**,
|
|
// gelesen vom Code von heute. Bricht er, ist ein bestehendes Backup unlesbar
|
|
// geworden — und das ist der schwerste denkbare Fehler dieser Anlage.
|
|
func TestFrozenContainerStaysReadable(testInstance *testing.T) {
|
|
fixtureContent, readError := os.ReadFile(fixtureFileName)
|
|
if readError != nil {
|
|
testInstance.Fatalf("der eingefrorene Container ließ sich nicht lesen: %v\n"+
|
|
" Erzeugen mit: go test -run TestWriteFrozenContainerFixture -args -schreibe-fixture", readError)
|
|
}
|
|
|
|
containerReader, openError := NewReader(bytes.NewReader(fixtureContent))
|
|
if openError != nil {
|
|
testInstance.Fatalf("der eingefrorene Container ließ sich nicht öffnen: %v", openError)
|
|
}
|
|
|
|
containerHeader := containerReader.Header()
|
|
|
|
if containerHeader.BackupID != "eingefroren-phase-22" {
|
|
testInstance.Errorf("die Backup-Kennung ist %q", containerHeader.BackupID)
|
|
}
|
|
|
|
if containerHeader.SourceType != "filesystem" {
|
|
testInstance.Errorf("die Quellart ist %q", containerHeader.SourceType)
|
|
}
|
|
|
|
if containerReader.FormatVersion() != 1 {
|
|
testInstance.Errorf("die Formatversion ist %d", containerReader.FormatVersion())
|
|
}
|
|
|
|
sectionsSeen := make(map[SectionType][]byte, 4)
|
|
|
|
for {
|
|
nextSection, sectionError := containerReader.NextSection()
|
|
if errors.Is(sectionError, io.EOF) {
|
|
break
|
|
}
|
|
|
|
if sectionError != nil {
|
|
testInstance.Fatalf("ein Abschnitt ließ sich nicht lesen: %v", sectionError)
|
|
}
|
|
|
|
sectionsSeen[nextSection.Type] = nextSection.Content
|
|
}
|
|
|
|
// Der Footer entscheidet über die Vollständigkeit: Ein abgeschnittener
|
|
// Container hat keinen und wird dadurch zuverlässig erkannt.
|
|
if !containerReader.IsComplete() {
|
|
testInstance.Fatal("der eingefrorene Container gilt als unvollständig")
|
|
}
|
|
|
|
if string(sectionsSeen[SectionDataChunks]) != "eingefrorene nutzdaten der phase 22" {
|
|
testInstance.Errorf("die Datenblöcke weichen ab: %q", string(sectionsSeen[SectionDataChunks]))
|
|
}
|
|
|
|
if len(sectionsSeen[SectionManifest]) == 0 {
|
|
testInstance.Error("im eingefrorenen Container fehlt das Manifest")
|
|
}
|
|
|
|
// Ein Abschnitt, den es damals noch nicht gab, darf einen alten Leser nicht
|
|
// stören — solange er nicht als erforderlich gekennzeichnet ist. Das ist
|
|
// die Erweiterbarkeit, die das Format überhaupt zukunftsfähig macht.
|
|
if len(sectionsSeen[SectionIntegrityInfo]) == 0 {
|
|
testInstance.Error("der optionale Zusatzabschnitt wurde nicht gelesen")
|
|
}
|
|
}
|
|
|
|
// TestTruncatedFrozenContainerIsRejected prueft die Gegenprobe.
|
|
//
|
|
// Ohne sie belegte der Fixture-Test nur, dass irgendetwas gelesen wird. Ein
|
|
// abgeschnittener Container **muss** auffallen: Er sieht bis zur letzten
|
|
// gelesenen Stelle einwandfrei aus.
|
|
func TestTruncatedFrozenContainerIsRejected(testInstance *testing.T) {
|
|
fixtureContent, readError := os.ReadFile(fixtureFileName)
|
|
if readError != nil {
|
|
testInstance.Skipf("der eingefrorene Container fehlt: %v", readError)
|
|
}
|
|
|
|
truncatedContent := fixtureContent[:len(fixtureContent)-16]
|
|
|
|
containerReader, openError := NewReader(bytes.NewReader(truncatedContent))
|
|
if openError != nil {
|
|
// Schon das Öffnen darf scheitern — dann ist die Sache erledigt.
|
|
return
|
|
}
|
|
|
|
for {
|
|
_, sectionError := containerReader.NextSection()
|
|
if sectionError != nil {
|
|
break
|
|
}
|
|
}
|
|
|
|
if containerReader.IsComplete() {
|
|
testInstance.Error("ein abgeschnittener Container wurde als vollständig gemeldet")
|
|
}
|
|
}
|
|
|
|
// TestWriteFrozenContainerFixture erzeugt den eingefrorenen Container neu.
|
|
//
|
|
// Er laeuft nur mit ausdruecklichem Schalter. Ihn versehentlich mitlaufen zu
|
|
// lassen hiesse, den Vertrag jedes Mal auf den aktuellen Stand zu heben — und
|
|
// damit genau die Pruefung abzuschalten, um die es geht.
|
|
//
|
|
// go test ./packages/backupformat -run TestWriteFrozenContainerFixture -schreibe-fixture
|
|
func TestWriteFrozenContainerFixture(testInstance *testing.T) {
|
|
if os.Getenv("SYNCOVA_WRITE_FIXTURE") != "ja" {
|
|
testInstance.Skip("nur mit SYNCOVA_WRITE_FIXTURE=ja; sonst würde der eingefrorene Vertrag überschrieben")
|
|
}
|
|
|
|
var containerBuffer bytes.Buffer
|
|
|
|
containerWriter, writerError := NewWriter(&containerBuffer, ContainerHeader{
|
|
BackupID: "eingefroren-phase-22",
|
|
ChainID: "kette-phase-22",
|
|
SourceID: "/daten",
|
|
SourceType: "filesystem",
|
|
CreatedAt: time.Date(2026, 8, 13, 12, 0, 0, 0, time.UTC),
|
|
CreatedByVersion: "0.1.0-dev",
|
|
RepositoryID: "repository-phase-22",
|
|
})
|
|
if writerError != nil {
|
|
testInstance.Fatalf("der Container ließ sich nicht anlegen: %v", writerError)
|
|
}
|
|
|
|
if sectionError := containerWriter.WriteJSONSection(SectionManifest, FlagRequired, map[string]any{
|
|
"manifest_version": 1,
|
|
"backup_id": "eingefroren-phase-22",
|
|
"entries": []any{},
|
|
}); sectionError != nil {
|
|
testInstance.Fatalf("das Manifest ließ sich nicht schreiben: %v", sectionError)
|
|
}
|
|
|
|
if sectionError := containerWriter.WriteSection(SectionDataChunks, FlagRequired,
|
|
[]byte("eingefrorene nutzdaten der phase 22")); sectionError != nil {
|
|
testInstance.Fatalf("die Datenblöcke ließen sich nicht schreiben: %v", sectionError)
|
|
}
|
|
|
|
if sectionError := containerWriter.WriteSection(SectionIntegrityInfo, 0,
|
|
[]byte("optionaler zusatzabschnitt")); sectionError != nil {
|
|
testInstance.Fatalf("der Zusatzabschnitt ließ sich nicht schreiben: %v", sectionError)
|
|
}
|
|
|
|
if closeError := containerWriter.Close(); closeError != nil {
|
|
testInstance.Fatalf("der Container ließ sich nicht abschließen: %v", closeError)
|
|
}
|
|
|
|
if makeError := os.MkdirAll(filepath.Dir(fixtureFileName), 0o755); makeError != nil {
|
|
testInstance.Fatalf("das Verzeichnis ließ sich nicht anlegen: %v", makeError)
|
|
}
|
|
|
|
if writeError := os.WriteFile(fixtureFileName, containerBuffer.Bytes(), 0o644); writeError != nil {
|
|
testInstance.Fatalf("der Container ließ sich nicht ablegen: %v", writeError)
|
|
}
|
|
|
|
testInstance.Logf("eingefrorener Container geschrieben: %d Byte, Anfang %s",
|
|
containerBuffer.Len(), hex.EncodeToString(containerBuffer.Bytes()[:8]))
|
|
}
|