syncova-backup/packages/platform/health/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

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
}