syncova-backup/packages/scheduler/window.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

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
}