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

269 lines
9.2 KiB
Go

package repository
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"sort"
)
// jsonMarshalIndent serialisiert einen Wert lesbar.
//
// Der kleine Helfer hält die Formatierung im gesamten Paket einheitlich.
func jsonMarshalIndent(value any) ([]byte, error) {
encodedValue, marshalError := json.MarshalIndent(value, "", " ")
if marshalError != nil {
return nil, marshalError
}
return append(encodedValue, '\n'), nil
}
// jsonUnmarshal liest einen serialisierten Wert.
func jsonUnmarshal(rawValue []byte, target any) error {
return json.Unmarshal(rawValue, target)
}
// writeCatalog schreibt den Katalog atomar.
func (localRepository *LocalRepository) writeCatalog(catalog *Catalog) error {
encodedCatalog, encodeError := encodeCatalog(catalog)
if encodeError != nil {
return encodeError
}
// Das Verzeichnis wird angelegt, falls es fehlt.
//
// „Katalog verloren" (Szenario C der Phase 18) heißt nicht immer „Datei
// gelöscht": Bei einem Teilausfall des Dateisystems oder nach einem
// beherzten Aufräumen fehlt das ganze Verzeichnis. Ohne diesen Schritt
// scheiterte ausgerechnet der Neuaufbau, der den Verlust beheben soll —
// und die Fehlermeldung sprach von einer temporären Datei, nicht vom
// eigentlichen Problem. Im Nachweis aufgefallen.
if directoryError := os.MkdirAll(filepath.Dir(localRepository.catalogPath()),
directoryPermissions); directoryError != nil {
return fmt.Errorf("das katalogverzeichnis konnte nicht angelegt werden: %w", directoryError)
}
// Der Katalog bleibt auch in einem gehärteten Repository beschreibbar:
// er ist ein Beschleuniger, kein Backup-Inhalt, und muss bei jedem neuen
// Backup fortgeschrieben werden.
if writeError := writeFileAtomically(localRepository.catalogPath(), encodedCatalog, dataFilePermissions); writeError != nil {
return fmt.Errorf("der katalog konnte nicht geschrieben werden: %w", writeError)
}
return nil
}
// Catalog liefert den Katalog des Repositorys.
//
// Ist der Katalog nicht lesbar, wird er stillschweigend neu aufgebaut: er ist
// jederzeit aus den Manifesten herleitbar und deshalb kein Datenverlust.
func (localRepository *LocalRepository) Catalog(catalogContext context.Context) (*Catalog, error) {
localRepository.catalogMutex.Lock()
defer localRepository.catalogMutex.Unlock()
return localRepository.loadCatalogLocked(catalogContext)
}
// loadCatalogLocked liest den Katalog; der Aufrufer hält bereits die Sperre.
func (localRepository *LocalRepository) loadCatalogLocked(catalogContext context.Context) (*Catalog, error) {
rawCatalog, readError := os.ReadFile(localRepository.catalogPath())
if readError != nil {
if !errors.Is(readError, os.ErrNotExist) {
return nil, fmt.Errorf("der katalog konnte nicht gelesen werden: %w", readError)
}
localRepository.logger.Warn("der katalog fehlt und wird aus den manifesten neu aufgebaut")
return localRepository.rebuildCatalogLocked(catalogContext)
}
catalog, decodeError := decodeCatalog(rawCatalog)
if decodeError != nil {
localRepository.logger.Warn("der katalog ist unlesbar und wird aus den manifesten neu aufgebaut",
slog.String("error", decodeError.Error()))
return localRepository.rebuildCatalogLocked(catalogContext)
}
// Ein Katalog eines fremden Repositorys enthält falsche Angaben; er wird
// verworfen statt verwendet.
if catalog.RepositoryID != localRepository.descriptor.RepositoryID {
localRepository.logger.Warn("der katalog gehört zu einem anderen repository und wird neu aufgebaut",
slog.String("catalog_repository_id", catalog.RepositoryID),
slog.String("repository_id", localRepository.descriptor.RepositoryID))
return localRepository.rebuildCatalogLocked(catalogContext)
}
return catalog, nil
}
// appendToCatalog nimmt ein abgeschlossenes Backup in den Katalog auf.
func (localRepository *LocalRepository) appendToCatalog(manifest *Manifest) error {
localRepository.catalogMutex.Lock()
defer localRepository.catalogMutex.Unlock()
catalog, loadError := localRepository.loadCatalogLocked(context.Background())
if loadError != nil {
return loadError
}
newEntry := catalogEntryFromManifest(manifest)
// Ein bereits vorhandener Eintrag wird ersetzt statt verdoppelt.
entryReplaced := false
for entryIndex := range catalog.Entries {
if catalog.Entries[entryIndex].BackupID == newEntry.BackupID {
catalog.Entries[entryIndex] = newEntry
entryReplaced = true
break
}
}
if !entryReplaced {
catalog.Entries = append(catalog.Entries, newEntry)
}
sortCatalogEntries(catalog.Entries)
catalog.GeneratedAt = localRepository.timeSource().UTC()
catalog.RebuiltFromManifests = false
return localRepository.writeCatalog(catalog)
}
// removeFromCatalog entfernt ein Backup aus dem Katalog.
func (localRepository *LocalRepository) removeFromCatalog(backupID string) error {
localRepository.catalogMutex.Lock()
defer localRepository.catalogMutex.Unlock()
catalog, loadError := localRepository.loadCatalogLocked(context.Background())
if loadError != nil {
return loadError
}
remainingEntries := make([]CatalogEntry, 0, len(catalog.Entries))
for _, catalogEntry := range catalog.Entries {
if catalogEntry.BackupID != backupID {
remainingEntries = append(remainingEntries, catalogEntry)
}
}
catalog.Entries = remainingEntries
catalog.GeneratedAt = localRepository.timeSource().UTC()
return localRepository.writeCatalog(catalog)
}
// RebuildCatalog baut den Katalog allein aus den Manifesten neu auf.
//
// Das ist der Kern der Repository-Wiederherstellung (PROMPT.md §46): Nach dem
// Verlust des Control Servers genügt es, das Repository anzuhängen und diesen
// Vorgang auszuführen. Es wird keinerlei Datenbank benötigt.
func (localRepository *LocalRepository) RebuildCatalog(rebuildContext context.Context) (*Catalog, error) {
localRepository.catalogMutex.Lock()
defer localRepository.catalogMutex.Unlock()
return localRepository.rebuildCatalogLocked(rebuildContext)
}
// rebuildCatalogLocked baut den Katalog neu auf; der Aufrufer hält die Sperre.
func (localRepository *LocalRepository) rebuildCatalogLocked(rebuildContext context.Context) (*Catalog, error) {
backupIDs, listError := localRepository.listManifestBackupIDs()
if listError != nil {
return nil, listError
}
rebuiltCatalog := &Catalog{
CatalogVersion: CatalogVersion,
RepositoryID: localRepository.descriptor.RepositoryID,
Entries: make([]CatalogEntry, 0, len(backupIDs)),
GeneratedAt: localRepository.timeSource().UTC(),
RebuiltFromManifests: true,
}
var skippedManifests int
for _, backupID := range backupIDs {
if contextError := rebuildContext.Err(); contextError != nil {
return nil, contextError
}
rawManifest, readError := os.ReadFile(localRepository.manifestPath(backupID))
if readError != nil {
localRepository.logger.Error("ein manifest konnte beim wiederaufbau nicht gelesen werden",
slog.String("backup_id", backupID),
slog.String("error", readError.Error()))
skippedManifests++
continue
}
manifest, decodeError := decodeManifest(rawManifest)
if decodeError != nil {
localRepository.logger.Error("ein manifest ist beschädigt und wird nicht in den katalog aufgenommen",
slog.String("backup_id", backupID),
slog.String("error", decodeError.Error()))
skippedManifests++
continue
}
// Ein unvollständiges oder verfälschtes Backup gehört nicht in den
// Katalog: es würde als wiederherstellbar erscheinen, ohne es zu sein.
if verifyError := VerifyManifest(manifest); verifyError != nil {
localRepository.logger.Error("ein backup ist unvollständig und wird nicht in den katalog aufgenommen",
slog.String("backup_id", backupID),
slog.String("error", verifyError.Error()))
skippedManifests++
continue
}
rebuiltCatalog.Entries = append(rebuiltCatalog.Entries, catalogEntryFromManifest(manifest))
}
sortCatalogEntries(rebuiltCatalog.Entries)
if writeError := localRepository.writeCatalog(rebuiltCatalog); writeError != nil {
return nil, writeError
}
localRepository.logger.Info("katalog aus den manifesten neu aufgebaut",
slog.Int("backups", len(rebuiltCatalog.Entries)),
slog.Int("uebersprungene_manifeste", skippedManifests))
return rebuiltCatalog, nil
}
// ListBackups liefert alle abgeschlossenen Backups.
func (localRepository *LocalRepository) ListBackups(listContext context.Context) ([]CatalogEntry, error) {
catalog, catalogError := localRepository.Catalog(listContext)
if catalogError != nil {
return nil, catalogError
}
return catalog.Entries, nil
}
// sortCatalogEntries sortiert Katalogeinträge, jüngstes Backup zuerst.
//
// Die Reihenfolge entspricht der Erwartung in der Oberfläche: der letzte
// Wiederherstellungspunkt ist der wichtigste.
func sortCatalogEntries(catalogEntries []CatalogEntry) {
sort.SliceStable(catalogEntries, func(firstIndex int, secondIndex int) bool {
if !catalogEntries[firstIndex].CompletedAt.Equal(catalogEntries[secondIndex].CompletedAt) {
return catalogEntries[firstIndex].CompletedAt.After(catalogEntries[secondIndex].CompletedAt)
}
// Bei gleichem Zeitstempel entscheidet die Kennung, damit die
// Reihenfolge über Läufe hinweg stabil bleibt.
return catalogEntries[firstIndex].BackupID < catalogEntries[secondIndex].BackupID
})
}