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 ".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" }