syncova-backup/packages/repository/catalog.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

234 lines
8.6 KiB
Go

package repository
import (
"encoding/json"
"fmt"
"time"
)
// CatalogVersion ist die Version des Katalogformats.
const CatalogVersion = 1
// CatalogEntry beschreibt ein Backup im Katalog.
//
// Der Eintrag enthält bewusst nur, was für eine Übersicht nötig ist. Alle
// Einzelheiten stehen im Manifest — der Katalog bleibt dadurch klein und
// schnell ladbar (PROMPT.md §47).
type CatalogEntry struct {
// BackupID ist die Kennung des Backups.
BackupID string `json:"backup_id"`
// ChainID verbindet das Backup mit seiner Kette.
ChainID string `json:"chain_id"`
// ParentBackupID benennt das Elternbackup einer Zusatzsicherung.
ParentBackupID string `json:"parent_backup_id,omitempty"`
// BackupType ist die Art des Backups.
BackupType BackupType `json:"backup_type"`
// ConsistencyLevel beschreibt die Konsistenz der Daten.
ConsistencyLevel ConsistencyLevel `json:"consistency_level"`
// SourceType benennt die Art der Quelle.
SourceType string `json:"source_type"`
// SourceID ist die Kennung der Quelle.
SourceID string `json:"source_id"`
// SourceName ist der sprechende Name der Quelle.
SourceName string `json:"source_name"`
// StartedAt ist der Beginn des Backups in UTC.
StartedAt time.Time `json:"started_at"`
// CompletedAt ist der Abschluss des Backups in UTC.
CompletedAt time.Time `json:"completed_at"`
// LogicalBytes ist die Menge der gesicherten Ursprungsdaten.
LogicalBytes int64 `json:"logical_bytes"`
// StoredBytes ist die tatsächlich abgelegte Datenmenge.
StoredBytes int64 `json:"stored_bytes"`
// ChunkCount ist die Zahl der Chunk-Verweise des Backups.
ChunkCount int64 `json:"chunk_count"`
// EntryCount ist die Zahl der gesicherten Objekte.
EntryCount int `json:"entry_count"`
// ImmutableUntil ist das Ende der Aufbewahrungspflicht in UTC.
ImmutableUntil *time.Time `json:"immutable_until,omitempty"`
// SelfContainedRestore meldet ein Manifest, das ohne seine Kette ausreicht.
SelfContainedRestore bool `json:"self_contained_restore,omitempty"`
// ManifestHash ist die Prüfsumme des zugehörigen Manifests.
//
// Sie erlaubt es festzustellen, ob ein Manifest nach der Aufnahme in den
// Katalog verändert wurde.
ManifestHash string `json:"manifest_hash"`
}
// Catalog ist die Übersicht aller Backups eines Repositorys.
//
// Der Katalog ist ausdrücklich nur ein Beschleuniger. Verbindlich sind die
// Manifeste; geht der Katalog verloren oder ist er beschädigt, wird er allein
// aus ihnen wiederhergestellt (PROMPT.md §46).
type Catalog struct {
// CatalogVersion ist die Version des Katalogformats.
CatalogVersion int `json:"catalog_version"`
// RepositoryID benennt das zugehörige Repository.
//
// Sie verhindert, dass ein versehentlich kopierter Katalog zu einem fremden
// Repository gehört und dort falsche Angaben macht.
RepositoryID string `json:"repository_id"`
// Entries sind die Backups, nach Abschlusszeitpunkt absteigend sortiert.
Entries []CatalogEntry `json:"entries"`
// GeneratedAt ist der Zeitpunkt der letzten Aktualisierung in UTC.
GeneratedAt time.Time `json:"generated_at"`
// RebuiltFromManifests meldet, dass der Katalog durch einen Scan entstand.
RebuiltFromManifests bool `json:"rebuilt_from_manifests"`
}
// FindByBackupID sucht einen Eintrag anhand seiner Backup-Kennung.
func (catalog *Catalog) FindByBackupID(backupID string) (CatalogEntry, bool) {
for _, catalogEntry := range catalog.Entries {
if catalogEntry.BackupID == backupID {
return catalogEntry, true
}
}
return CatalogEntry{}, false
}
// FindByChainID liefert alle Backups einer Kette, älteste zuerst.
//
// Die Reihenfolge ist für die Wiederherstellung wesentlich: eine Zusatzsicherung
// lässt sich nur zusammen mit ihren Vorgängern zurückspielen.
func (catalog *Catalog) FindByChainID(chainID string) []CatalogEntry {
chainEntries := make([]CatalogEntry, 0)
for _, catalogEntry := range catalog.Entries {
if catalogEntry.ChainID == chainID {
chainEntries = append(chainEntries, catalogEntry)
}
}
// Der Katalog ist absteigend sortiert; für eine Kette wird aufsteigend benötigt.
for leftIndex, rightIndex := 0, len(chainEntries)-1; leftIndex < rightIndex; leftIndex, rightIndex = leftIndex+1, rightIndex-1 {
chainEntries[leftIndex], chainEntries[rightIndex] = chainEntries[rightIndex], chainEntries[leftIndex]
}
return chainEntries
}
// ValidateChain prüft, ob eine Backup-Kette lückenlos ist (PROMPT.md §84).
//
// Fehlt ein Glied, sind alle darauf folgenden Wiederherstellungspunkte
// unbrauchbar. Das muss eindeutig benannt werden, statt eine scheinbar
// vollständige Liste anzuzeigen.
func (catalog *Catalog) ValidateChain(chainID string) ChainValidationResult {
chainEntries := catalog.FindByChainID(chainID)
if len(chainEntries) == 0 {
return ChainValidationResult{
ChainID: chainID,
IsValid: false,
Message: "Zu dieser Kette existiert kein Backup.",
}
}
// Die vorhandenen Kennungen erlauben es, fehlende Eltern zu erkennen.
availableBackupIDs := make(map[string]struct{}, len(chainEntries))
for _, chainEntry := range chainEntries {
availableBackupIDs[chainEntry.BackupID] = struct{}{}
}
var brokenEntries []string
var affectedEntries []string
for _, chainEntry := range chainEntries {
// Eine Vollsicherung braucht kein Elternbackup.
if chainEntry.ParentBackupID == "" {
continue
}
if _, parentExists := availableBackupIDs[chainEntry.ParentBackupID]; !parentExists {
brokenEntries = append(brokenEntries, chainEntry.BackupID)
}
}
if len(brokenEntries) == 0 {
return ChainValidationResult{
ChainID: chainID,
IsValid: true,
BackupCount: len(chainEntries),
Message: "Die Kette ist vollständig.",
}
}
// Von einem fehlenden Glied an sind alle nachfolgenden Punkte betroffen.
for _, chainEntry := range chainEntries {
for _, brokenBackupID := range brokenEntries {
if chainEntry.BackupID == brokenBackupID {
affectedEntries = append(affectedEntries, chainEntry.BackupID)
}
}
}
return ChainValidationResult{
ChainID: chainID,
IsValid: false,
BackupCount: len(chainEntries),
BrokenBackupIDs: brokenEntries,
AffectedBackupIDs: affectedEntries,
Message: fmt.Sprintf("Die Kette ist unterbrochen: %d Wiederherstellungspunkte sind nicht verwendbar.", len(affectedEntries)),
}
}
// ChainValidationResult ist das Ergebnis einer Kettenprüfung.
type ChainValidationResult struct {
// ChainID benennt die geprüfte Kette.
ChainID string `json:"chain_id"`
// IsValid meldet, ob die Kette lückenlos ist.
IsValid bool `json:"is_valid"`
// BackupCount ist die Zahl der Backups in der Kette.
BackupCount int `json:"backup_count"`
// BrokenBackupIDs sind Backups, deren Elternbackup fehlt.
BrokenBackupIDs []string `json:"broken_backup_ids,omitempty"`
// AffectedBackupIDs sind alle dadurch unbrauchbaren Wiederherstellungspunkte.
AffectedBackupIDs []string `json:"affected_backup_ids,omitempty"`
// Message erklärt das Ergebnis verständlich.
Message string `json:"message"`
}
// catalogEntryFromManifest bildet einen Katalogeintrag aus einem Manifest.
func catalogEntryFromManifest(manifest *Manifest) CatalogEntry {
return CatalogEntry{
BackupID: manifest.BackupID,
ChainID: manifest.ChainID,
ParentBackupID: manifest.ParentBackupID,
BackupType: manifest.BackupType,
ConsistencyLevel: manifest.ConsistencyLevel,
SourceType: manifest.Source.SourceType,
SourceID: manifest.Source.SourceID,
SourceName: manifest.Source.SourceName,
StartedAt: manifest.StartedAt,
CompletedAt: manifest.CompletedAt,
LogicalBytes: manifest.Statistics.LogicalBytes,
StoredBytes: manifest.Statistics.StoredBytes,
ChunkCount: manifest.TotalChunkCount(),
EntryCount: len(manifest.Entries),
ImmutableUntil: manifest.ImmutableUntil,
SelfContainedRestore: manifest.SelfContainedRestore,
ManifestHash: manifest.ContentHash,
}
}
// encodeCatalog serialisiert einen Katalog.
func encodeCatalog(catalog *Catalog) ([]byte, error) {
encodedCatalog, marshalError := json.MarshalIndent(catalog, "", " ")
if marshalError != nil {
return nil, fmt.Errorf("der katalog konnte nicht erzeugt werden: %w", marshalError)
}
return append(encodedCatalog, '\n'), nil
}
// decodeCatalog liest einen Katalog.
func decodeCatalog(rawCatalog []byte) (*Catalog, error) {
var catalog Catalog
if unmarshalError := json.Unmarshal(rawCatalog, &catalog); unmarshalError != nil {
// Ein unlesbarer Katalog ist kein Datenverlust: er lässt sich aus den
// Manifesten neu aufbauen.
return nil, fmt.Errorf("der katalog ist unlesbar: %w", unmarshalError)
}
return &catalog, nil
}