diff --git a/coderd/autostart/schedule/crontab_test.go b/coderd/autostart/schedule/crontab_test.go new file mode 100644 index 0000000000000..63383f38e6338 --- /dev/null +++ b/coderd/autostart/schedule/crontab_test.go @@ -0,0 +1,67 @@ +package schedule_test + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/coderd/autostart/schedule" +) + +func Test_Weekly(t *testing.T) { + t.Parallel() + testCases := []struct { + name string + spec string + at time.Time + expectedNext time.Time + expectedError string + }{ + { + name: "with timezone", + spec: "CRON_TZ=US/Central 30 9 1-5", + at: time.Date(2022, 4, 1, 14, 29, 0, 0, time.UTC), + expectedNext: time.Date(2022, 4, 1, 14, 30, 0, 0, time.UTC), + expectedError: "", + }, + { + name: "without timezone", + spec: "30 9 1-5", + at: time.Date(2022, 4, 1, 9, 29, 0, 0, time.Local), + expectedNext: time.Date(2022, 4, 1, 9, 30, 0, 0, time.Local), + expectedError: "", + }, + { + name: "invalid schedule", + spec: "asdfasdfasdfsd", + at: time.Time{}, + expectedNext: time.Time{}, + expectedError: "parse schedule: expected exactly 3 fields, found 1: [asdfasdfasdfsd]", + }, + { + name: "invalid location", + spec: "CRON_TZ=Fictional/Country 30 9 1-5", + at: time.Time{}, + expectedNext: time.Time{}, + expectedError: "parse schedule: provided bad location Fictional/Country: unknown time zone Fictional/Country", + }, + } + + for _, testCase := range testCases { + testCase := testCase + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + actual, err := schedule.Weekly(testCase.spec) + if testCase.expectedError == "" { + nextTime := actual.Next(testCase.at) + require.NoError(t, err) + require.Equal(t, testCase.expectedNext, nextTime) + require.Equal(t, testCase.spec, actual.String()) + } else { + require.EqualError(t, err, testCase.expectedError) + require.Nil(t, actual) + } + }) + } +} diff --git a/coderd/autostart/schedule/schedule.go b/coderd/autostart/schedule/schedule.go new file mode 100644 index 0000000000000..11cd8a0030ec6 --- /dev/null +++ b/coderd/autostart/schedule/schedule.go @@ -0,0 +1,66 @@ +// package schedule provides utilities for parsing and deserializing +// cron-style expressions. +package schedule + +import ( + "time" + + "github.com/robfig/cron/v3" + "golang.org/x/xerrors" +) + +// For the purposes of this library, we only need minute, hour, and +// day-of-week. +const parserFormatWeekly = cron.Minute | cron.Hour | cron.Dow + +var defaultParser = cron.NewParser(parserFormatWeekly) + +// Weekly parses a Schedule from spec scoped to a recurring weekly event. +// Spec consists of the following space-delimited fields, in the following order: +// - timezone e.g. CRON_TZ=US/Central (optional) +// - minutes of hour e.g. 30 (required) +// - hour of day e.g. 9 (required) +// - day of week e.g. 1 (required) +// +// Example Usage: +// local_sched, _ := schedule.Weekly("59 23 *") +// fmt.Println(sched.Next(time.Now().Format(time.RFC3339))) +// // Output: 2022-04-04T23:59:00Z +// us_sched, _ := schedule.Weekly("CRON_TZ=US/Central 30 9 1-5") +// fmt.Println(sched.Next(time.Now()).Format(time.RFC3339)) +// // Output: 2022-04-04T14:30:00Z +func Weekly(spec string) (*Schedule, error) { + specSched, err := defaultParser.Parse(spec) + if err != nil { + return nil, xerrors.Errorf("parse schedule: %w", err) + } + + schedule, ok := specSched.(*cron.SpecSchedule) + if !ok { + return nil, xerrors.Errorf("expected *cron.SpecSchedule but got %T", specSched) + } + + cronSched := &Schedule{ + sched: schedule, + spec: spec, + } + return cronSched, nil +} + +// Schedule represents a cron schedule. +// It's essentially a thin wrapper for robfig/cron/v3 that implements Stringer. +type Schedule struct { + sched *cron.SpecSchedule + // XXX: there isn't any nice way for robfig/cron to serialize + spec string +} + +// String serializes the schedule to its original human-friendly format. +func (s Schedule) String() string { + return s.spec +} + +// Next returns the next time in the schedule relative to t. +func (s Schedule) Next(t time.Time) time.Time { + return s.sched.Next(t) +} diff --git a/go.mod b/go.mod index 231f3612cac52..aa919e45aa16e 100644 --- a/go.mod +++ b/go.mod @@ -222,6 +222,7 @@ require ( github.com/prometheus/procfs v0.7.3 // indirect github.com/rivo/tview v0.0.0-20200712113419-c65badfc3d92 // indirect github.com/rivo/uniseg v0.2.0 // indirect + github.com/robfig/cron/v3 v3.0.1 github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/sirupsen/logrus v1.8.1 // indirect github.com/spf13/afero v1.8.1 // indirect diff --git a/go.sum b/go.sum index ef7dc0b6fee6b..0d4abe9d7a2e0 100644 --- a/go.sum +++ b/go.sum @@ -1501,6 +1501,8 @@ github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qq github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rogpeppe/go-internal v1.1.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=