// Package recovery stellt Daten aus einem Repository wieder her. // // Der Kern ist nicht das Zurueckschreiben — das leistet packages/agent bereits. // Der Kern ist alles darum herum: die Pruefung **vor** dem Schreiben, die // Sitzung mit Pruefpunkt und der Schutz gegen versehentliches Ueberschreiben. // // Das folgt dem Produktgrundsatz: Ein Backup gilt erst als vertrauenswuerdig, // wenn Integritaet geprueft und Wiederherstellbarkeit nachgewiesen wurde. Ein // Nachweis, der erst im Ernstfall erbracht wird, ist keiner. package recovery import ( "context" "errors" "fmt" "os" "path/filepath" "strings" "time" "github.com/syncova/syncova/packages/repository" ) // ValidationSeverity ist das Gewicht eines Befunds. type ValidationSeverity string const ( // SeverityBlocking verhindert die Wiederherstellung. // // Ein solcher Befund wird nicht durch eine Bestaetigung ueberstimmbar: Es // gibt keinen Weg, aus fehlenden Bloecken Daten zu machen. SeverityBlocking ValidationSeverity = "blocking" // SeverityWarning verlangt Aufmerksamkeit, verhindert aber nichts. SeverityWarning ValidationSeverity = "warning" // SeverityInformation ist ein Hinweis. SeverityInformation ValidationSeverity = "information" ) // ValidationFinding ist ein einzelner Befund der Vorabpruefung. type ValidationFinding struct { // Code ist die maschinenlesbare Kennung in SCREAMING_SNAKE_CASE. Code string `json:"code"` // Severity ist das Gewicht. Severity ValidationSeverity `json:"severity"` // Message erklaert den Befund verstaendlich. Message string `json:"message"` // Detail nennt das betroffene Objekt, sofern eines benennbar ist. Detail string `json:"detail,omitempty"` } // ValidationReport ist das Ergebnis der Vorabpruefung. type ValidationReport struct { // BackupID ist das gepruefte Backup. BackupID string `json:"backup_id"` // TargetPath ist das gepruefte Ziel. TargetPath string `json:"target_path"` // Findings sind die Befunde. Findings []ValidationFinding `json:"findings"` // EntryCount ist die Zahl wiederherzustellender Objekte. EntryCount int `json:"entry_count"` // FileCount ist die Zahl wiederherzustellender Dateien. FileCount int `json:"file_count"` // TotalBytes ist die zurueckzuschreibende Datenmenge. TotalBytes int64 `json:"total_bytes"` // UniqueChunkCount ist die Zahl benoetigter Bloecke. UniqueChunkCount int `json:"unique_chunk_count"` // MissingChunkCount ist die Zahl fehlender Bloecke. MissingChunkCount int `json:"missing_chunk_count"` // AvailableTargetBytes ist der freie Platz am Ziel; -1 bedeutet unbekannt. AvailableTargetBytes int64 `json:"available_target_bytes"` // CheckedAt ist der Zeitpunkt der Pruefung in UTC. CheckedAt time.Time `json:"checked_at"` // DurationSeconds ist die Dauer der Pruefung. DurationSeconds float64 `json:"duration_seconds"` } // CanProceed meldet, ob die Wiederherstellung beginnen darf. func (report *ValidationReport) CanProceed() bool { for _, finding := range report.Findings { if finding.Severity == SeverityBlocking { return false } } return true } // BlockingFindings liefert die verhindernden Befunde. func (report *ValidationReport) BlockingFindings() []ValidationFinding { blocking := make([]ValidationFinding, 0) for _, finding := range report.Findings { if finding.Severity == SeverityBlocking { blocking = append(blocking, finding) } } return blocking } // Summary fasst das Ergebnis in einem Satz zusammen. func (report *ValidationReport) Summary() string { if !report.CanProceed() { return fmt.Sprintf("NICHT WIEDERHERSTELLBAR: %d Hindernisse, %d fehlende Bloecke.", len(report.BlockingFindings()), report.MissingChunkCount) } warningCount := 0 for _, finding := range report.Findings { if finding.Severity == SeverityWarning { warningCount++ } } if warningCount > 0 { return fmt.Sprintf("Wiederherstellbar mit %d Hinweisen: %d Dateien, %s.", warningCount, report.FileCount, formatByteCount(report.TotalBytes)) } return fmt.Sprintf("Wiederherstellbar: %d Dateien, %s.", report.FileCount, formatByteCount(report.TotalBytes)) } // ValidationRequest beschreibt eine zu pruefende Wiederherstellung. type ValidationRequest struct { // BackupID ist das wiederherzustellende Backup. BackupID string // TargetPath ist das Zielverzeichnis. TargetPath string // PathPrefix beschraenkt auf einen Teilbaum; leer bedeutet alles. PathPrefix string // OverwriteExisting erlaubt das Ueberschreiben vorhandener Daten. OverwriteExisting bool // DeepChunkCheck prueft jeden Block einzeln auf Vorhandensein. // // Das ist der eigentliche Nachweis der Wiederherstellbarkeit und kostet bei // grossen Backups Zeit — deshalb abschaltbar. Abgeschaltet prueft die // Vorabpruefung nur das Manifest; sie kann dann nicht mehr sagen, ob die // Daten wirklich da sind. DeepChunkCheck bool } // ErrBackupUnreadable meldet ein nicht lesbares Backup. var ErrBackupUnreadable = errors.New("das backup konnte nicht gelesen werden") // Validator prueft eine Wiederherstellung, ohne etwas zu schreiben. type Validator struct { // sourceRepository ist das Repository mit dem Backup. sourceRepository *repository.LocalRepository } // NewValidator erzeugt die Vorabpruefung. func NewValidator(sourceRepository *repository.LocalRepository) *Validator { return &Validator{sourceRepository: sourceRepository} } // Validate prueft, ob eine Wiederherstellung gelingen kann. // // Es wird **nichts geschrieben**. Die Pruefung ist damit gefahrlos und kann // jederzeit laufen — auch als regelmaessiger Nachweis, dass die Backups // weiterhin wiederherstellbar sind, lange bevor jemand sie braucht. func (validator *Validator) Validate(validationContext context.Context, validationRequest ValidationRequest) (*ValidationReport, error) { startTime := time.Now() report := &ValidationReport{ BackupID: validationRequest.BackupID, TargetPath: validationRequest.TargetPath, Findings: make([]ValidationFinding, 0, 4), AvailableTargetBytes: -1, CheckedAt: time.Now().UTC(), } backupManifest, readError := validator.sourceRepository.ReadManifest(validationContext, validationRequest.BackupID) if readError != nil { // Ohne Manifest gibt es nichts zu pruefen und nichts wiederherzustellen. report.Findings = append(report.Findings, ValidationFinding{ Code: "MANIFEST_UNREADABLE", Severity: SeverityBlocking, Message: "Das Manifest des Backups ist nicht lesbar. Ohne es laesst sich nicht feststellen, was gesichert wurde.", Detail: readError.Error(), }) report.DurationSeconds = time.Since(startTime).Seconds() return report, nil } validator.checkManifestCompleteness(backupManifest, report) validator.checkEncryptionKey(backupManifest, report) selectedEntries := selectEntries(backupManifest, validationRequest.PathPrefix) if len(selectedEntries) == 0 { report.Findings = append(report.Findings, ValidationFinding{ Code: "NO_MATCHING_ENTRIES", Severity: SeverityBlocking, Message: "Das Backup enthaelt unter diesem Pfad keine Objekte.", Detail: validationRequest.PathPrefix, }) } validator.summarizeEntries(selectedEntries, report) if validationRequest.DeepChunkCheck { validator.checkChunkAvailability(validationContext, selectedEntries, report) } else { report.Findings = append(report.Findings, ValidationFinding{ Code: "CHUNK_CHECK_SKIPPED", Severity: SeverityInformation, Message: "Die Bloecke wurden nicht einzeln geprueft. Ob die Daten tatsaechlich " + "vorhanden sind, ist damit nicht festgestellt.", }) } validator.checkTarget(validationRequest, report) report.DurationSeconds = time.Since(startTime).Seconds() return report, nil } // checkManifestCompleteness prueft den Abschlussvermerk des Manifests. func (validator *Validator) checkManifestCompleteness(backupManifest *repository.Manifest, report *ValidationReport) { // Ein Backup ohne Abschlussvermerk ist unvollstaendig, unabhaengig davon, // was in der Datenbank steht (SYNCOVA_ARCHITECTURE.md §10). if !backupManifest.Complete { report.Findings = append(report.Findings, ValidationFinding{ Code: "BACKUP_INCOMPLETE", Severity: SeverityBlocking, Message: "Das Backup traegt keinen Abschlussvermerk. Es wurde begonnen, aber nie " + "vollstaendig festgeschrieben.", }) } } // checkEncryptionKey prueft, ob der noetige Schluessel verfuegbar ist. func (validator *Validator) checkEncryptionKey(backupManifest *repository.Manifest, report *ValidationReport) { if backupManifest.EncryptionKeyVersion == "" { report.Findings = append(report.Findings, ValidationFinding{ Code: "BACKUP_UNENCRYPTED", Severity: SeverityWarning, Message: "Dieses Backup ist unverschluesselt abgelegt.", }) return } // Der Datenschluessel gehoert zum Repository. Fehlt er, liegen die Daten da // und sind trotzdem unlesbar — der unangenehmste denkbare Fall, weil alles // vollstaendig aussieht. storedVersion, versionError := validator.sourceRepository.DataKeyVersion() if versionError != nil { report.Findings = append(report.Findings, ValidationFinding{ Code: "DATA_KEY_MISSING", Severity: SeverityBlocking, Message: "Der Datenschluessel des Repositorys ist nicht auffindbar. Die Bloecke " + "liegen vor, lassen sich aber nicht entschluesseln.", Detail: versionError.Error(), }) return } if storedVersion != backupManifest.EncryptionKeyVersion { report.Findings = append(report.Findings, ValidationFinding{ Code: "KEY_VERSION_MISMATCH", Severity: SeverityWarning, Message: fmt.Sprintf("Das Backup wurde mit Schluesselversion %s erzeugt, das Repository "+ "fuehrt %s. Der aeltere Schluessel muss weiterhin verfuegbar sein.", backupManifest.EncryptionKeyVersion, storedVersion), }) } } // summarizeEntries zaehlt die wiederherzustellenden Objekte. func (validator *Validator) summarizeEntries(selectedEntries []repository.ManifestEntry, report *ValidationReport) { report.EntryCount = len(selectedEntries) for _, manifestEntry := range selectedEntries { if manifestEntry.EntryType == "file" { report.FileCount++ report.TotalBytes += manifestEntry.SizeBytes } } } // checkChunkAvailability prueft, ob jeder benoetigte Block vorhanden ist. // // Das ist der eigentliche Nachweis der Wiederherstellbarkeit. Ein Manifest // allein belegt nur, dass jemand einmal etwas gesichert hat — nicht, dass die // Daten noch da sind. Ein versehentlich aufgeraeumtes Verzeichnis, ein // unvollstaendig kopiertes Repository, ein fehlgeschlagenes Prune: Alles das // faellt hier auf und nicht erst im Ernstfall. func (validator *Validator) checkChunkAvailability(checkContext context.Context, selectedEntries []repository.ManifestEntry, report *ValidationReport) { requiredChunks := make(map[string]string) for _, manifestEntry := range selectedEntries { for _, chunkReference := range manifestEntry.Chunks { if _, alreadySeen := requiredChunks[chunkReference.Identifier]; alreadySeen { continue } requiredChunks[chunkReference.Identifier] = manifestEntry.Path } } report.UniqueChunkCount = len(requiredChunks) // Die betroffenen Objekte werden gesammelt, aber nur die ersten genannt: // Bei einem verlorenen Verzeichnis waeren es sonst tausende Zeilen. const maximumNamedObjects = 5 affectedPaths := make([]string, 0, maximumNamedObjects) for chunkIdentifier, owningPath := range requiredChunks { if checkContext.Err() != nil { report.Findings = append(report.Findings, ValidationFinding{ Code: "CHECK_CANCELLED", Severity: SeverityBlocking, Message: "Die Pruefung wurde abgebrochen und ist damit ohne Aussage.", }) return } chunkExists, existenceError := validator.sourceRepository.HasChunk(checkContext, chunkIdentifier) if existenceError != nil { report.Findings = append(report.Findings, ValidationFinding{ Code: "CHUNK_CHECK_FAILED", Severity: SeverityBlocking, Message: "Die Bloecke des Backups liessen sich nicht pruefen.", Detail: existenceError.Error(), }) return } if !chunkExists { report.MissingChunkCount++ if len(affectedPaths) < maximumNamedObjects { affectedPaths = append(affectedPaths, owningPath) } } } if report.MissingChunkCount > 0 { detailText := strings.Join(affectedPaths, ", ") if report.MissingChunkCount > len(affectedPaths) { detailText += fmt.Sprintf(" und %d weitere", report.MissingChunkCount-len(affectedPaths)) } report.Findings = append(report.Findings, ValidationFinding{ Code: "CHUNKS_MISSING", Severity: SeverityBlocking, Message: fmt.Sprintf("%d von %d benoetigten Bloecken fehlen im Repository. "+ "Die betroffenen Dateien lassen sich nicht wiederherstellen.", report.MissingChunkCount, report.UniqueChunkCount), Detail: detailText, }) } } // checkTarget prueft das Zielverzeichnis. func (validator *Validator) checkTarget(validationRequest ValidationRequest, report *ValidationReport) { if strings.TrimSpace(validationRequest.TargetPath) == "" { report.Findings = append(report.Findings, ValidationFinding{ Code: "TARGET_MISSING", Severity: SeverityBlocking, Message: "Es wurde kein Zielverzeichnis angegeben.", }) return } targetInformation, statError := os.Stat(validationRequest.TargetPath) switch { case statError != nil && os.IsNotExist(statError): // Ein nicht vorhandenes Ziel ist kein Hindernis — es wird angelegt. // Sein Elternverzeichnis muss aber beschreibbar sein. validator.checkParentWritable(validationRequest.TargetPath, report) case statError != nil: report.Findings = append(report.Findings, ValidationFinding{ Code: "TARGET_UNREACHABLE", Severity: SeverityBlocking, Message: "Auf das Zielverzeichnis kann nicht zugegriffen werden.", Detail: statError.Error(), }) return case !targetInformation.IsDir(): report.Findings = append(report.Findings, ValidationFinding{ Code: "TARGET_NOT_DIRECTORY", Severity: SeverityBlocking, Message: "Das Ziel ist kein Verzeichnis.", }) return default: validator.checkExistingTarget(validationRequest, report) } validator.checkFreeSpace(validationRequest.TargetPath, report) } // checkParentWritable prueft das Elternverzeichnis eines neuen Ziels. func (validator *Validator) checkParentWritable(targetPath string, report *ValidationReport) { parentDirectory := filepath.Dir(targetPath) parentInformation, statError := os.Stat(parentDirectory) if statError != nil { report.Findings = append(report.Findings, ValidationFinding{ Code: "TARGET_PARENT_MISSING", Severity: SeverityBlocking, Message: "Das uebergeordnete Verzeichnis des Ziels existiert nicht.", Detail: parentDirectory, }) return } if !parentInformation.IsDir() { report.Findings = append(report.Findings, ValidationFinding{ Code: "TARGET_PARENT_NOT_DIRECTORY", Severity: SeverityBlocking, Message: "Das uebergeordnete Verzeichnis des Ziels ist kein Verzeichnis.", Detail: parentDirectory, }) } } // checkExistingTarget prueft ein bereits vorhandenes Zielverzeichnis. func (validator *Validator) checkExistingTarget(validationRequest ValidationRequest, report *ValidationReport) { directoryEntries, readError := os.ReadDir(validationRequest.TargetPath) if readError != nil { report.Findings = append(report.Findings, ValidationFinding{ Code: "TARGET_UNREADABLE", Severity: SeverityBlocking, Message: "Das Zielverzeichnis laesst sich nicht lesen.", Detail: readError.Error(), }) return } if len(directoryEntries) == 0 { return } // Ein nicht leeres Ziel ist der gefaehrliche Fall: Ohne ausdrueckliche // Zustimmung wird nicht ueberschrieben, denn die vorhandenen Daten koennten // genau die sein, die man eigentlich retten will. if !validationRequest.OverwriteExisting { report.Findings = append(report.Findings, ValidationFinding{ Code: "TARGET_NOT_EMPTY", Severity: SeverityBlocking, Message: fmt.Sprintf("Das Zielverzeichnis enthaelt bereits %d Objekte. Ohne ausdrueckliche "+ "Zustimmung zum Ueberschreiben wird nicht wiederhergestellt.", len(directoryEntries)), }) return } report.Findings = append(report.Findings, ValidationFinding{ Code: "TARGET_WILL_BE_OVERWRITTEN", Severity: SeverityWarning, Message: fmt.Sprintf("Das Zielverzeichnis enthaelt %d Objekte, die ueberschrieben werden. "+ "Diese Daten sind danach verloren.", len(directoryEntries)), }) } // safetyMarginFactor haelt Platz fuer Dateisystem-Verwaltungsdaten frei. // // Ein Dateisystem braucht mehr als die Nutzdaten: Verzeichniseintraege, // Inodes, Blockverschnitt. Fuenf Prozent sind ein grober, aber brauchbarer // Aufschlag — genauer liesse es sich nur je Dateisystem bestimmen. const safetyMarginFactor = 1.05 // checkFreeSpace prueft den freien Platz am Ziel. func (validator *Validator) checkFreeSpace(targetPath string, report *ValidationReport) { availableBytes, spaceError := determineAvailableBytes(targetPath) if spaceError != nil { // Der freie Platz ist eine Zusatzinformation. Ihn nicht zu kennen darf // eine Wiederherstellung nicht verhindern. report.Findings = append(report.Findings, ValidationFinding{ Code: "FREE_SPACE_UNKNOWN", Severity: SeverityInformation, Message: "Der freie Platz am Ziel liess sich nicht ermitteln.", }) return } report.AvailableTargetBytes = availableBytes requiredBytes := int64(float64(report.TotalBytes) * safetyMarginFactor) if availableBytes < requiredBytes { report.Findings = append(report.Findings, ValidationFinding{ Code: "INSUFFICIENT_SPACE", Severity: SeverityBlocking, Message: fmt.Sprintf("Am Ziel sind %s frei, benoetigt werden etwa %s. Die "+ "Wiederherstellung wuerde unterwegs abbrechen.", formatByteCount(availableBytes), formatByteCount(requiredBytes)), }) } } // selectEntries waehlt die Objekte eines Teilbaums. func selectEntries(backupManifest *repository.Manifest, pathPrefix string) []repository.ManifestEntry { trimmedPrefix := strings.Trim(strings.TrimSpace(pathPrefix), "/") if trimmedPrefix == "" { return backupManifest.Entries } selected := make([]repository.ManifestEntry, 0, len(backupManifest.Entries)) for _, manifestEntry := range backupManifest.Entries { // Der Vergleich beruecksichtigt die Verzeichnisgrenze: "dokumente" darf // nicht auch "dokumentation" treffen. if manifestEntry.Path == trimmedPrefix || strings.HasPrefix(manifestEntry.Path, trimmedPrefix+"/") { selected = append(selected, manifestEntry) } } return selected } // byteUnitSuffixes sind die Einheiten der Groessenausgabe. var byteUnitSuffixes = []string{"B", "KiB", "MiB", "GiB", "TiB", "PiB"} // formatByteCount gibt eine Bytezahl lesbar aus. func formatByteCount(byteCount int64) string { if byteCount < 1024 { return fmt.Sprintf("%d B", byteCount) } scaledValue := float64(byteCount) unitIndex := 0 for scaledValue >= 1024 && unitIndex < len(byteUnitSuffixes)-1 { scaledValue /= 1024 unitIndex++ } return fmt.Sprintf("%.1f %s", scaledValue, byteUnitSuffixes[unitIndex]) }