syncova-backup/packages/disasterrecovery/catalog_import.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

265 lines
10 KiB
Go

package disasterrecovery
import (
"context"
"fmt"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/syncova/syncova/packages/repository"
)
// CatalogImportResult beschreibt die Rekonstruktion der Wiederherstellungspunkte.
type CatalogImportResult struct {
// ChainsCreated ist die Zahl angelegter Ketten.
ChainsCreated int
// BackupsImported ist die Zahl uebernommener Wiederherstellungspunkte.
BackupsImported int
// BackupsAlreadyKnown ist die Zahl bereits vorhandener Punkte.
BackupsAlreadyKnown int
// UnresolvedParents benennt Zusatzsicherungen ohne auffindbares Elternbackup.
//
// Sie werden trotzdem uebernommen: Syncova-Manifeste sind vollstaendig
// (Phase 6), eine Zusatzsicherung laesst sich also auch ohne ihr
// Elternbackup wiederherstellen. Verschwiegen wird die Luecke dennoch nicht.
UnresolvedParents []string
}
// CatalogImporter uebernimmt die Wiederherstellungspunkte eines Repositorys.
//
// Dies ist die Probe auf die zentrale Behauptung des ganzen Produkts:
//
// Das Repository ist selbstbeschreibend und ohne die Datenbank rekonstruierbar.
//
// Was hier passiert, ist der Schritt „Rebuild Catalog" aus der Kette
// „Attach Repository → Discover Format → Scan Manifests → Validate Chains →
// Rebuild Catalog → Restore". Die Datenbank bekommt **keine** Angaben, die nicht
// aus den Manifesten stammen — sie ist Empfaenger, nicht Quelle.
type CatalogImporter struct {
// connectionPool ist der Datenbankpool der Control Plane.
connectionPool *pgxpool.Pool
}
// NewCatalogImporter erzeugt die Katalogübernahme.
func NewCatalogImporter(connectionPool *pgxpool.Pool) *CatalogImporter {
return &CatalogImporter{connectionPool: connectionPool}
}
// ImportCatalog uebernimmt die Wiederherstellungspunkte in die Datenbank.
//
// Der Katalog wird vorher **aus den Manifesten neu gebaut**, nicht gelesen: Ein
// vorhandener Katalog koennte veraltet oder beschaedigt sein, und genau darauf
// darf man sich in einem Wiederherstellungsfall nicht verlassen. Verbindlich
// sind die Manifeste (PROMPT.md §46).
func (catalogImporter *CatalogImporter) ImportCatalog(importContext context.Context,
openRepository *repository.LocalRepository, repositoryIdentifier uuid.UUID) (*CatalogImportResult, error) {
rebuiltCatalog, rebuildError := openRepository.RebuildCatalog(importContext)
if rebuildError != nil {
return nil, fmt.Errorf("der katalog liess sich nicht aus den manifesten aufbauen: %w",
rebuildError)
}
importResult := &CatalogImportResult{UnresolvedParents: make([]string, 0, 4)}
databaseTransaction, beginError := catalogImporter.connectionPool.Begin(importContext)
if beginError != nil {
return nil, fmt.Errorf("die transaktion liess sich nicht beginnen: %w", beginError)
}
defer func() { _ = databaseTransaction.Rollback(importContext) }()
// Erst alle Ketten, dann die Backups: Ein Backup verweist auf seine Kette.
chainIdentifiers, chainError := importChains(importContext, databaseTransaction,
rebuiltCatalog, repositoryIdentifier, importResult)
if chainError != nil {
return nil, chainError
}
if backupError := importBackups(importContext, databaseTransaction, rebuiltCatalog,
repositoryIdentifier, chainIdentifiers, importResult); backupError != nil {
return nil, backupError
}
if parentError := linkParentBackups(importContext, databaseTransaction, rebuiltCatalog,
repositoryIdentifier, importResult); parentError != nil {
return nil, parentError
}
if commitError := databaseTransaction.Commit(importContext); commitError != nil {
return nil, fmt.Errorf("die uebernahme liess sich nicht abschliessen: %w", commitError)
}
return importResult, nil
}
// importChains legt die Sicherungsketten an.
//
// Die Kettenkennung des Repositorys ist eine Zeichenkette, die der Datenbank
// eine UUID sein muss. Sie wird deterministisch abgeleitet (UUIDv5), damit
// dieselbe Kette bei einer zweiten Uebernahme dieselbe Kennung bekommt —
// andernfalls entstuenden bei jedem Lauf neue Ketten, und die Zuordnung der
// Backups zerfiele.
func importChains(importContext context.Context, databaseTransaction pgx.Tx,
rebuiltCatalog *repository.Catalog, repositoryIdentifier uuid.UUID,
importResult *CatalogImportResult) (map[string]uuid.UUID, error) {
const insertStatement = `
INSERT INTO backup_chains (id, source_reference, repository_id, status)
VALUES ($1, $2, $3, 'active')
ON CONFLICT (id) DO NOTHING`
chainIdentifiers := make(map[string]uuid.UUID, 8)
for _, catalogEntry := range rebuiltCatalog.Entries {
if catalogEntry.ChainID == "" {
continue
}
if _, alreadySeen := chainIdentifiers[catalogEntry.ChainID]; alreadySeen {
continue
}
chainIdentifier := deriveIdentifier(repositoryIdentifier, "chain", catalogEntry.ChainID)
chainIdentifiers[catalogEntry.ChainID] = chainIdentifier
sourceReference := catalogEntry.SourceName
if sourceReference == "" {
sourceReference = catalogEntry.SourceID
}
commandTag, executeError := databaseTransaction.Exec(importContext, insertStatement,
chainIdentifier, sourceReference, repositoryIdentifier)
if executeError != nil {
return nil, fmt.Errorf("eine kette liess sich nicht anlegen: %w", executeError)
}
if commandTag.RowsAffected() > 0 {
importResult.ChainsCreated++
}
}
return chainIdentifiers, nil
}
// importBackups uebernimmt die Wiederherstellungspunkte.
//
// Uebernommen wird ausschliesslich, was im Manifest steht. Insbesondere bleiben
// last_verified_at und last_restore_test_at **leer**: Dass ein Backup vor dem
// Ausfall geprueft wurde, sagt nichts ueber seinen heutigen Zustand — es lag
// seither in einer Ablage, die einen Totalverlust miterlebt hat. Ein Backup, das
// nach dem Wiederaufbau als „wiederherstellbar" gefuehrt wuerde, ohne dass
// jemand nachgesehen hat, waere genau die stille Beschoenigung, die Phase 10
// verhindert.
func importBackups(importContext context.Context, databaseTransaction pgx.Tx,
rebuiltCatalog *repository.Catalog, repositoryIdentifier uuid.UUID,
chainIdentifiers map[string]uuid.UUID, importResult *CatalogImportResult) error {
const insertStatement = `
INSERT INTO backups (id, chain_id, repository_id, backup_id_in_repository,
backup_type, consistency_level, status, manifest_ref,
manifest_hash, logical_bytes, unique_bytes,
started_at, completed_at, immutable_until, created_at)
VALUES ($1, $2, $3, $4, $5, $6, 'complete', $7, $8, $9, $10, $11, $12, $13, $12)
ON CONFLICT (id) DO NOTHING`
for _, catalogEntry := range rebuiltCatalog.Entries {
backupIdentifier := deriveIdentifier(repositoryIdentifier, "backup", catalogEntry.BackupID)
var chainReference *uuid.UUID
if chainIdentifier, found := chainIdentifiers[catalogEntry.ChainID]; found {
chainReference = &chainIdentifier
}
consistencyLevel := string(catalogEntry.ConsistencyLevel)
if consistencyLevel == "" {
consistencyLevel = "crash_consistent"
}
commandTag, executeError := databaseTransaction.Exec(importContext, insertStatement,
backupIdentifier, chainReference, repositoryIdentifier, catalogEntry.BackupID,
string(catalogEntry.BackupType), consistencyLevel,
manifestReferenceOf(catalogEntry.BackupID), catalogEntry.ManifestHash,
catalogEntry.LogicalBytes, catalogEntry.StoredBytes,
catalogEntry.StartedAt, catalogEntry.CompletedAt, catalogEntry.ImmutableUntil)
if executeError != nil {
return fmt.Errorf("der wiederherstellungspunkt %q liess sich nicht uebernehmen: %w",
catalogEntry.BackupID, executeError)
}
if commandTag.RowsAffected() == 0 {
importResult.BackupsAlreadyKnown++
continue
}
importResult.BackupsImported++
}
return nil
}
// linkParentBackups traegt die Elternbeziehungen nach.
//
// In einem zweiten Durchgang, weil ein Elternbackup im Katalog hinter seinem
// Kind stehen kann: Die Reihenfolge des Katalogs ist die der Manifeste, nicht
// die der Kette.
func linkParentBackups(importContext context.Context, databaseTransaction pgx.Tx,
rebuiltCatalog *repository.Catalog, repositoryIdentifier uuid.UUID,
importResult *CatalogImportResult) error {
const updateStatement = `
UPDATE backups SET parent_backup_id = $2
WHERE id = $1 AND parent_backup_id IS NULL`
knownBackups := make(map[string]bool, len(rebuiltCatalog.Entries))
for _, catalogEntry := range rebuiltCatalog.Entries {
knownBackups[catalogEntry.BackupID] = true
}
for _, catalogEntry := range rebuiltCatalog.Entries {
if catalogEntry.ParentBackupID == "" {
continue
}
if !knownBackups[catalogEntry.ParentBackupID] {
// Das Elternbackup fehlt — etwa weil eine Aufbewahrungsregel es
// entfernt hat. Kein Fehler: Syncova-Manifeste sind vollstaendig,
// die Zusatzsicherung bleibt fuer sich wiederherstellbar.
importResult.UnresolvedParents = append(importResult.UnresolvedParents,
catalogEntry.BackupID)
continue
}
if _, executeError := databaseTransaction.Exec(importContext, updateStatement,
deriveIdentifier(repositoryIdentifier, "backup", catalogEntry.BackupID),
deriveIdentifier(repositoryIdentifier, "backup", catalogEntry.ParentBackupID)); executeError != nil {
return fmt.Errorf("die elternbeziehung liess sich nicht setzen: %w", executeError)
}
}
return nil
}
// deriveIdentifier bildet eine stabile UUID aus Repository, Art und Kennung.
//
// Deterministisch (UUIDv5) und nicht zufaellig: Eine zweite Uebernahme desselben
// Repositorys muss dieselben Kennungen ergeben. Mit Zufallswerten entstuenden
// bei jedem Lauf Dubletten — und niemand koennte sagen, welcher Eintrag der
// richtige ist.
//
// Das Repository geht mit ein, damit zwei Repositories mit gleichlautenden
// Backupkennungen nicht kollidieren.
func deriveIdentifier(repositoryIdentifier uuid.UUID, objectKind string, objectKey string) uuid.UUID {
return uuid.NewSHA1(repositoryIdentifier, []byte(objectKind+":"+objectKey))
}
// manifestReferenceOf bildet den Verweis auf das Manifest eines Backups.
//
// Dieselbe Form, die auch der Executor schreibt (packages/backupexecutor).
// Die Datei heisst im Repository tatsaechlich "<id>.manifest.json"; der Verweis
// trifft sie also nicht buchstaeblich. Er wird nirgends zum Oeffnen benutzt —
// gelesen wird ueber ReadManifest(backupID) —, und eine zweite, abweichende
// Form waere schlimmer als eine einheitlich ungenaue.
func manifestReferenceOf(backupIdentifier string) string {
return "manifests/" + backupIdentifier + ".json"
}