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

263 lines
8.3 KiB
Go

package scheduler
import (
"fmt"
"strconv"
"strings"
"time"
)
// cronExpression ist ein ausgewerteter Cron-Ausdruck.
//
// Umgesetzt sind die fünf klassischen Felder: Minute, Stunde, Tag des Monats,
// Monat, Wochentag. Sekunden fehlen ausdrücklich — ein Sicherungsauftrag im
// Sekundentakt ist kein Zeitplan, sondern ein Tippfehler.
//
// Ein eigener Parser statt einer fremden Bibliothek: Cron ist klein, die
// Fallstricke liegen nicht im Zerlegen, sondern in der Behandlung von
// Zeitzonen und der Tag/Wochentag-Sonderregel. Beides muss ohnehin selbst
// gelöst werden.
type cronExpression struct {
// minutes sind die zugelassenen Minuten.
minutes map[int]bool
// hours sind die zugelassenen Stunden.
hours map[int]bool
// monthDays sind die zugelassenen Tage des Monats.
monthDays map[int]bool
// months sind die zugelassenen Monate.
months map[int]bool
// weekdays sind die zugelassenen Wochentage.
weekdays map[int]bool
// monthDayRestricted meldet ein eingeschränktes Tagesfeld.
//
// Nötig für die Sonderregel, die unten erklärt ist.
monthDayRestricted bool
// weekdayRestricted meldet ein eingeschränktes Wochentagsfeld.
weekdayRestricted bool
}
// cronFieldBounds sind die zulässigen Wertebereiche je Feld.
var cronFieldBounds = []struct {
// name benennt das Feld für Fehlermeldungen.
name string
// minimum ist der kleinste zulässige Wert.
minimum int
// maximum ist der größte zulässige Wert.
maximum int
}{
{"minute", 0, 59},
{"stunde", 0, 23},
{"tag des monats", 1, 31},
{"monat", 1, 12},
// Der Wochentag reicht bis 7: Sonntag darf sowohl 0 als auch 7 sein. Beide
// Schreibweisen sind gebräuchlich, und wer die zweite ablehnt, lehnt einen
// gültigen Cron-Ausdruck ab.
{"wochentag", 0, 7},
}
// cronNamedValues erlaubt Namen statt Zahlen in Monats- und Wochentagsfeldern.
var cronNamedValues = map[string]int{
"jan": 1, "feb": 2, "mar": 3, "apr": 4, "may": 5, "jun": 6,
"jul": 7, "aug": 8, "sep": 9, "oct": 10, "nov": 11, "dec": 12,
"sun": 0, "mon": 1, "tue": 2, "wed": 3, "thu": 4, "fri": 5, "sat": 6,
}
// cronPredefinedExpressions sind die gebräuchlichen Kurzformen.
var cronPredefinedExpressions = map[string]string{
"@yearly": "0 0 1 1 *",
"@annually": "0 0 1 1 *",
"@monthly": "0 0 1 * *",
"@weekly": "0 0 * * 0",
"@daily": "0 0 * * *",
"@midnight": "0 0 * * *",
"@hourly": "0 * * * *",
}
// parseCronExpression zerlegt einen Cron-Ausdruck.
func parseCronExpression(expressionText string) (*cronExpression, error) {
trimmedExpression := strings.TrimSpace(expressionText)
if trimmedExpression == "" {
return nil, fmt.Errorf("%w: der cron-ausdruck ist leer", ErrInvalidSchedule)
}
if replacement, isPredefined := cronPredefinedExpressions[strings.ToLower(trimmedExpression)]; isPredefined {
trimmedExpression = replacement
}
expressionFields := strings.Fields(trimmedExpression)
if len(expressionFields) != len(cronFieldBounds) {
return nil, fmt.Errorf("%w: ein cron-ausdruck braucht %d felder (minute stunde tag monat wochentag), angegeben waren %d",
ErrInvalidSchedule, len(cronFieldBounds), len(expressionFields))
}
parsedFields := make([]map[int]bool, len(expressionFields))
for fieldIndex, fieldText := range expressionFields {
fieldBounds := cronFieldBounds[fieldIndex]
allowedValues, fieldError := parseCronField(fieldText, fieldBounds.minimum, fieldBounds.maximum)
if fieldError != nil {
return nil, fmt.Errorf("%w: das feld %q (%s) ist unbrauchbar: %v",
ErrInvalidSchedule, fieldText, fieldBounds.name, fieldError)
}
parsedFields[fieldIndex] = allowedValues
}
return &cronExpression{
minutes: parsedFields[0],
hours: parsedFields[1],
monthDays: parsedFields[2],
months: parsedFields[3],
weekdays: parsedFields[4],
monthDayRestricted: expressionFields[2] != "*",
weekdayRestricted: expressionFields[4] != "*",
}, nil
}
// parseCronField wertet ein einzelnes Feld aus.
func parseCronField(fieldText string, minimumValue int, maximumValue int) (map[int]bool, error) {
allowedValues := make(map[int]bool)
for _, listPart := range strings.Split(fieldText, ",") {
if listPart == "" {
return nil, fmt.Errorf("leerer listeneintrag")
}
stepWidth := 1
rangePart := listPart
// Schrittweite: "*/15" oder "0-30/5"
if slashIndex := strings.Index(listPart, "/"); slashIndex >= 0 {
rangePart = listPart[:slashIndex]
parsedStep, stepError := strconv.Atoi(listPart[slashIndex+1:])
if stepError != nil || parsedStep <= 0 {
return nil, fmt.Errorf("die schrittweite %q ist keine positive zahl", listPart[slashIndex+1:])
}
stepWidth = parsedStep
}
rangeStart := minimumValue
rangeEnd := maximumValue
if rangePart != "*" {
startText, endText, isRange := strings.Cut(rangePart, "-")
parsedStart, startError := parseCronValue(startText)
if startError != nil {
return nil, startError
}
rangeStart = parsedStart
if isRange {
parsedEnd, endError := parseCronValue(endText)
if endError != nil {
return nil, endError
}
rangeEnd = parsedEnd
} else if stepWidth == 1 {
// Ein einzelner Wert ohne Schrittweite meint genau ihn.
rangeEnd = parsedStart
}
}
if rangeStart < minimumValue || rangeEnd > maximumValue || rangeStart > rangeEnd {
return nil, fmt.Errorf("der bereich %d-%d liegt außerhalb von %d-%d",
rangeStart, rangeEnd, minimumValue, maximumValue)
}
for currentValue := rangeStart; currentValue <= rangeEnd; currentValue += stepWidth {
allowedValues[currentValue] = true
}
}
if len(allowedValues) == 0 {
return nil, fmt.Errorf("das feld ergibt keinen zulässigen wert")
}
return allowedValues, nil
}
// parseCronValue liest eine Zahl oder einen Namen.
func parseCronValue(valueText string) (int, error) {
trimmedValue := strings.TrimSpace(valueText)
if namedValue, isNamed := cronNamedValues[strings.ToLower(trimmedValue)]; isNamed {
return namedValue, nil
}
parsedValue, parseError := strconv.Atoi(trimmedValue)
if parseError != nil {
return 0, fmt.Errorf("%q ist weder eine zahl noch ein bekannter name", trimmedValue)
}
return parsedValue, nil
}
// matches prüft, ob ein Zeitpunkt zum Ausdruck passt.
//
// Die Tag/Wochentag-Sonderregel ist der eine Punkt, an dem Cron nicht der
// Anschauung folgt: Sind **beide** Felder eingeschränkt, gilt ODER statt UND.
// „0 0 13 * 5" bedeutet „am 13. **oder** freitags", nicht „an Freitagen, die
// der 13. sind". Wer das als UND umsetzt, baut einen Zeitplan, der fast nie
// läuft — und es fällt erst nach Monaten auf.
func (expression *cronExpression) matches(candidateTime time.Time) bool {
if !expression.minutes[candidateTime.Minute()] {
return false
}
if !expression.hours[candidateTime.Hour()] {
return false
}
if !expression.months[int(candidateTime.Month())] {
return false
}
weekdayNumber := int(candidateTime.Weekday())
// Sonntag ist 0 und darf zusätzlich als 7 geschrieben sein.
weekdayMatches := expression.weekdays[weekdayNumber] ||
(weekdayNumber == 0 && expression.weekdays[7])
monthDayMatches := expression.monthDays[candidateTime.Day()]
if expression.monthDayRestricted && expression.weekdayRestricted {
return monthDayMatches || weekdayMatches
}
return monthDayMatches && weekdayMatches
}
// nextCronRun sucht den nächsten passenden Zeitpunkt.
//
// Die Suche geht minutenweise. Bei einem Ausdruck wie „am 29. Februar" sind das
// bis zu vier Jahre — deshalb die Obergrenze, die einen verständlichen Fehler
// statt einer hängenden Schleife ergibt.
func (schedule *Schedule) nextCronRun(localReference time.Time) (time.Time, error) {
parsedExpression, parseError := parseCronExpression(schedule.CronExpression)
if parseError != nil {
return time.Time{}, parseError
}
// Sekunden und Nanosekunden fallen weg; die Suche beginnt in der nächsten
// vollen Minute, damit der Zeitpunkt echt später liegt.
candidateTime := localReference.Truncate(time.Minute).Add(time.Minute)
const maximumSearchMinutes = maximumSearchDays * 24 * 60
for searchedMinutes := 0; searchedMinutes < maximumSearchMinutes; searchedMinutes++ {
if parsedExpression.matches(candidateTime) {
return candidateTime, nil
}
candidateTime = candidateTime.Add(time.Minute)
}
return time.Time{}, fmt.Errorf("%w: der ausdruck %q ergibt in den nächsten %d jahren keinen zeitpunkt",
ErrNoNextRun, schedule.CronExpression, maximumSearchDays/366)
}