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>
212 lines
7.5 KiB
Go
212 lines
7.5 KiB
Go
package scheduler
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
"math/rand/v2"
|
|
"time"
|
|
)
|
|
|
|
// FailureClass ist die Einordnung eines Fehlers (PROMPT.md §139).
|
|
//
|
|
// Die Einordnung entscheidet über die Wiederholung. Ohne sie gäbe es nur zwei
|
|
// gleich schlechte Möglichkeiten: alles zu wiederholen — dann läuft ein
|
|
// Auftrag mit falschem Passwort ewig weiter — oder nichts — dann scheitert
|
|
// eine Sicherung an einer Sekunde Netzstörung.
|
|
type FailureClass string
|
|
|
|
const (
|
|
// FailureTransient ist ein vorübergehender Fehler.
|
|
FailureTransient FailureClass = "transient"
|
|
// FailurePermanent ist ein dauerhafter Fehler.
|
|
FailurePermanent FailureClass = "permanent"
|
|
// FailureIntegrity ist ein Integritätsfehler.
|
|
FailureIntegrity FailureClass = "integrity"
|
|
// FailureAuthentication ist ein Anmeldefehler.
|
|
FailureAuthentication FailureClass = "auth"
|
|
// FailureRepository ist ein Fehler des Repositorys.
|
|
FailureRepository FailureClass = "repository"
|
|
// FailureSource ist ein Fehler der Quelle.
|
|
FailureSource FailureClass = "source"
|
|
// FailureNetwork ist ein Netzfehler.
|
|
FailureNetwork FailureClass = "network"
|
|
// FailureConfiguration ist ein Konfigurationsfehler.
|
|
FailureConfiguration FailureClass = "configuration"
|
|
// FailureSecurity ist ein sicherheitsrelevanter Fehler.
|
|
FailureSecurity FailureClass = "security"
|
|
)
|
|
|
|
// retryableFailureClasses sind die Klassen, bei denen ein neuer Versuch
|
|
// sinnvoll ist.
|
|
//
|
|
// Bewusst kurz gehalten. Ein Anmeldefehler behebt sich nicht durch Warten; ein
|
|
// Integritätsfehler wird durch Wiederholen nicht besser, sondern nur später
|
|
// bemerkt. Und ein sicherheitsrelevanter Fehler gehört gemeldet, nicht
|
|
// verschluckt.
|
|
var retryableFailureClasses = map[FailureClass]bool{
|
|
FailureTransient: true,
|
|
FailureNetwork: true,
|
|
FailureRepository: true,
|
|
FailureSource: true,
|
|
}
|
|
|
|
// IsRetryable meldet, ob ein neuer Versuch sinnvoll ist.
|
|
func (failureClass FailureClass) IsRetryable() bool {
|
|
return retryableFailureClasses[failureClass]
|
|
}
|
|
|
|
// RetryPolicy beschreibt das Wiederholungsverhalten.
|
|
type RetryPolicy struct {
|
|
// MaximumAttempts ist die Gesamtzahl der Versuche einschließlich des ersten.
|
|
//
|
|
// 1 bedeutet: keine Wiederholung.
|
|
MaximumAttempts int `json:"maximum_attempts"`
|
|
// InitialDelay ist die Wartezeit vor dem zweiten Versuch.
|
|
InitialDelay time.Duration `json:"initial_delay"`
|
|
// MaximumDelay begrenzt die Wartezeit nach oben.
|
|
//
|
|
// Ohne Deckel wüchse die Wartezeit exponentiell weiter: Beim achten Versuch
|
|
// wären es aus 30 Sekunden bereits über eine Stunde, beim zehnten mehr als
|
|
// vier. Ein nächtliches Sicherungsfenster wäre längst vorbei.
|
|
MaximumDelay time.Duration `json:"maximum_delay"`
|
|
// JitterFraction streut die Wartezeit zufällig.
|
|
//
|
|
// Nötig gegen den Gleichlauf: Fallen zwanzig Aufträge derselben Störung zum
|
|
// Opfer, würden sie ohne Streuung alle gleichzeitig erneut anlaufen und die
|
|
// eben erst erholte Gegenstelle sofort wieder überlasten.
|
|
JitterFraction float64 `json:"jitter_fraction"`
|
|
}
|
|
|
|
// DefaultRetryPolicy ist die Voreinstellung.
|
|
//
|
|
// Drei Versuche, beginnend bei 30 Sekunden, gedeckelt bei 15 Minuten. Wer nach
|
|
// drei Anläufen über eine halbe Stunde nicht durchkommt, hat ein Problem, das
|
|
// sich nicht von selbst löst — dann ist eine Meldung nützlicher als ein
|
|
// vierter Versuch.
|
|
func DefaultRetryPolicy() RetryPolicy {
|
|
return RetryPolicy{
|
|
MaximumAttempts: 3,
|
|
InitialDelay: 30 * time.Second,
|
|
MaximumDelay: 15 * time.Minute,
|
|
JitterFraction: 0.2,
|
|
}
|
|
}
|
|
|
|
// Validate prüft eine Wiederholungsstrategie.
|
|
func (retryPolicy *RetryPolicy) Validate() error {
|
|
if retryPolicy.MaximumAttempts < 1 {
|
|
return errors.New("es muss mindestens ein versuch zugelassen sein")
|
|
}
|
|
|
|
if retryPolicy.MaximumAttempts > 10 {
|
|
// Mehr als zehn Versuche verschleiern ein dauerhaftes Problem, statt es
|
|
// zu melden.
|
|
return fmt.Errorf("mehr als 10 versuche sind nicht zugelassen, angegeben waren %d", retryPolicy.MaximumAttempts)
|
|
}
|
|
|
|
if retryPolicy.InitialDelay < 0 || retryPolicy.MaximumDelay < 0 {
|
|
return errors.New("die wartezeiten dürfen nicht negativ sein")
|
|
}
|
|
|
|
if retryPolicy.MaximumDelay > 0 && retryPolicy.MaximumDelay < retryPolicy.InitialDelay {
|
|
return errors.New("die obere grenze der wartezeit liegt unter der anfänglichen wartezeit")
|
|
}
|
|
|
|
if retryPolicy.JitterFraction < 0 || retryPolicy.JitterFraction > 1 {
|
|
return errors.New("die streuung muss zwischen 0 und 1 liegen")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// RetryDecision ist das Urteil über einen weiteren Versuch.
|
|
type RetryDecision struct {
|
|
// ShouldRetry meldet, ob ein weiterer Versuch stattfindet.
|
|
ShouldRetry bool
|
|
// Delay ist die Wartezeit bis dahin.
|
|
Delay time.Duration
|
|
// Reason erklärt das Urteil verständlich.
|
|
//
|
|
// Auch ein „nein" braucht eine Begründung: Der Betreiber muss unterscheiden
|
|
// können zwischen „aufgegeben, weil die Versuche erschöpft sind" und
|
|
// „nicht wiederholt, weil das Passwort falsch ist".
|
|
Reason string
|
|
}
|
|
|
|
// Decide entscheidet über einen weiteren Versuch.
|
|
//
|
|
// attemptNumber ist die Nummer des soeben gescheiterten Versuchs, beginnend
|
|
// bei 1.
|
|
func (retryPolicy *RetryPolicy) Decide(attemptNumber int, failureClass FailureClass) RetryDecision {
|
|
if !failureClass.IsRetryable() {
|
|
return RetryDecision{
|
|
Reason: fmt.Sprintf("Ein Fehler der Art %q behebt sich nicht durch einen weiteren Versuch.", failureClass),
|
|
}
|
|
}
|
|
|
|
if attemptNumber >= retryPolicy.MaximumAttempts {
|
|
return RetryDecision{
|
|
Reason: fmt.Sprintf("Alle %d zugelassenen Versuche sind aufgebraucht.", retryPolicy.MaximumAttempts),
|
|
}
|
|
}
|
|
|
|
return RetryDecision{
|
|
ShouldRetry: true,
|
|
Delay: retryPolicy.delayForAttempt(attemptNumber),
|
|
Reason: fmt.Sprintf("Versuch %d von %d scheiterte an einem Fehler der Art %q; ein weiterer Versuch folgt.",
|
|
attemptNumber, retryPolicy.MaximumAttempts, failureClass),
|
|
}
|
|
}
|
|
|
|
// delayForAttempt berechnet die Wartezeit nach einem Versuch.
|
|
func (retryPolicy *RetryPolicy) delayForAttempt(attemptNumber int) time.Duration {
|
|
if retryPolicy.InitialDelay <= 0 {
|
|
return 0
|
|
}
|
|
|
|
// Exponentiell: 30 s, 60 s, 120 s, 240 s …
|
|
//
|
|
// Gerechnet wird in Gleitkomma, weil ein Schieben um mehr als 62 Stellen
|
|
// überliefe und aus einer langen Wartezeit eine negative machte.
|
|
exponentialFactor := math.Pow(2, float64(attemptNumber-1))
|
|
computedDelay := time.Duration(float64(retryPolicy.InitialDelay) * exponentialFactor)
|
|
|
|
if retryPolicy.MaximumDelay > 0 && computedDelay > retryPolicy.MaximumDelay {
|
|
computedDelay = retryPolicy.MaximumDelay
|
|
}
|
|
|
|
if retryPolicy.JitterFraction <= 0 {
|
|
return computedDelay
|
|
}
|
|
|
|
// Die Streuung wirkt nur nach unten. Nach oben verlängerte sie die
|
|
// Wartezeit über die vereinbarte Grenze hinaus — der Deckel wäre dann
|
|
// keiner.
|
|
jitterRange := float64(computedDelay) * retryPolicy.JitterFraction
|
|
appliedJitter := rand.Float64() * jitterRange
|
|
|
|
return time.Duration(float64(computedDelay) - appliedJitter)
|
|
}
|
|
|
|
// ClassifyError ordnet einen Fehler anhand bekannter Merkmale ein.
|
|
//
|
|
// Die Einordnung ist eine Hilfe für Aufrufer, die keine eigene liefern. Sie
|
|
// fällt im Zweifel auf FailurePermanent zurück — also auf „nicht wiederholen".
|
|
// Der umgekehrte Standard wäre gefährlicher: Ein falsch als vorübergehend
|
|
// eingeordneter Fehler ließe einen aussichtslosen Auftrag immer wieder
|
|
// anlaufen und verdeckte dabei die eigentliche Ursache.
|
|
func ClassifyError(occurredError error, knownClassifications map[error]FailureClass) FailureClass {
|
|
if occurredError == nil {
|
|
return ""
|
|
}
|
|
|
|
for knownError, failureClass := range knownClassifications {
|
|
if errors.Is(occurredError, knownError) {
|
|
return failureClass
|
|
}
|
|
}
|
|
|
|
return FailurePermanent
|
|
}
|