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>
433 lines
15 KiB
Go
433 lines
15 KiB
Go
// 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()
|
||
}
|
||
}
|