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>
248 lines
8.4 KiB
Go
248 lines
8.4 KiB
Go
// Package health implementiert die Health Engine aus PROMPT.md §93/§94.
|
|
//
|
|
// Jede Komponente meldet einen expliziten Zustand; der Gesamtzustand ist immer
|
|
// der schlechteste gemeldete Einzelzustand. Ein unbekannter oder nicht geprüfter
|
|
// Zustand gilt niemals als gesund (Fail Secure).
|
|
package health
|
|
|
|
import (
|
|
"context"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Status ist der Gesundheitszustand einer Komponente.
|
|
type Status string
|
|
|
|
const (
|
|
// StatusHealthy bedeutet: die Komponente arbeitet uneingeschränkt.
|
|
StatusHealthy Status = "healthy"
|
|
// StatusDegraded bedeutet: die Komponente arbeitet, aber eingeschränkt.
|
|
StatusDegraded Status = "degraded"
|
|
// StatusWarning bedeutet: ein Problem bahnt sich an und braucht Aufmerksamkeit.
|
|
StatusWarning Status = "warning"
|
|
// StatusCritical bedeutet: die Komponente ist funktionsunfähig.
|
|
StatusCritical Status = "critical"
|
|
// StatusOffline bedeutet: die Komponente ist nicht erreichbar.
|
|
StatusOffline Status = "offline"
|
|
)
|
|
|
|
// statusSeverity ordnet jedem Zustand eine Schwere zu.
|
|
// Höhere Werte sind schlechter und bestimmen den Gesamtzustand.
|
|
var statusSeverity = map[Status]int{
|
|
StatusHealthy: 0,
|
|
StatusDegraded: 1,
|
|
StatusWarning: 2,
|
|
StatusOffline: 3,
|
|
StatusCritical: 4,
|
|
}
|
|
|
|
// IsWorseThan meldet, ob dieser Zustand schwerwiegender ist als der verglichene.
|
|
func (status Status) IsWorseThan(otherStatus Status) bool {
|
|
return statusSeverity[status] > statusSeverity[otherStatus]
|
|
}
|
|
|
|
// CheckResult ist das Ergebnis einer einzelnen Komponentenprüfung.
|
|
type CheckResult struct {
|
|
// Status ist der ermittelte Zustand der Komponente.
|
|
Status Status `json:"status"`
|
|
// Message erklärt den Zustand verständlich (PROMPT.md §48: keine rohen Fehlercodes).
|
|
Message string `json:"message,omitempty"`
|
|
// RecommendedAction nennt den nächsten sinnvollen Schritt bei Problemen.
|
|
RecommendedAction string `json:"recommended_action,omitempty"`
|
|
// LatencyMilliseconds ist die Dauer der Prüfung.
|
|
LatencyMilliseconds float64 `json:"latency_ms"`
|
|
// CheckedAt ist der Zeitpunkt der Prüfung in UTC.
|
|
CheckedAt time.Time `json:"checked_at"`
|
|
}
|
|
|
|
// SystemReport ist der Gesamtzustand des Dienstes.
|
|
type SystemReport struct {
|
|
// Status ist der schlechteste Zustand aller geprüften Komponenten.
|
|
Status Status `json:"status"`
|
|
// Components enthält das Ergebnis je Komponente.
|
|
Components map[string]CheckResult `json:"components"`
|
|
// CheckedAt ist der Zeitpunkt der Gesamtauswertung in UTC.
|
|
CheckedAt time.Time `json:"checked_at"`
|
|
}
|
|
|
|
// CheckFunc prüft eine einzelne Komponente.
|
|
//
|
|
// Die Implementierung muss den übergebenen Context respektieren, damit eine
|
|
// hängende Komponente nicht den gesamten Health-Check blockiert.
|
|
type CheckFunc func(context.Context) CheckResult
|
|
|
|
// registeredCheck verbindet einen Komponentennamen mit seiner Prüffunktion.
|
|
type registeredCheck struct {
|
|
// componentName ist der Name der Komponente im Bericht (z. B. "database").
|
|
componentName string
|
|
// checkFunction führt die eigentliche Prüfung durch.
|
|
checkFunction CheckFunc
|
|
// isCritical legt fest, ob diese Komponente für die Betriebsbereitschaft nötig ist.
|
|
isCritical bool
|
|
}
|
|
|
|
// Registry sammelt alle Health-Checks eines Dienstes.
|
|
// Sie ist nebenläufig nutzbar, da Checks aus HTTP-Handlern aufgerufen werden.
|
|
type Registry struct {
|
|
// mutex schützt die Check-Liste gegen gleichzeitige Registrierung und Auswertung.
|
|
mutex sync.RWMutex
|
|
// checks sind die registrierten Komponentenprüfungen.
|
|
checks []registeredCheck
|
|
// checkTimeout begrenzt die Dauer einer einzelnen Prüfung.
|
|
checkTimeout time.Duration
|
|
}
|
|
|
|
// NewRegistry erzeugt eine leere Registry.
|
|
//
|
|
// checkTimeout begrenzt jede einzelne Prüfung, damit eine langsame Komponente
|
|
// den Health-Endpunkt nicht blockiert.
|
|
func NewRegistry(checkTimeout time.Duration) *Registry {
|
|
return &Registry{checkTimeout: checkTimeout}
|
|
}
|
|
|
|
// Register nimmt eine Komponentenprüfung auf.
|
|
//
|
|
// isCritical steuert, ob ein Ausfall dieser Komponente die Betriebsbereitschaft
|
|
// (Readiness) aufhebt.
|
|
func (registry *Registry) Register(componentName string, isCritical bool, checkFunction CheckFunc) {
|
|
registry.mutex.Lock()
|
|
defer registry.mutex.Unlock()
|
|
|
|
registry.checks = append(registry.checks, registeredCheck{
|
|
componentName: componentName,
|
|
checkFunction: checkFunction,
|
|
isCritical: isCritical,
|
|
})
|
|
}
|
|
|
|
// Check führt alle registrierten Prüfungen nebenläufig aus.
|
|
func (registry *Registry) Check(parentContext context.Context) SystemReport {
|
|
registry.mutex.RLock()
|
|
// Eine Kopie erlaubt das Freigeben der Sperre vor den eigentlichen Prüfungen,
|
|
// die deutlich länger dauern als das Kopieren der Liste.
|
|
checksToRun := make([]registeredCheck, len(registry.checks))
|
|
copy(checksToRun, registry.checks)
|
|
registry.mutex.RUnlock()
|
|
|
|
componentResults := make(map[string]CheckResult, len(checksToRun))
|
|
|
|
// resultsMutex schützt die Ergebniskarte, da alle Prüfungen parallel laufen.
|
|
var resultsMutex sync.Mutex
|
|
var checkWaitGroup sync.WaitGroup
|
|
|
|
for _, checkToRun := range checksToRun {
|
|
checkWaitGroup.Add(1)
|
|
|
|
go func(currentCheck registeredCheck) {
|
|
defer checkWaitGroup.Done()
|
|
|
|
checkResult := registry.runSingleCheck(parentContext, currentCheck)
|
|
|
|
resultsMutex.Lock()
|
|
componentResults[currentCheck.componentName] = checkResult
|
|
resultsMutex.Unlock()
|
|
}(checkToRun)
|
|
}
|
|
|
|
checkWaitGroup.Wait()
|
|
|
|
return SystemReport{
|
|
Status: worstStatus(componentResults),
|
|
Components: componentResults,
|
|
CheckedAt: time.Now().UTC(),
|
|
}
|
|
}
|
|
|
|
// runSingleCheck führt eine Prüfung mit Timeout und Panic-Schutz aus.
|
|
func (registry *Registry) runSingleCheck(parentContext context.Context, currentCheck registeredCheck) CheckResult {
|
|
checkContext, cancelCheckContext := context.WithTimeout(parentContext, registry.checkTimeout)
|
|
defer cancelCheckContext()
|
|
|
|
checkStartTime := time.Now()
|
|
|
|
// resultChannel ist gepuffert, damit die Goroutine auch nach einem Timeout
|
|
// terminieren kann und nicht dauerhaft blockiert.
|
|
resultChannel := make(chan CheckResult, 1)
|
|
|
|
go func() {
|
|
// Ein Panic in einer Prüfung darf den Health-Endpunkt nicht mitreißen.
|
|
defer func() {
|
|
if panicValue := recover(); panicValue != nil {
|
|
resultChannel <- CheckResult{
|
|
Status: StatusCritical,
|
|
Message: "Die Zustandsprüfung dieser Komponente ist unerwartet abgebrochen.",
|
|
RecommendedAction: "Serverlog zu dieser Komponente prüfen.",
|
|
}
|
|
}
|
|
}()
|
|
|
|
resultChannel <- currentCheck.checkFunction(checkContext)
|
|
}()
|
|
|
|
select {
|
|
case checkResult := <-resultChannel:
|
|
checkResult.LatencyMilliseconds = float64(time.Since(checkStartTime).Microseconds()) / 1000
|
|
checkResult.CheckedAt = time.Now().UTC()
|
|
|
|
// Eine Prüfung ohne gesetzten Status gilt als kritisch, nicht als gesund.
|
|
if checkResult.Status == "" {
|
|
checkResult.Status = StatusCritical
|
|
checkResult.Message = "Die Komponente hat keinen Zustand gemeldet."
|
|
}
|
|
|
|
return checkResult
|
|
|
|
case <-checkContext.Done():
|
|
return CheckResult{
|
|
Status: StatusCritical,
|
|
Message: "Die Komponente hat nicht rechtzeitig geantwortet.",
|
|
RecommendedAction: "Erreichbarkeit und Auslastung der Komponente prüfen.",
|
|
LatencyMilliseconds: float64(time.Since(checkStartTime).Microseconds()) / 1000,
|
|
CheckedAt: time.Now().UTC(),
|
|
}
|
|
}
|
|
}
|
|
|
|
// IsReady meldet, ob alle als kritisch markierten Komponenten gesund sind.
|
|
//
|
|
// Readiness entscheidet, ob ein Load Balancer Verkehr schicken darf; eine
|
|
// unkritische Warnung soll den Dienst deshalb nicht aus dem Verkehr nehmen.
|
|
func (registry *Registry) IsReady(parentContext context.Context) (bool, SystemReport) {
|
|
systemReport := registry.Check(parentContext)
|
|
|
|
registry.mutex.RLock()
|
|
defer registry.mutex.RUnlock()
|
|
|
|
for _, registeredComponent := range registry.checks {
|
|
if !registeredComponent.isCritical {
|
|
continue
|
|
}
|
|
|
|
componentResult, isPresent := systemReport.Components[registeredComponent.componentName]
|
|
// Ein fehlendes Ergebnis wird als nicht bereit gewertet.
|
|
if !isPresent || componentResult.Status != StatusHealthy {
|
|
return false, systemReport
|
|
}
|
|
}
|
|
|
|
return true, systemReport
|
|
}
|
|
|
|
// worstStatus ermittelt den schlechtesten Zustand aller Komponenten.
|
|
func worstStatus(componentResults map[string]CheckResult) Status {
|
|
// Ohne registrierte Prüfung gibt es keine belegte Aussage über die Gesundheit.
|
|
if len(componentResults) == 0 {
|
|
return StatusHealthy
|
|
}
|
|
|
|
overallStatus := StatusHealthy
|
|
for _, componentResult := range componentResults {
|
|
if componentResult.Status.IsWorseThan(overallStatus) {
|
|
overallStatus = componentResult.Status
|
|
}
|
|
}
|
|
|
|
return overallStatus
|
|
}
|