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>
254 lines
10 KiB
Go
254 lines
10 KiB
Go
package repository
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"time"
|
|
)
|
|
|
|
// Schwellen der Kapazitätswarnung (PROMPT.md §38).
|
|
const (
|
|
// capacityWarningPercentage löst eine Warnung aus.
|
|
capacityWarningPercentage = 80.0
|
|
// capacityCriticalPercentage löst eine kritische Meldung aus.
|
|
//
|
|
// Ein volles Repository bedeutet: keine Backups mehr. Deshalb wird früh
|
|
// gewarnt, nicht erst beim Anschlag.
|
|
capacityCriticalPercentage = 90.0
|
|
)
|
|
|
|
// Health liefert den Zustand des Repositorys.
|
|
//
|
|
// Geprüft werden Erreichbarkeit, Schreibbarkeit und Kapazität. Ein Repository,
|
|
// das sich nicht beschreiben lässt, ist für Backups wertlos — auch wenn es
|
|
// sich lesen lässt.
|
|
func (localRepository *LocalRepository) Health(healthContext context.Context) (HealthReport, error) {
|
|
checkStartTime := time.Now()
|
|
|
|
healthReport := HealthReport{
|
|
Status: HealthStatusHealthy,
|
|
Message: "Das Repository ist einsatzbereit.",
|
|
CheckedAt: localRepository.timeSource().UTC(),
|
|
}
|
|
|
|
// Der Descriptor ist der Nachweis, dass überhaupt ein Repository vorliegt.
|
|
if _, statError := os.Stat(localRepository.descriptorPath()); statError != nil {
|
|
healthReport.Status = HealthStatusCritical
|
|
healthReport.Message = "Das Repository ist nicht erreichbar."
|
|
healthReport.RecommendedAction = "Einbindung des Datenträgers und Zugriffsrechte prüfen."
|
|
healthReport.LatencyMilliseconds = elapsedMilliseconds(checkStartTime)
|
|
|
|
return healthReport, nil
|
|
}
|
|
|
|
capacityBytes, freeBytes, capacityError := diskCapacity(localRepository.rootPath)
|
|
if capacityError != nil {
|
|
// Ohne Kapazitätsangabe bleibt das Repository nutzbar; die Aussage ist
|
|
// aber unvollständig und wird als solche gemeldet.
|
|
localRepository.logger.Warn("die kapazität konnte nicht ermittelt werden",
|
|
slog.String("error", capacityError.Error()))
|
|
} else {
|
|
healthReport.CapacityBytes = capacityBytes
|
|
healthReport.FreeBytes = freeBytes
|
|
healthReport.UsedBytes = capacityBytes - freeBytes
|
|
}
|
|
|
|
backupIDs, listError := localRepository.listManifestBackupIDs()
|
|
if listError != nil {
|
|
healthReport.Status = HealthStatusDegraded
|
|
healthReport.Message = "Die Backups des Repositorys konnten nicht gelesen werden."
|
|
healthReport.RecommendedAction = "Zugriffsrechte des Manifestverzeichnisses prüfen."
|
|
healthReport.LatencyMilliseconds = elapsedMilliseconds(checkStartTime)
|
|
|
|
return healthReport, nil
|
|
}
|
|
|
|
healthReport.BackupCount = len(backupIDs)
|
|
|
|
// Die Schreibprobe belegt, dass tatsächlich gesichert werden könnte.
|
|
if writeError := localRepository.performWriteProbe(); writeError != nil {
|
|
healthReport.Status = HealthStatusCritical
|
|
healthReport.Message = "Das Repository kann nicht beschrieben werden."
|
|
healthReport.RecommendedAction = "Freien Speicherplatz, Zugriffsrechte und Zustand des Datenträgers prüfen."
|
|
healthReport.LatencyMilliseconds = elapsedMilliseconds(checkStartTime)
|
|
|
|
return healthReport, nil
|
|
}
|
|
|
|
// Die Belegung wird erst nach den harten Fehlern bewertet.
|
|
if healthReport.CapacityBytes > 0 {
|
|
capacityStatus, capacityMessage, capacityAction := evaluateCapacity(healthReport.UsedPercentage())
|
|
|
|
if capacityStatus != HealthStatusHealthy {
|
|
healthReport.Status = capacityStatus
|
|
healthReport.Message = capacityMessage
|
|
healthReport.RecommendedAction = capacityAction
|
|
}
|
|
}
|
|
|
|
healthReport.LatencyMilliseconds = elapsedMilliseconds(checkStartTime)
|
|
|
|
return healthReport, nil
|
|
}
|
|
|
|
// evaluateCapacity bewertet die Belegung eines Repositorys.
|
|
//
|
|
// Die Bewertung ist bewusst als reine Funktion ausgelagert: sie lässt sich
|
|
// damit unabhängig vom tatsächlichen Füllstand des Testrechners prüfen.
|
|
func evaluateCapacity(usedPercentage float64) (capacityStatus HealthStatus, capacityMessage string, capacityAction string) {
|
|
switch {
|
|
case usedPercentage >= capacityCriticalPercentage:
|
|
return HealthStatusCritical,
|
|
fmt.Sprintf("Das Repository ist zu %.1f %% belegt. Bald sind keine Backups mehr möglich.", usedPercentage),
|
|
"Aufbewahrungsdauer verkürzen, verwaiste Chunks bereinigen oder Speicher erweitern."
|
|
|
|
case usedPercentage >= capacityWarningPercentage:
|
|
return HealthStatusWarning,
|
|
fmt.Sprintf("Das Repository ist zu %.1f %% belegt.", usedPercentage),
|
|
"Kapazitätsentwicklung beobachten und Speichererweiterung einplanen."
|
|
|
|
default:
|
|
return HealthStatusHealthy, "Das Repository ist einsatzbereit.", ""
|
|
}
|
|
}
|
|
|
|
// performWriteProbe prüft, ob sich das Repository beschreiben lässt.
|
|
//
|
|
// Die Probe schreibt eine winzige Datei und entfernt sie sofort wieder. Ein
|
|
// reiner Rechtecheck genügt nicht: ein voller oder schreibgeschützt eingebundener
|
|
// Datenträger fällt erst beim tatsächlichen Schreiben auf.
|
|
func (localRepository *LocalRepository) performWriteProbe() error {
|
|
probePath := localRepository.rootPath + string(os.PathSeparator) + directoryMetadata +
|
|
string(os.PathSeparator) + ".health-probe"
|
|
|
|
if writeError := writeFileAtomically(probePath, []byte("ok\n"), dataFilePermissions); writeError != nil {
|
|
return writeError
|
|
}
|
|
|
|
if removeError := os.Remove(probePath); removeError != nil && !errors.Is(removeError, os.ErrNotExist) {
|
|
return fmt.Errorf("die probedatei konnte nicht entfernt werden: %w", removeError)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// elapsedMilliseconds liefert die vergangene Zeit in Millisekunden.
|
|
func elapsedMilliseconds(startTime time.Time) float64 {
|
|
return float64(time.Since(startTime).Microseconds()) / 1000
|
|
}
|
|
|
|
// DeleteBackup entfernt ein Backup, sofern kein Aufbewahrungsschutz greift.
|
|
//
|
|
// Entfernt wird ausschließlich das Manifest. Die Chunks bleiben liegen, weil
|
|
// andere Backups sie noch benötigen könnten; sie verschwinden erst mit der
|
|
// Bereinigung (PROMPT.md §141).
|
|
func (localRepository *LocalRepository) DeleteBackup(deleteContext context.Context, backupID string) error {
|
|
if validationError := validateBackupIdentifier(backupID); validationError != nil {
|
|
return validationError
|
|
}
|
|
|
|
if localRepository.lockHandle == nil {
|
|
return fmt.Errorf("das repository wurde nur lesend geöffnet; es kann nichts gelöscht werden")
|
|
}
|
|
|
|
manifest, manifestError := localRepository.ReadManifest(deleteContext, backupID)
|
|
if manifestError != nil {
|
|
return manifestError
|
|
}
|
|
|
|
// Die Schutzlage wird aus Manifest **und** Schutzvermerk gebildet. Nur das
|
|
// Manifest zu prüfen wäre der Fehler, der eine Verlängerung wirkungslos
|
|
// machte: Die verlängerte Frist steht nicht darin.
|
|
protectionStatus, statusError := localRepository.protectionStatusFromManifest(manifest)
|
|
if statusError != nil {
|
|
return statusError
|
|
}
|
|
|
|
// Ein Legal Hold steht über der Frist: Er endet nicht von selbst, sondern
|
|
// wird ausdrücklich aufgehoben. Ein Backup, das in einem Rechtsstreit
|
|
// gebraucht wird, darf nicht deshalb verschwinden, weil dreissig Tage um
|
|
// sind.
|
|
if protectionStatus.LegalHold {
|
|
return fmt.Errorf("%w (%s)", ErrLegalHold, protectionStatus.LegalHoldReason)
|
|
}
|
|
|
|
// Der Aufbewahrungsschutz gilt unabhängig davon, wer löschen möchte.
|
|
// Genau das ist der Zweck eines gehärteten Repositorys (PROMPT.md §15).
|
|
if protectionStatus.IsProtected(localRepository.timeSource()) {
|
|
return fmt.Errorf("%w (geschützt bis %s)",
|
|
ErrRetentionLocked, protectionStatus.ImmutableUntil.Format(time.RFC3339))
|
|
}
|
|
|
|
manifestFilePath := localRepository.manifestPath(backupID)
|
|
|
|
// Der Schutz wird erst hier aufgehoben — nachdem Frist und Legal Hold
|
|
// geprüft sind. Beide Freigaben in einem Schritt zu erledigen wäre bequem
|
|
// und falsch: Ein Fehler in der Prüfung liesse dann ein ungeschütztes
|
|
// Manifest zurück.
|
|
if localRepository.descriptor.Immutable {
|
|
if releaseError := releaseManifestFile(manifestFilePath); releaseError != nil {
|
|
return fmt.Errorf("der löschschutz des manifests liess sich nicht aufheben: %w", releaseError)
|
|
}
|
|
|
|
if chmodError := os.Chmod(manifestFilePath, dataFilePermissions); chmodError != nil {
|
|
return fmt.Errorf("die dateirechte des manifests konnten nicht geändert werden: %w", chmodError)
|
|
}
|
|
}
|
|
|
|
if removeError := os.Remove(manifestFilePath); removeError != nil {
|
|
if errors.Is(removeError, os.ErrNotExist) {
|
|
return fmt.Errorf("%w (kennung %s)", ErrBackupNotFound, backupID)
|
|
}
|
|
|
|
return fmt.Errorf("das manifest konnte nicht entfernt werden: %w", removeError)
|
|
}
|
|
|
|
// Der Schutzvermerk folgt dem Manifest. Bliebe er liegen, wäre ein später
|
|
// unter derselben Kennung angelegtes Backup unbeabsichtigt geschützt.
|
|
if holdError := localRepository.removeRetentionHold(backupID); holdError != nil {
|
|
localRepository.logger.Warn("der schutzvermerk konnte nach dem löschen nicht entfernt werden",
|
|
slog.String("backup_id", backupID),
|
|
slog.String("error", holdError.Error()))
|
|
}
|
|
|
|
if catalogError := localRepository.removeFromCatalog(backupID); catalogError != nil {
|
|
localRepository.logger.Error("der katalog konnte nach dem löschen nicht aktualisiert werden",
|
|
slog.String("backup_id", backupID),
|
|
slog.String("error", catalogError.Error()))
|
|
}
|
|
|
|
localRepository.logger.Info("backup gelöscht",
|
|
slog.String("backup_id", backupID),
|
|
slog.Int64("chunks_verblieben", manifest.TotalChunkCount()))
|
|
|
|
return nil
|
|
}
|
|
|
|
// MeasureFilesystemUsage ermittelt Belegung und Kapazität des Dateisystems.
|
|
//
|
|
// **Gemessen wird das Dateisystem, nicht das Repository.** Der Unterschied ist
|
|
// für die Deutung entscheidend: Liegt das Repository auf einem geteilten
|
|
// Dateisystem, wächst die Kurve auch dann, wenn jemand anders Daten ablegt.
|
|
//
|
|
// Die Alternative wäre, die Chunk-Ablage zu durchlaufen und aufzusummieren. Das
|
|
// ist die genauere Zahl und bei Millionen Blöcken eine Aufgabe von Minuten — im
|
|
// Fünfminutentakt gemessen liefe der Dienst nur noch damit. Ein Statfs kostet
|
|
// einen Systemaufruf.
|
|
//
|
|
// Wer die genaue Repositorygröße braucht, findet sie im Integritätsscan.
|
|
func MeasureFilesystemUsage(repositoryPath string) (usedBytes int64, capacityBytes int64, measureError error) {
|
|
totalBytes, freeBytes, capacityError := diskCapacity(repositoryPath)
|
|
if capacityError != nil {
|
|
return 0, 0, capacityError
|
|
}
|
|
|
|
// Belegt ist, was weder frei noch reserviert ist. freeBytes ist der für
|
|
// unprivilegierte Nutzer verfügbare Platz — die Differenz enthält damit auch
|
|
// die Systemreserve. Das ist die Zahl, die zählt: Sie steht dem Dienst nicht
|
|
// zur Verfügung.
|
|
return totalBytes - freeBytes, totalBytes, nil
|
|
}
|