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>
325 lines
11 KiB
Go
325 lines
11 KiB
Go
package scheduler
|
|
|
|
import (
|
|
"fmt"
|
|
"sort"
|
|
"time"
|
|
)
|
|
|
|
// WindowKind benennt die Wirkung eines Wartungsfensters.
|
|
type WindowKind string
|
|
|
|
const (
|
|
// WindowKindBlackout unterdrückt Läufe im Fenster.
|
|
//
|
|
// Der gewöhnliche Fall: Während der Inventur oder des Monatsabschlusses
|
|
// soll die Anlage nicht zusätzlich mit Sicherungen belastet werden.
|
|
WindowKindBlackout WindowKind = "blackout"
|
|
// WindowKindAllowed lässt Läufe ausschließlich im Fenster zu.
|
|
//
|
|
// Die Umkehrung: Manche Betreiber erlauben Sicherungen nur nachts. Ein
|
|
// Auftrag, dessen Zeitpunkt außerhalb liegt, wartet auf das nächste
|
|
// Fenster.
|
|
WindowKindAllowed WindowKind = "allowed"
|
|
)
|
|
|
|
// MaintenanceWindow ist ein Zeitraum mit besonderer Behandlung.
|
|
type MaintenanceWindow struct {
|
|
// Identifier ist die Kennung des Fensters.
|
|
Identifier string `json:"identifier"`
|
|
// Name ist die sprechende Bezeichnung.
|
|
Name string `json:"name"`
|
|
// WindowKind ist die Wirkung.
|
|
WindowKind WindowKind `json:"kind"`
|
|
// StartsAt ist der Beginn eines einmaligen Fensters in UTC.
|
|
StartsAt time.Time `json:"starts_at,omitempty"`
|
|
// EndsAt ist das Ende eines einmaligen Fensters in UTC.
|
|
EndsAt time.Time `json:"ends_at,omitempty"`
|
|
// Recurring beschreibt ein wiederkehrendes Fenster.
|
|
//
|
|
// Ist es gesetzt, gelten StartsAt und EndsAt nicht.
|
|
Recurring *RecurringWindow `json:"recurring,omitempty"`
|
|
// AppliesToJobIDs beschränkt das Fenster auf bestimmte Aufträge.
|
|
//
|
|
// Eine leere Liste bedeutet: gilt für alle. Das ist der sichere Standard —
|
|
// wer ein Fenster einrichtet, meint in aller Regel die ganze Anlage.
|
|
AppliesToJobIDs []string `json:"applies_to_job_ids,omitempty"`
|
|
}
|
|
|
|
// RecurringWindow ist ein wiederkehrender Zeitraum.
|
|
type RecurringWindow struct {
|
|
// Weekdays sind die Wochentage; leer bedeutet täglich.
|
|
Weekdays []time.Weekday `json:"weekdays,omitempty"`
|
|
// StartHour ist die Anfangsstunde in Ortszeit.
|
|
StartHour int `json:"start_hour"`
|
|
// StartMinute ist die Anfangsminute.
|
|
StartMinute int `json:"start_minute"`
|
|
// Duration ist die Länge des Fensters.
|
|
//
|
|
// Eine Länge statt einer Endzeit, damit ein Fenster über Mitternacht
|
|
// hinausreichen kann. „22:00 bis 02:00" als zwei Uhrzeiten zu beschreiben
|
|
// verlangt eine Sonderregel, die gern vergessen wird.
|
|
Duration time.Duration `json:"duration"`
|
|
// TimeZone ist die Zeitzone der Uhrzeiten.
|
|
TimeZone string `json:"time_zone,omitempty"`
|
|
}
|
|
|
|
// Validate prüft ein Wartungsfenster.
|
|
func (window *MaintenanceWindow) Validate() error {
|
|
if window.WindowKind != WindowKindBlackout && window.WindowKind != WindowKindAllowed {
|
|
return fmt.Errorf("%w: die wirkung %q ist unbekannt", ErrInvalidSchedule, window.WindowKind)
|
|
}
|
|
|
|
if window.Recurring == nil {
|
|
if window.StartsAt.IsZero() || window.EndsAt.IsZero() {
|
|
return fmt.Errorf("%w: ein einmaliges fenster braucht anfang und ende", ErrInvalidSchedule)
|
|
}
|
|
|
|
if !window.EndsAt.After(window.StartsAt) {
|
|
return fmt.Errorf("%w: das ende des fensters liegt nicht nach seinem anfang", ErrInvalidSchedule)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
recurringWindow := window.Recurring
|
|
|
|
if recurringWindow.Duration <= 0 {
|
|
return fmt.Errorf("%w: ein wiederkehrendes fenster braucht eine länge", ErrInvalidSchedule)
|
|
}
|
|
|
|
// Ein Fenster von mehr als einer Woche überdeckte sich mit sich selbst; die
|
|
// Auswertung wäre nicht mehr eindeutig.
|
|
if recurringWindow.Duration > 7*24*time.Hour {
|
|
return fmt.Errorf("%w: ein wiederkehrendes fenster darf höchstens eine woche dauern", ErrInvalidSchedule)
|
|
}
|
|
|
|
if validationError := validateHour(recurringWindow.StartHour); validationError != nil {
|
|
return validationError
|
|
}
|
|
|
|
if validationError := validateMinute(recurringWindow.StartMinute); validationError != nil {
|
|
return validationError
|
|
}
|
|
|
|
if recurringWindow.TimeZone != "" {
|
|
if _, loadError := time.LoadLocation(recurringWindow.TimeZone); loadError != nil {
|
|
return fmt.Errorf("%w: die zeitzone %q ist unbekannt", ErrInvalidSchedule, recurringWindow.TimeZone)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// AppliesTo meldet, ob das Fenster für einen Auftrag gilt.
|
|
func (window *MaintenanceWindow) AppliesTo(jobIdentifier string) bool {
|
|
if len(window.AppliesToJobIDs) == 0 {
|
|
return true
|
|
}
|
|
|
|
for _, applicableJobID := range window.AppliesToJobIDs {
|
|
if applicableJobID == jobIdentifier {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// Contains meldet, ob ein Zeitpunkt im Fenster liegt.
|
|
func (window *MaintenanceWindow) Contains(candidateTime time.Time) bool {
|
|
if window.Recurring == nil {
|
|
// Der Anfang gehört dazu, das Ende nicht. Sonst gehörte ein Zeitpunkt
|
|
// zwei aufeinanderfolgenden Fenstern an.
|
|
return !candidateTime.Before(window.StartsAt) && candidateTime.Before(window.EndsAt)
|
|
}
|
|
|
|
return window.Recurring.contains(candidateTime)
|
|
}
|
|
|
|
// contains meldet, ob ein Zeitpunkt in einem wiederkehrenden Fenster liegt.
|
|
//
|
|
// Geprüft wird rückwärts über acht Tage. Der Grund ist die Länge: Ein Fenster,
|
|
// das samstags um 22:00 beginnt und zwölf Stunden dauert, reicht bis Sonntag
|
|
// 10:00. Wer nur den Tag des Zeitpunkts prüft, hielte Sonntag 09:00 für frei —
|
|
// und liesse den Auftrag mitten in die Wartung laufen.
|
|
func (recurringWindow *RecurringWindow) contains(candidateTime time.Time) bool {
|
|
windowLocation := time.UTC
|
|
if recurringWindow.TimeZone != "" {
|
|
if loadedLocation, loadError := time.LoadLocation(recurringWindow.TimeZone); loadError == nil {
|
|
windowLocation = loadedLocation
|
|
}
|
|
}
|
|
|
|
localCandidate := candidateTime.In(windowLocation)
|
|
|
|
// Acht Tage decken auch ein Fenster von voller Wochenlänge ab.
|
|
const lookbackDays = 8
|
|
|
|
for dayOffset := 0; dayOffset < lookbackDays; dayOffset++ {
|
|
windowDay := localCandidate.AddDate(0, 0, -dayOffset)
|
|
|
|
if !recurringWindow.matchesWeekday(windowDay.Weekday()) {
|
|
continue
|
|
}
|
|
|
|
windowStart := time.Date(windowDay.Year(), windowDay.Month(), windowDay.Day(),
|
|
recurringWindow.StartHour, recurringWindow.StartMinute, 0, 0, windowLocation)
|
|
windowEnd := windowStart.Add(recurringWindow.Duration)
|
|
|
|
if !localCandidate.Before(windowStart) && localCandidate.Before(windowEnd) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// matchesWeekday prüft den Wochentag eines Fensterbeginns.
|
|
func (recurringWindow *RecurringWindow) matchesWeekday(candidateWeekday time.Weekday) bool {
|
|
if len(recurringWindow.Weekdays) == 0 {
|
|
return true
|
|
}
|
|
|
|
for _, allowedWeekday := range recurringWindow.Weekdays {
|
|
if allowedWeekday == candidateWeekday {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// maximumWindowSearch begrenzt die Suche nach dem Fensterende.
|
|
const maximumWindowSearch = 32 * 24 * time.Hour
|
|
|
|
// windowSearchStep ist die Schrittweite bei der Suche nach dem Fensterende.
|
|
//
|
|
// Eine Minute genügt: Zeitpläne haben keine feinere Auflösung.
|
|
const windowSearchStep = time.Minute
|
|
|
|
// WindowSet ist eine Menge von Wartungsfenstern.
|
|
type WindowSet struct {
|
|
// windows sind die enthaltenen Fenster.
|
|
windows []MaintenanceWindow
|
|
}
|
|
|
|
// NewWindowSet erzeugt eine geprüfte Fenstermenge.
|
|
func NewWindowSet(maintenanceWindows []MaintenanceWindow) (*WindowSet, error) {
|
|
for windowIndex := range maintenanceWindows {
|
|
if validationError := maintenanceWindows[windowIndex].Validate(); validationError != nil {
|
|
return nil, fmt.Errorf("das fenster %q ist unbrauchbar: %w",
|
|
maintenanceWindows[windowIndex].Name, validationError)
|
|
}
|
|
}
|
|
|
|
return &WindowSet{windows: maintenanceWindows}, nil
|
|
}
|
|
|
|
// BlockReason erklärt, warum ein Zeitpunkt gesperrt ist.
|
|
type BlockReason struct {
|
|
// WindowName ist das verantwortliche Fenster.
|
|
WindowName string
|
|
// WindowKind ist dessen Wirkung.
|
|
WindowKind WindowKind
|
|
// Explanation ist eine verständliche Begründung.
|
|
Explanation string
|
|
}
|
|
|
|
// IsBlocked prüft, ob ein Zeitpunkt für einen Auftrag gesperrt ist.
|
|
//
|
|
// Zwei Regeln greifen nacheinander:
|
|
//
|
|
// 1. Liegt der Zeitpunkt in einem Sperrfenster, ist er gesperrt.
|
|
// 2. Gibt es Erlaubnisfenster und liegt der Zeitpunkt in keinem davon, ist er
|
|
// ebenfalls gesperrt.
|
|
//
|
|
// Die zweite Regel gilt nur, wenn überhaupt ein Erlaubnisfenster für den
|
|
// Auftrag eingerichtet ist. Andernfalls wäre jeder Zeitpunkt gesperrt, sobald
|
|
// irgendwo ein Erlaubnisfenster existiert — und alle Sicherungen fielen aus.
|
|
func (windowSet *WindowSet) IsBlocked(candidateTime time.Time, jobIdentifier string) (bool, *BlockReason) {
|
|
var hasAllowedWindow bool
|
|
var isInsideAllowedWindow bool
|
|
|
|
for windowIndex := range windowSet.windows {
|
|
currentWindow := &windowSet.windows[windowIndex]
|
|
|
|
if !currentWindow.AppliesTo(jobIdentifier) {
|
|
continue
|
|
}
|
|
|
|
if currentWindow.WindowKind == WindowKindBlackout {
|
|
if currentWindow.Contains(candidateTime) {
|
|
return true, &BlockReason{
|
|
WindowName: currentWindow.Name,
|
|
WindowKind: WindowKindBlackout,
|
|
Explanation: fmt.Sprintf("Der Zeitpunkt liegt im Wartungsfenster %q.", currentWindow.Name),
|
|
}
|
|
}
|
|
|
|
continue
|
|
}
|
|
|
|
hasAllowedWindow = true
|
|
if currentWindow.Contains(candidateTime) {
|
|
isInsideAllowedWindow = true
|
|
}
|
|
}
|
|
|
|
if hasAllowedWindow && !isInsideAllowedWindow {
|
|
return true, &BlockReason{
|
|
WindowKind: WindowKindAllowed,
|
|
Explanation: "Der Zeitpunkt liegt außerhalb der Zeiträume, in denen Sicherungen zugelassen sind.",
|
|
}
|
|
}
|
|
|
|
return false, nil
|
|
}
|
|
|
|
// NextAllowedTime sucht den nächsten freien Zeitpunkt ab einem Bezugspunkt.
|
|
//
|
|
// Ein durch ein Fenster verhinderter Lauf wird **verschoben, nicht übergangen**.
|
|
// Ihn ausfallen zu lassen wäre der schlimmste Ausgang: Es entstünde eine Lücke
|
|
// in der Sicherungskette, ohne dass jemand davon erführe. Dass ein Lauf
|
|
// verspätet ist, sieht man; dass er fehlt, nicht.
|
|
func (windowSet *WindowSet) NextAllowedTime(desiredTime time.Time, jobIdentifier string) (time.Time, error) {
|
|
candidateTime := desiredTime
|
|
searchDeadline := desiredTime.Add(maximumWindowSearch)
|
|
|
|
for candidateTime.Before(searchDeadline) {
|
|
isBlocked, _ := windowSet.IsBlocked(candidateTime, jobIdentifier)
|
|
if !isBlocked {
|
|
return candidateTime, nil
|
|
}
|
|
|
|
candidateTime = candidateTime.Add(windowSearchStep)
|
|
}
|
|
|
|
// Kein freier Zeitpunkt binnen eines Monats bedeutet fast immer eine
|
|
// fehlerhafte Konfiguration — etwa ein Sperrfenster ohne Ende. Das ist ein
|
|
// Fehler und kein stiller Ausfall.
|
|
return time.Time{}, fmt.Errorf("für den auftrag %s gibt es in den nächsten %.0f tagen keinen zulässigen zeitpunkt; "+
|
|
"prüfen Sie die wartungsfenster", jobIdentifier, maximumWindowSearch.Hours()/24)
|
|
}
|
|
|
|
// ActiveWindows liefert die zu einem Zeitpunkt wirksamen Fenster.
|
|
//
|
|
// Für die Oberfläche: Der Anwender soll sehen, warum sein Auftrag wartet.
|
|
func (windowSet *WindowSet) ActiveWindows(candidateTime time.Time, jobIdentifier string) []MaintenanceWindow {
|
|
activeWindows := make([]MaintenanceWindow, 0)
|
|
|
|
for windowIndex := range windowSet.windows {
|
|
currentWindow := &windowSet.windows[windowIndex]
|
|
|
|
if currentWindow.AppliesTo(jobIdentifier) && currentWindow.Contains(candidateTime) {
|
|
activeWindows = append(activeWindows, *currentWindow)
|
|
}
|
|
}
|
|
|
|
sort.Slice(activeWindows, func(firstIndex int, secondIndex int) bool {
|
|
return activeWindows[firstIndex].Name < activeWindows[secondIndex].Name
|
|
})
|
|
|
|
return activeWindows
|
|
}
|