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>
265 lines
10 KiB
Go
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"
|
|
}
|