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>
552 lines
19 KiB
Go
552 lines
19 KiB
Go
// 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])
|
|
}
|