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>
269 lines
9.2 KiB
Go
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
|
|
})
|
|
}
|