A schedule is now a line in /etc/cron.d/hamlab-zfs, which Ham Lab owns and rewrites whole whenever the schedules change; the line calls `hamlab zfs-snapshot <id>` as the account Ham Lab runs as, reaching zfs through sudo exactly as the daemon does. The daemon's own ticker is gone. Snapshots are backup-shaped and should not stop because a web console is down, being upgraded, or uninstalled — and a cron line is greppable by anyone looking at the machine the normal way, which a schedule hidden inside a daemon is not. A cron expression is now the single description of when a schedule fires: the four common shapes render to one, a custom schedule states it outright, and the next run is read back with the same evaluator the Jobs tab uses on everyone else's lines. So the page, the file and the prediction cannot disagree. Schedules gain a custom cron expression and an optional snapshot name prefix. What a run did moves out of the policy into zfs-runs/<id>.json beside the config, because the cron job is a separate process and must never write state.json behind the daemon's back. The detected external job can be adopted: its schedule, retention and dataset are read out of the cron line into a new Ham Lab schedule, ready to check rather than retype. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
175 lines
4.4 KiB
Go
175 lines
4.4 KiB
Go
package cron
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// schedule is a parsed five-field cron expression as sets.
|
|
type schedule struct {
|
|
min, hour, dom, mon, dow [64]bool
|
|
domStar, dowStar bool // "*" in dom/dow changes the match rule
|
|
reboot bool
|
|
}
|
|
|
|
var monthNames = 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}
|
|
var dayNames = map[string]int{"sun": 0, "mon": 1, "tue": 2, "wed": 3, "thu": 4, "fri": 5, "sat": 6}
|
|
|
|
var aliases = 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 * * * *",
|
|
}
|
|
|
|
func parseSchedule(expr string) (*schedule, error) {
|
|
expr = strings.TrimSpace(expr)
|
|
if expr == "@reboot" {
|
|
return &schedule{reboot: true}, nil
|
|
}
|
|
if a, ok := aliases[strings.ToLower(expr)]; ok {
|
|
expr = a
|
|
}
|
|
f := strings.Fields(expr)
|
|
if len(f) != 5 {
|
|
return nil, errors.New("expected 5 fields")
|
|
}
|
|
s := &schedule{}
|
|
var err error
|
|
if s.min, err = parseField(f[0], 0, 59, nil); err != nil {
|
|
return nil, fmt.Errorf("minute: %w", err)
|
|
}
|
|
if s.hour, err = parseField(f[1], 0, 23, nil); err != nil {
|
|
return nil, fmt.Errorf("hour: %w", err)
|
|
}
|
|
if s.dom, err = parseField(f[2], 1, 31, nil); err != nil {
|
|
return nil, fmt.Errorf("day of month: %w", err)
|
|
}
|
|
if s.mon, err = parseField(f[3], 1, 12, monthNames); err != nil {
|
|
return nil, fmt.Errorf("month: %w", err)
|
|
}
|
|
if s.dow, err = parseField(f[4], 0, 7, dayNames); err != nil {
|
|
return nil, fmt.Errorf("day of week: %w", err)
|
|
}
|
|
if s.dow[7] { // 7 == Sunday
|
|
s.dow[0] = true
|
|
}
|
|
s.domStar = f[2] == "*"
|
|
s.dowStar = f[4] == "*"
|
|
return s, nil
|
|
}
|
|
|
|
func parseField(field string, lo, hi int, names map[string]int) ([64]bool, error) {
|
|
var set [64]bool
|
|
atom := func(s string) (int, error) {
|
|
if names != nil {
|
|
if v, ok := names[strings.ToLower(s)]; ok {
|
|
return v, nil
|
|
}
|
|
}
|
|
v, err := strconv.Atoi(s)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("bad value %q", s)
|
|
}
|
|
return v, nil
|
|
}
|
|
for _, part := range strings.Split(field, ",") {
|
|
step := 1
|
|
if i := strings.Index(part, "/"); i >= 0 {
|
|
v, err := strconv.Atoi(part[i+1:])
|
|
if err != nil || v <= 0 {
|
|
return set, fmt.Errorf("bad step in %q", part)
|
|
}
|
|
step, part = v, part[:i]
|
|
}
|
|
a, b := lo, hi
|
|
switch {
|
|
case part == "*":
|
|
case strings.Contains(part, "-"):
|
|
x, y, _ := strings.Cut(part, "-")
|
|
var err error
|
|
if a, err = atom(x); err != nil {
|
|
return set, err
|
|
}
|
|
if b, err = atom(y); err != nil {
|
|
return set, err
|
|
}
|
|
default:
|
|
v, err := atom(part)
|
|
if err != nil {
|
|
return set, err
|
|
}
|
|
a = v
|
|
if step == 1 {
|
|
b = v
|
|
}
|
|
}
|
|
if a < lo || b > hi || a > b {
|
|
return set, fmt.Errorf("out of range: %q", part)
|
|
}
|
|
for v := a; v <= b; v += step {
|
|
set[v] = true
|
|
}
|
|
}
|
|
return set, nil
|
|
}
|
|
|
|
func (s *schedule) dayMatches(t time.Time) bool {
|
|
if !s.mon[int(t.Month())] {
|
|
return false
|
|
}
|
|
dom, dow := s.dom[t.Day()], s.dow[int(t.Weekday())]
|
|
switch {
|
|
case s.domStar && s.dowStar:
|
|
return true
|
|
case s.domStar:
|
|
return dow
|
|
case s.dowStar:
|
|
return dom
|
|
default: // both restricted: cron runs when either matches
|
|
return dom || dow
|
|
}
|
|
}
|
|
|
|
// NextRun is the first time strictly after `from` that a cron expression fires. Exported so
|
|
// anything that writes a cron line — the ZFS snapshot schedules — can say when it runs next
|
|
// using the same evaluator that reads everyone else's.
|
|
func NextRun(expr string, from time.Time) (time.Time, bool) { return nextRun(expr, from) }
|
|
|
|
// ValidSchedule reports whether a cron expression parses.
|
|
func ValidSchedule(expr string) error {
|
|
_, err := parseSchedule(expr)
|
|
return err
|
|
}
|
|
|
|
// nextRun finds the first time strictly after `from` that the schedule fires.
|
|
func nextRun(expr string, from time.Time) (time.Time, bool) {
|
|
s, err := parseSchedule(expr)
|
|
if err != nil || s.reboot {
|
|
return time.Time{}, false
|
|
}
|
|
t := from.Truncate(time.Minute).Add(time.Minute)
|
|
day := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
|
|
for i := 0; i < 366*2; i++ {
|
|
if s.dayMatches(day) {
|
|
for h := 0; h < 24; h++ {
|
|
if !s.hour[h] {
|
|
continue
|
|
}
|
|
for mi := 0; mi < 60; mi++ {
|
|
if !s.min[mi] {
|
|
continue
|
|
}
|
|
c := time.Date(day.Year(), day.Month(), day.Day(), h, mi, 0, 0, day.Location())
|
|
if !c.Before(t) {
|
|
return c, true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
day = day.AddDate(0, 0, 1)
|
|
}
|
|
return time.Time{}, false
|
|
}
|