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 }