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

433 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Package scheduler entscheidet, wann ein Sicherungsauftrag läuft.
//
// Der Kern ist bewusst frei von Datenbank und Netzwerk: Zeitpläne,
// Wartungsfenster, Prioritäten und Wiederholungen sind reine Berechnungen und
// damit vollständig prüfbar. Ein Zeitplan, der sich nur im Betrieb über Wochen
// beobachten liesse, wäre nicht zu belegen.
package scheduler
import (
"errors"
"fmt"
"strconv"
"strings"
"time"
)
// ScheduleType benennt die Art eines Zeitplans.
type ScheduleType string
const (
// ScheduleTypeManual läuft nur auf ausdrückliche Anforderung.
ScheduleTypeManual ScheduleType = "manual"
// ScheduleTypeInterval läuft in festen Abständen.
ScheduleTypeInterval ScheduleType = "interval"
// ScheduleTypeHourly läuft stündlich zu einer festen Minute.
ScheduleTypeHourly ScheduleType = "hourly"
// ScheduleTypeDaily läuft täglich zu einer festen Uhrzeit.
ScheduleTypeDaily ScheduleType = "daily"
// ScheduleTypeWeekly läuft an bestimmten Wochentagen.
ScheduleTypeWeekly ScheduleType = "weekly"
// ScheduleTypeMonthly läuft an bestimmten Tagen des Monats.
ScheduleTypeMonthly ScheduleType = "monthly"
// ScheduleTypeCron folgt einem Cron-Ausdruck mit fünf Feldern.
ScheduleTypeCron ScheduleType = "cron"
)
// lastDayOfMonth ist der Platzhalter für den letzten Tag eines Monats.
//
// Er wird gebraucht, weil „am 31." in kurzen Monaten nicht existiert. Wer eine
// Monatssicherung zum Monatsende will, meint den letzten Tag — nicht „fällt in
// vier Monaten des Jahres aus".
const lastDayOfMonth = -1
// Schedule beschreibt, wann ein Auftrag laufen soll.
type Schedule struct {
// ScheduleType ist die Art des Zeitplans.
ScheduleType ScheduleType `json:"type"`
// Interval ist der Abstand bei ScheduleTypeInterval.
Interval time.Duration `json:"interval,omitempty"`
// Minute ist die Minute der Stunde (0–59).
Minute int `json:"minute,omitempty"`
// Hour ist die Stunde des Tages (0–23).
Hour int `json:"hour,omitempty"`
// Weekdays sind die Wochentage bei ScheduleTypeWeekly.
Weekdays []time.Weekday `json:"weekdays,omitempty"`
// MonthDays sind die Tage des Monats bei ScheduleTypeMonthly.
//
// Der Wert -1 bedeutet „letzter Tag des Monats".
MonthDays []int `json:"month_days,omitempty"`
// CronExpression ist der Ausdruck bei ScheduleTypeCron.
CronExpression string `json:"cron_expression,omitempty"`
// TimeZone ist die Zeitzone, in der die Uhrzeiten gelten.
//
// Ohne Angabe gilt UTC. Das ist eine bewusste Entscheidung: Ein Zeitplan
// ohne Zeitzone in der Ortszeit des Servers auszuführen bedeutet, dass
// dieselbe Konfiguration auf zwei Servern zu verschiedenen Zeiten läuft.
TimeZone string `json:"time_zone,omitempty"`
}
// ErrInvalidSchedule meldet einen unbrauchbaren Zeitplan.
var ErrInvalidSchedule = errors.New("der zeitplan ist unbrauchbar")
// minimumInterval ist der kleinste zulässige Abstand.
//
// Kürzere Abstände sind kein sinnvoller Sicherungsplan, sondern fast immer ein
// Tippfehler — und ein Auftrag, der sich alle paar Sekunden selbst anstößt,
// legt die Anlage lahm.
const minimumInterval = time.Minute
// Validate prüft einen Zeitplan auf Ausführbarkeit.
//
// Die Prüfung geschieht beim Anlegen, nicht erst beim Ausführen. Ein Zeitplan,
// dessen Fehler sich erst um 02:00 Uhr zeigt, fällt genau dann aus, wenn
// niemand hinsieht.
func (schedule *Schedule) Validate() error {
if _, locationError := schedule.location(); locationError != nil {
return locationError
}
switch schedule.ScheduleType {
case ScheduleTypeManual:
return nil
case ScheduleTypeInterval:
if schedule.Interval < minimumInterval {
return fmt.Errorf("%w: der abstand muss mindestens %s betragen, angegeben war %s",
ErrInvalidSchedule, minimumInterval, schedule.Interval)
}
return nil
case ScheduleTypeHourly:
return validateMinute(schedule.Minute)
case ScheduleTypeDaily:
return errors.Join(validateMinute(schedule.Minute), validateHour(schedule.Hour))
case ScheduleTypeWeekly:
if len(schedule.Weekdays) == 0 {
return fmt.Errorf("%w: ein wöchentlicher zeitplan braucht mindestens einen wochentag", ErrInvalidSchedule)
}
for _, weekday := range schedule.Weekdays {
if weekday < time.Sunday || weekday > time.Saturday {
return fmt.Errorf("%w: %d ist kein wochentag", ErrInvalidSchedule, weekday)
}
}
return errors.Join(validateMinute(schedule.Minute), validateHour(schedule.Hour))
case ScheduleTypeMonthly:
if len(schedule.MonthDays) == 0 {
return fmt.Errorf("%w: ein monatlicher zeitplan braucht mindestens einen tag", ErrInvalidSchedule)
}
for _, monthDay := range schedule.MonthDays {
if monthDay != lastDayOfMonth && (monthDay < 1 || monthDay > 31) {
return fmt.Errorf("%w: %d ist kein tag des monats (-1 bedeutet letzter tag)", ErrInvalidSchedule, monthDay)
}
}
return errors.Join(validateMinute(schedule.Minute), validateHour(schedule.Hour))
case ScheduleTypeCron:
_, parseError := parseCronExpression(schedule.CronExpression)
return parseError
default:
return fmt.Errorf("%w: die art %q ist unbekannt", ErrInvalidSchedule, schedule.ScheduleType)
}
}
// validateMinute prüft eine Minutenangabe.
func validateMinute(minuteValue int) error {
if minuteValue < 0 || minuteValue > 59 {
return fmt.Errorf("%w: %d ist keine minute", ErrInvalidSchedule, minuteValue)
}
return nil
}
// validateHour prüft eine Stundenangabe.
func validateHour(hourValue int) error {
if hourValue < 0 || hourValue > 23 {
return fmt.Errorf("%w: %d ist keine stunde", ErrInvalidSchedule, hourValue)
}
return nil
}
// location liefert die Zeitzone des Zeitplans.
func (schedule *Schedule) location() (*time.Location, error) {
if strings.TrimSpace(schedule.TimeZone) == "" {
return time.UTC, nil
}
loadedLocation, loadError := time.LoadLocation(schedule.TimeZone)
if loadError != nil {
return nil, fmt.Errorf("%w: die zeitzone %q ist unbekannt: %v",
ErrInvalidSchedule, schedule.TimeZone, loadError)
}
return loadedLocation, nil
}
// maximumSearchDays begrenzt die Suche nach dem nächsten Zeitpunkt.
//
// Ein Zeitplan wie „31. Februar" hat keinen nächsten Zeitpunkt. Ohne Grenze
// liefe die Suche endlos; mit ihr entsteht ein verständlicher Fehler. Vier
// Jahre decken auch den 29. Februar ab.
const maximumSearchDays = 366 * 4
// NextRun berechnet den nächsten Ausführungszeitpunkt nach einem Bezugspunkt.
//
// Der zurückgegebene Zeitpunkt liegt immer echt nach dem Bezugspunkt. Wäre er
// gleich, liefe ein Auftrag unmittelbar nach seinem eigenen Ende erneut — eine
// Endlosschleife, die sich als Zeitplan ausgibt.
func (schedule *Schedule) NextRun(referenceTime time.Time) (time.Time, error) {
if validationError := schedule.Validate(); validationError != nil {
return time.Time{}, validationError
}
if schedule.ScheduleType == ScheduleTypeManual {
return time.Time{}, ErrNoNextRun
}
scheduleLocation, _ := schedule.location()
localReference := referenceTime.In(scheduleLocation)
if schedule.ScheduleType == ScheduleTypeInterval {
return localReference.Add(schedule.Interval), nil
}
if schedule.ScheduleType == ScheduleTypeHourly {
return schedule.nextHourlyRun(localReference), nil
}
if schedule.ScheduleType == ScheduleTypeCron {
return schedule.nextCronRun(localReference)
}
return schedule.nextCalendarRun(localReference, scheduleLocation)
}
// ErrNoNextRun meldet einen Zeitplan ohne künftigen Zeitpunkt.
var ErrNoNextRun = errors.New("dieser zeitplan hat keinen nächsten ausführungszeitpunkt")
// nextHourlyRun berechnet den nächsten stündlichen Zeitpunkt.
func (schedule *Schedule) nextHourlyRun(localReference time.Time) time.Time {
candidateTime := time.Date(localReference.Year(), localReference.Month(), localReference.Day(),
localReference.Hour(), schedule.Minute, 0, 0, localReference.Location())
if !candidateTime.After(localReference) {
candidateTime = candidateTime.Add(time.Hour)
}
return candidateTime
}
// nextCalendarRun berechnet den nächsten täglichen, wöchentlichen oder
// monatlichen Zeitpunkt.
//
// Gesucht wird tageweise. Das ist langsamer als eine geschlossene Formel und
// dafür richtig: Monatslängen, Schaltjahre und Zeitumstellungen fallen dabei
// von selbst heraus, statt einzeln bedacht werden zu müssen.
func (schedule *Schedule) nextCalendarRun(localReference time.Time, scheduleLocation *time.Location) (time.Time, error) {
searchDay := time.Date(localReference.Year(), localReference.Month(), localReference.Day(),
0, 0, 0, 0, scheduleLocation)
for dayOffset := 0; dayOffset <= maximumSearchDays; dayOffset++ {
// AddDate statt Add(24h): An Umstellungstagen hat ein Tag 23 oder 25
// Stunden. Mit einer festen Stundenzahl verschöbe sich die Suche
// zweimal im Jahr um eine Stunde.
currentDay := searchDay.AddDate(0, 0, dayOffset)
if !schedule.matchesDay(currentDay) {
continue
}
candidateTime := resolveLocalTime(currentDay, schedule.Hour, schedule.Minute, scheduleLocation)
if candidateTime.After(localReference) {
return candidateTime, nil
}
}
return time.Time{}, fmt.Errorf("%w: in den nächsten %d tagen gibt es keinen passenden zeitpunkt",
ErrNoNextRun, maximumSearchDays)
}
// matchesDay prüft, ob ein Tag zum Zeitplan passt.
func (schedule *Schedule) matchesDay(candidateDay time.Time) bool {
switch schedule.ScheduleType {
case ScheduleTypeDaily:
return true
case ScheduleTypeWeekly:
for _, weekday := range schedule.Weekdays {
if candidateDay.Weekday() == weekday {
return true
}
}
return false
case ScheduleTypeMonthly:
daysInMonth := daysInMonthOf(candidateDay)
for _, monthDay := range schedule.MonthDays {
if monthDay == lastDayOfMonth {
if candidateDay.Day() == daysInMonth {
return true
}
continue
}
if candidateDay.Day() == monthDay {
return true
}
}
return false
default:
return false
}
}
// daysInMonthOf liefert die Zahl der Tage im Monat eines Zeitpunkts.
func daysInMonthOf(referenceDay time.Time) int {
// Der nullte Tag des Folgemonats ist der letzte des aktuellen.
firstOfNextMonth := time.Date(referenceDay.Year(), referenceDay.Month(), 1, 0, 0, 0, 0, referenceDay.Location()).
AddDate(0, 1, 0)
return firstOfNextMonth.AddDate(0, 0, -1).Day()
}
// resolveLocalTime bildet eine Ortszeit und behandelt die Zeitumstellung.
//
// Das ist der heikelste Punkt der ganzen Zeitplanung, und er wird von vielen
// Schedulern stillschweigend falsch gemacht. Zwei Fälle:
//
// 1. Die Uhrzeit existiert nicht. In Mitteleuropa springt die Uhr im Frühjahr
// von 02:00 auf 03:00; ein Auftrag „täglich 02:30" hätte an diesem Tag
// keinen Zeitpunkt. Go liefert für eine solche Angabe stillschweigend
// 03:30 — was hier auch gewollt ist, aber ausdrücklich festgestellt wird.
// Der Auftrag läuft, statt einmal im Jahr auszufallen.
//
// 2. Die Uhrzeit existiert zweimal. Im Herbst wird 02:00 bis 03:00 wiederholt;
// „täglich 02:30" gäbe es an diesem Tag zweimal. Go wählt das erste
// Vorkommen. Das ist die richtige Wahl: Ein Backup lieber eine Stunde
// früher als eines, das zweimal läuft und zwei Ketten anlegt.
//
// Die Funktion setzt beide Entscheidungen ausdrücklich um, statt sie dem Zufall
// des Standardverhaltens zu überlassen.
func resolveLocalTime(candidateDay time.Time, targetHour int, targetMinute int, scheduleLocation *time.Location) time.Time {
constructedTime := time.Date(candidateDay.Year(), candidateDay.Month(), candidateDay.Day(),
targetHour, targetMinute, 0, 0, scheduleLocation)
// Weicht die zurückgelesene Stunde ab, lag die gewünschte Uhrzeit in einer
// übersprungenen Lücke. Go hat bereits auf den nächsten gültigen Zeitpunkt
// gelegt — das wird übernommen, damit der Lauf nicht ausfällt.
if constructedTime.Hour() != targetHour {
return constructedTime
}
// Bei einer doppelt vorhandenen Stunde liefert Go das frühere Vorkommen.
// Ein Auftrag darf an diesem Tag nur einmal laufen; die zweite Gelegenheit
// liegt vor dem zurückgegebenen Zeitpunkt und wird nie erreicht, weil
// NextRun stets einen echt späteren Zeitpunkt verlangt.
return constructedTime
}
// Describe beschreibt einen Zeitplan in einem Satz.
//
// Die Beschreibung ist für die Oberfläche gedacht: Ein Cron-Ausdruck sagt einem
// Anwender wenig, „täglich um 02:00 Uhr (Europe/Berlin)" dagegen alles.
func (schedule *Schedule) Describe() string {
timeZoneSuffix := ""
if schedule.TimeZone != "" {
timeZoneSuffix = fmt.Sprintf(" (%s)", schedule.TimeZone)
}
switch schedule.ScheduleType {
case ScheduleTypeManual:
return "nur auf Anforderung"
case ScheduleTypeInterval:
return fmt.Sprintf("alle %s", formatDuration(schedule.Interval))
case ScheduleTypeHourly:
return fmt.Sprintf("stündlich zur Minute %02d%s", schedule.Minute, timeZoneSuffix)
case ScheduleTypeDaily:
return fmt.Sprintf("täglich um %02d:%02d Uhr%s", schedule.Hour, schedule.Minute, timeZoneSuffix)
case ScheduleTypeWeekly:
return fmt.Sprintf("%s um %02d:%02d Uhr%s",
formatWeekdays(schedule.Weekdays), schedule.Hour, schedule.Minute, timeZoneSuffix)
case ScheduleTypeMonthly:
return fmt.Sprintf("%s um %02d:%02d Uhr%s",
formatMonthDays(schedule.MonthDays), schedule.Hour, schedule.Minute, timeZoneSuffix)
case ScheduleTypeCron:
return fmt.Sprintf("nach Cron-Ausdruck %q%s", schedule.CronExpression, timeZoneSuffix)
default:
return "unbekannter Zeitplan"
}
}
// germanWeekdayNames sind die deutschen Wochentagsnamen.
var germanWeekdayNames = map[time.Weekday]string{
time.Monday: "montags",
time.Tuesday: "dienstags",
time.Wednesday: "mittwochs",
time.Thursday: "donnerstags",
time.Friday: "freitags",
time.Saturday: "samstags",
time.Sunday: "sonntags",
}
// formatWeekdays beschreibt eine Wochentagsliste.
func formatWeekdays(weekdays []time.Weekday) string {
dayNames := make([]string, 0, len(weekdays))
for _, weekday := range weekdays {
dayNames = append(dayNames, germanWeekdayNames[weekday])
}
return strings.Join(dayNames, ", ")
}
// formatMonthDays beschreibt eine Monatstagsliste.
func formatMonthDays(monthDays []int) string {
dayNames := make([]string, 0, len(monthDays))
for _, monthDay := range monthDays {
if monthDay == lastDayOfMonth {
dayNames = append(dayNames, "am letzten Tag des Monats")
continue
}
dayNames = append(dayNames, fmt.Sprintf("am %d.", monthDay))
}
return strings.Join(dayNames, " und ")
}
// formatDuration gibt eine Dauer lesbar aus.
func formatDuration(duration time.Duration) string {
switch {
case duration >= 24*time.Hour && duration%(24*time.Hour) == 0:
return strconv.Itoa(int(duration/(24*time.Hour))) + " Tage"
case duration >= time.Hour && duration%time.Hour == 0:
return strconv.Itoa(int(duration/time.Hour)) + " Stunden"
case duration >= time.Minute:
return strconv.Itoa(int(duration/time.Minute)) + " Minuten"
default:
return duration.String()
}
}