Skip to content

Commit d740154

Browse files
committed
feat: Add cron format -at flag
Mutually exclusive with `-every`, the `-at` flag receives a cron 5-field date time expression to periodically execute the command at date time periods.
1 parent 8c8b5f2 commit d740154

4 files changed

Lines changed: 434 additions & 12 deletions

File tree

README.md

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,13 @@ in one process per container environments.
8484
-every 2h24m -- \
8585
/script/git-maint
8686

87+
### Database Backup
88+
89+
# Run the backup script every day at 2 a.m and 2 p.m.
90+
runitor -slug db-backup \
91+
-at "00 02,14 * * *" -- \
92+
/script/db-backup
93+
8794
### Backup
8895

8996
# Do not attach output to ping.
@@ -93,8 +100,8 @@ in one process per container environments.
93100

94101
### Triggering an Immediate Run in Periodic Mode
95102

96-
When invoked with `-every <duration>` flag, runitor will also act as a basic
97-
task scheduler.
103+
When invoked with `-every <duration>` or `-at <cron>` flag, runitor will also
104+
act as a basic task scheduler.
98105

99106
Sometimes you may not want to restart the process or the container just to force
100107
an immediate run. Instead, you can send SIGALRM to runitor to get it run the
@@ -114,6 +121,8 @@ command right away and reset the interval.
114121
Client timeout per request (default 5s)
115122
-api-url string
116123
API URL (env: $HC_API_URL) (default "https://hc-ping.com")
124+
-at string
125+
Cron expression to run command at specified time (e.g. "0 */3 * * *")
117126
-create
118127
Create a new check if passed slug is not found in the project
119128
-every duration

cmd/runitor/main.go

Lines changed: 48 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,7 @@ func main() {
154154
create := flag.Bool("create", false, "Create a new check if passed slug is not found in the project")
155155
uuid := flag.String("uuid", "", "UUID of check (env: $CHECK_UUID). Use 'file:' prefix for indirection")
156156
every := flag.Duration("every", 0, "If non-zero, periodically run command at specified interval")
157+
at := flag.String("at", "", "Cron expression to run command at specified time (e.g. \"*/5 * * * *\")")
157158
quiet := flag.Bool("quiet", false, "Don't capture command's stdout")
158159
silent := flag.Bool("silent", false, "Don't capture command's stdout or stderr")
159160
onSuccess := pingTypeFlag("on-success", PingTypeSuccess, "Ping type to send when command exits successfully")
@@ -184,6 +185,10 @@ func main() {
184185
os.Exit(0)
185186
}
186187

188+
if *every != 0 && *at != "" {
189+
log.Fatal("-every and -at flags are mutually exclusive")
190+
}
191+
187192
ch := &handleParams{
188193
uuid: FromFlagOrEnv(*uuid, []string{"CHECK_UUID"}),
189194
slug: FromFlagOrEnv(*slug, []string{"CHECK_SLUG"}),
@@ -265,23 +270,56 @@ func main() {
265270
exitCode := task()
266271

267272
// One-shot mode. Exit with command's exit code.
268-
if *every == 0 {
273+
if *every == 0 && *at == "" {
269274
os.Exit(exitCode)
270275
}
271276

272-
// Task scheduler mode. Run the command periodically at specified interval.
273-
ticker := time.NewTicker(*every)
274277
runNow := make(chan os.Signal, 1)
275278
signal.Notify(runNow, syscall.SIGALRM)
276279

277-
for {
278-
select {
279-
case <-ticker.C:
280-
task()
280+
// Task scheduler mode. Run the command periodically.
281+
if *every != 0 {
282+
ticker := time.NewTicker(*every)
283+
for {
284+
select {
285+
case <-ticker.C:
286+
task()
287+
288+
case <-runNow:
289+
ticker.Reset(*every)
290+
task()
291+
}
292+
}
293+
}
281294

282-
case <-runNow:
283-
ticker.Reset(*every)
284-
task()
295+
if *at != "" {
296+
schedule, err := ParseCron(*at)
297+
if err != nil {
298+
log.Fatalf("invalid cron expression: %v", err)
299+
}
300+
301+
for {
302+
now := time.Now()
303+
next := schedule.Next(now)
304+
if next.IsZero() {
305+
log.Fatal("no next execution time found")
306+
}
307+
308+
// We sleep until the next scheduled time.
309+
timer := time.NewTimer(time.Until(next))
310+
311+
select {
312+
case <-timer.C:
313+
task()
314+
case <-runNow:
315+
if !timer.Stop() {
316+
select {
317+
case <-timer.C:
318+
default:
319+
}
320+
}
321+
task()
322+
}
285323
}
286324
}
287325
}

internal/cron.go

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
// Copyright (c) Berk D. Demir and the runitor contributors.
2+
// SPDX-License-Identifier: 0BSD
3+
package internal
4+
5+
import (
6+
"errors"
7+
"fmt"
8+
"strconv"
9+
"strings"
10+
"time"
11+
)
12+
13+
var (
14+
ErrCronFieldCount = errors.New("expected 5 fields")
15+
ErrCronInvalidStep = errors.New("invalid step")
16+
ErrCronPositiveStep = errors.New("step must be positive")
17+
ErrCronRangeStart = errors.New("invalid range start")
18+
ErrCronRangeEnd = errors.New("invalid range end")
19+
ErrCronInvalidValue = errors.New("invalid value")
20+
ErrCronOutOfRange = errors.New("value out of range")
21+
ErrCronRangeOrder = errors.New("range start > end")
22+
)
23+
24+
// Cron represents a parsed cron schedule.
25+
type Cron struct {
26+
minutes [60]bool
27+
hours [24]bool
28+
dom [32]bool // 1-31
29+
months [13]bool // 1-12
30+
dow [8]bool // 0-7 (7 is Sunday, aliased to 0)
31+
domAll bool
32+
dowAll bool
33+
}
34+
35+
// ParseCron parses a standard 5-field cron string.
36+
// Supported features:
37+
// - lists (1,2,3)
38+
// - ranges (1-5)
39+
// - steps (*/5, 1-10/2)
40+
// - * (all)
41+
// - day of week: 0-7 (Sunday=0 or 7)
42+
func ParseCron(s string) (*Cron, error) {
43+
fields := strings.Fields(s)
44+
if len(fields) != 5 {
45+
return nil, fmt.Errorf("%w, got %d", ErrCronFieldCount, len(fields))
46+
}
47+
48+
c := &Cron{}
49+
var err error
50+
51+
if _, err = parseField(fields[0], 0, 59, c.minutes[:]); err != nil {
52+
return nil, fmt.Errorf("parsing minutes: %w", err)
53+
}
54+
if _, err = parseField(fields[1], 0, 23, c.hours[:]); err != nil {
55+
return nil, fmt.Errorf("parsing hours: %w", err)
56+
}
57+
if c.domAll, err = parseField(fields[2], 1, 31, c.dom[:]); err != nil {
58+
return nil, fmt.Errorf("parsing dom: %w", err)
59+
}
60+
if _, err = parseField(fields[3], 1, 12, c.months[:]); err != nil {
61+
return nil, fmt.Errorf("parsing months: %w", err)
62+
}
63+
// Allow 0-7 for Day of Week
64+
if c.dowAll, err = parseField(fields[4], 0, 7, c.dow[:]); err != nil {
65+
return nil, fmt.Errorf("parsing dow: %w", err)
66+
}
67+
68+
// Handle 7 as Sunday (alias to 0)
69+
if c.dow[7] {
70+
c.dow[0] = true
71+
}
72+
73+
return c, nil
74+
}
75+
76+
// parseField parses a cron field and returns true if the field was literally "*".
77+
func parseField(s string, min, max int, dest []bool) (bool, error) {
78+
// If field is "*", set all to true.
79+
if s == "*" {
80+
for i := min; i <= max; i++ {
81+
dest[i] = true
82+
}
83+
return true, nil
84+
}
85+
86+
parts := strings.Split(s, ",")
87+
for _, part := range parts {
88+
step := 1
89+
rangeStr := part
90+
91+
if i := strings.Index(part, "/"); i >= 0 {
92+
stepStr := part[i+1:]
93+
var err error
94+
step, err = strconv.Atoi(stepStr)
95+
if err != nil {
96+
return false, fmt.Errorf("%w %q: %w", ErrCronInvalidStep, stepStr, err)
97+
}
98+
if step <= 0 {
99+
return false, ErrCronPositiveStep
100+
}
101+
rangeStr = part[:i]
102+
}
103+
104+
var start, end int
105+
var err error
106+
107+
if rangeStr == "*" {
108+
start, end = min, max
109+
} else if i := strings.Index(rangeStr, "-"); i >= 0 {
110+
startStr := rangeStr[:i]
111+
endStr := rangeStr[i+1:]
112+
start, err = strconv.Atoi(startStr)
113+
if err != nil {
114+
return false, fmt.Errorf("%w %q: %w", ErrCronRangeStart, startStr, err)
115+
}
116+
end, err = strconv.Atoi(endStr)
117+
if err != nil {
118+
return false, fmt.Errorf("%w %q: %w", ErrCronRangeEnd, endStr, err)
119+
}
120+
} else {
121+
start, err = strconv.Atoi(rangeStr)
122+
if err != nil {
123+
return false, fmt.Errorf("%w %q: %w", ErrCronInvalidValue, rangeStr, err)
124+
}
125+
end = start
126+
}
127+
128+
if start < min || end > max {
129+
return false, fmt.Errorf("%w [%d, %d]", ErrCronOutOfRange, min, max)
130+
}
131+
if start > end {
132+
return false, ErrCronRangeOrder
133+
}
134+
135+
for i := start; i <= end; i += step {
136+
dest[i] = true
137+
}
138+
}
139+
return false, nil
140+
}
141+
142+
// Next returns the next scheduled time after t.
143+
// It assumes t is in the desired location (timezone).
144+
func (c *Cron) Next(t time.Time) time.Time {
145+
// Start checking from the next minute
146+
next := t.Truncate(time.Minute).Add(time.Minute)
147+
148+
// To prevent infinite loops (though unlikely with valid cron), limit search to a few years.
149+
// 5 years seems safe.
150+
limit := next.AddDate(5, 0, 0)
151+
152+
for next.Before(limit) {
153+
// Month check
154+
month := int(next.Month())
155+
if !c.months[month] {
156+
// Move to start of next month
157+
// Simply adding 1 to month logic handles year rollover
158+
next = time.Date(next.Year(), next.Month()+1, 1, 0, 0, 0, 0, next.Location())
159+
continue
160+
}
161+
162+
// Day check
163+
dom := next.Day()
164+
dow := int(next.Weekday())
165+
166+
// Logic:
167+
// If both DOM and DOW are restricted (not *), then match if EITHER matches.
168+
// If only one is restricted, match that one (the other is *).
169+
// If both are *, match everything (AND/OR doesn't matter).
170+
isDomRestricted := !c.domAll
171+
isDowRestricted := !c.dowAll
172+
173+
matchDom := c.dom[dom]
174+
matchDow := c.dow[dow]
175+
176+
matchDay := false
177+
if isDomRestricted && isDowRestricted {
178+
matchDay = matchDom || matchDow
179+
} else {
180+
matchDay = matchDom && matchDow
181+
}
182+
183+
if !matchDay {
184+
// Advance day
185+
next = time.Date(next.Year(), next.Month(), next.Day()+1, 0, 0, 0, 0, next.Location())
186+
continue
187+
}
188+
189+
// Hour check
190+
hour := next.Hour()
191+
if !c.hours[hour] {
192+
next = next.Add(time.Hour)
193+
// Reset minute
194+
next = time.Date(next.Year(), next.Month(), next.Day(), next.Hour(), 0, 0, 0, next.Location())
195+
continue
196+
}
197+
198+
// Minute check
199+
minute := next.Minute()
200+
if !c.minutes[minute] {
201+
next = next.Add(time.Minute)
202+
continue
203+
}
204+
205+
return next
206+
}
207+
return time.Time{} // Should not happen
208+
}
209+

0 commit comments

Comments
 (0)