syncova-backup/packages/repository/health.go
Jerrit Fritzsche 610719c316
Some checks failed
CI / Backend (Go) (push) Failing after 3m7s
CI / Frontend (React/TypeScript) (push) Successful in 37s
CI / Sicherheitsprüfungen (push) Successful in 44s
Syncova Backups V1
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>
2026-08-17 09:10:54 +02:00

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
}