From 093882dc7144903e531d91dbf5647336ab035647 Mon Sep 17 00:00:00 2001 From: Guillaume Lours Date: Thu, 5 Nov 2026 19:49:28 +0100 Subject: [PATCH] daemon/internal/jobs: Add a cron expression parser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the five-field cron grammar of schedule triggers: numbers, lists, ranges and steps. Names, shortcuts and Quartz extensions are left to clients. Day-of-month and day-of-week combine following Vixie's rule. Occurrences follow the wall clock of the job's timezone: a time skipped by a DST transition does not fire that day, and a repeated one fires once. The search is bounded by the 400-year Gregorian cycle, so impossible dates never fire while sparse ones such as February 29 on a Sunday still do. Zone data is embedded, so timezones resolve on hosts without a zoneinfo database, such as Windows. Co-authored-by: Paweł Gronowski Signed-off-by: Guillaume Lours Signed-off-by: Paweł Gronowski --- daemon/internal/jobs/cron.go | 244 ++++++++++++++++++++++++++++++ daemon/internal/jobs/cron_test.go | 225 +++++++++++++++++++++++++++ 2 files changed, 469 insertions(+) create mode 100644 daemon/internal/jobs/cron.go create mode 100644 daemon/internal/jobs/cron_test.go diff --git a/daemon/internal/jobs/cron.go b/daemon/internal/jobs/cron.go new file mode 100644 index 0000000000..6556c07962 --- /dev/null +++ b/daemon/internal/jobs/cron.go @@ -0,0 +1,244 @@ +package jobs + +import ( + "errors" + "fmt" + "strconv" + "strings" + "time" + + // Schedules name IANA timezones and must evaluate the same on every + // host, including Windows and minimal images without zoneinfo. The + // system database still takes precedence where it exists. + _ "time/tzdata" +) + +// cronSchedule is a parsed five-field crontab expression. Each set is a +// bitmask of the accepted values for its field. +type cronSchedule struct { + minutes uint64 // 0-59 + hours uint64 // 0-23 + dom uint64 // 1-31, day of month + months uint64 // 1-12 + dow uint64 // 0-6, day of week, Sunday is 0 + + // The day-of-month and day-of-week fields follow the standard crontab + // quirk: when both are restricted (neither is *), a day matching either + // fires; otherwise the restricted one must match. + domRestricted, dowRestricted bool +} + +// nextCronBoundYears caps the search for the next occurrence. The Gregorian +// calendar, weekdays included, repeats every 400 years, so an expression +// with no occurrence in that span never fires, as with impossible dates +// such as "0 0 30 2 *"; sparse ones such as "0 0 29 2 */7" (February 29 on +// a Sunday) still find occurrences decades apart. The scan jumps over +// unmatched months and days, so even a full-span search stays in the +// milliseconds. +const nextCronBoundYears = 400 + +// parseCron parses a five-field crontab expression: minute, hour, +// day-of-month, month, day-of-week. Each field accepts numbers, lists +// (a,b,c), ranges (a-b) and steps (*/n or a-b/n). +// +// The wire format is deliberately strict: month and weekday names, @daily +// style shortcuts, and Quartz extensions (L, #, W) are rejected. Clients +// expand shortcuts before submitting, so the daemon-side grammar can stay +// small and unambiguous. +func parseCron(expr string) (*cronSchedule, error) { + fields := strings.Fields(expr) + if len(fields) != 5 { + return nil, fmt.Errorf("cron expression must have 5 fields (minute hour day-of-month month day-of-week), got %d", len(fields)) + } + var ( + schedule cronSchedule + err error + ) + if schedule.minutes, err = parseCronField(fields[0], 0, 59); err != nil { + return nil, fmt.Errorf("minute field: %w", err) + } + if schedule.hours, err = parseCronField(fields[1], 0, 23); err != nil { + return nil, fmt.Errorf("hour field: %w", err) + } + if schedule.dom, err = parseCronField(fields[2], 1, 31); err != nil { + return nil, fmt.Errorf("day-of-month field: %w", err) + } + if schedule.months, err = parseCronField(fields[3], 1, 12); err != nil { + return nil, fmt.Errorf("month field: %w", err) + } + if schedule.dow, err = parseCronField(fields[4], 0, 7); err != nil { + return nil, fmt.Errorf("day-of-week field: %w", err) + } + // Both 0 and 7 mean Sunday; fold 7 onto 0 so matching only checks 0-6. + if schedule.dow&(1<<7) != 0 { + schedule.dow = (schedule.dow &^ (1 << 7)) | 1 + } + // Vixie's first-character rule: a field is "restricted" only when it + // does not start with *, so */2 counts as unrestricted for the AND/OR + // day-combination choice while its mask still applies. + schedule.domRestricted = !strings.HasPrefix(fields[2], "*") + schedule.dowRestricted = !strings.HasPrefix(fields[4], "*") + return &schedule, nil +} + +// parseCronField parses one field into a bitmask of accepted values. +func parseCronField(field string, minValue, maxValue int) (uint64, error) { + var mask uint64 + for part := range strings.SplitSeq(field, ",") { + rangeExpr, stepExpr, hasStep := strings.Cut(part, "/") + step := 1 + if hasStep { + // Steps only make sense over a span; a step on a single value + // ("3/5") is a Vixie laxity this grammar deliberately rejects. + if rangeExpr != "*" && !strings.Contains(rangeExpr, "-") { + return 0, fmt.Errorf("invalid term %q: a step requires * or a range", part) + } + // Atoi tolerates a leading sign; the grammar is digits only. + if stepExpr == "" || stepExpr[0] < '0' || stepExpr[0] > '9' { + return 0, fmt.Errorf("invalid step in term %q", part) + } + parsed, err := strconv.Atoi(stepExpr) + if err != nil || parsed <= 0 { + return 0, fmt.Errorf("invalid step in term %q", part) + } + step = parsed + } + + lo, hi := minValue, maxValue + if rangeExpr != "*" { + loExpr, hiExpr, isRange := strings.Cut(rangeExpr, "-") + var err error + if lo, err = parseCronValue(loExpr, minValue, maxValue); err != nil { + return 0, fmt.Errorf("invalid term %q: %w", part, err) + } + hi = lo + if isRange { + if hi, err = parseCronValue(hiExpr, minValue, maxValue); err != nil { + return 0, fmt.Errorf("invalid term %q: %w", part, err) + } + if hi < lo { + return 0, fmt.Errorf("invalid term %q: range is reversed", part) + } + } + } + for v := lo; v <= hi; v += step { + mask |= 1 << v + // Check the remaining span before adding an untrusted step, + // which may be large enough to overflow an int. + if step > hi-v { + break + } + } + } + if mask == 0 { + return 0, errors.New("field is empty") + } + return mask, nil +} + +// parseCronValue parses one numeric field value and checks its bounds. +func parseCronValue(expr string, minValue, maxValue int) (int, error) { + // Atoi tolerates a leading sign; the grammar is digits only. + if expr == "" || expr[0] < '0' || expr[0] > '9' { + return 0, fmt.Errorf("%q is not a number (names and shortcuts are not supported)", expr) + } + value, err := strconv.Atoi(expr) + if err != nil { + return 0, fmt.Errorf("%q is not a number (names and shortcuts are not supported)", expr) + } + if value < minValue || value > maxValue { + return 0, fmt.Errorf("value %d is outside %d-%d", value, minValue, maxValue) + } + return value, nil +} + +// next returns the first occurrence strictly after the given time, evaluated +// on the wall clock of loc, and false when no occurrence exists within the +// search bound. +// +// Daylight-saving transitions follow wall-clock semantics: an occurrence +// falling in the skipped hour of a spring-forward day does not fire that day +// (the wall-clock time never happens), and an occurrence in the repeated +// hour of a fall-back day fires once, at the first occurrence. +func (s *cronSchedule) next(after time.Time, loc *time.Location) (time.Time, bool) { + t := after.In(loc).Truncate(time.Minute).Add(time.Minute) + + bound := after.AddDate(nextCronBoundYears, 0, 0) + for t.Before(bound) { + if s.months&(1<= setBack { + return time.Time{}, false + } + + return start.Add(setBack), true +} + +// dayMatches applies Vixie cron's day-combination rule: both masks always +// apply, combined with OR only when both fields are restricted ("1,15 * Sun" +// means the 1st, the 15th AND Sundays) and with AND otherwise (an +// unrestricted field's mask is full, so it never excludes anything). +func (s *cronSchedule) dayMatches(t time.Time) bool { + domOK := s.dom&(1<