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>
263 lines
8.3 KiB
Go
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)
|
|
}
|