diff --git a/main.go b/main.go index 270f686..88682fa 100644 --- a/main.go +++ b/main.go @@ -8,6 +8,7 @@ import ( "os/signal" "syscall" "time" + _ "time/tzdata" // the image is FROM scratch, so embed zoneinfo for spec.timeZone "github.com/alecthomas/kingpin/v2" "github.com/multiplay/go-slack/chat" @@ -19,8 +20,10 @@ import ( "k8s.io/client-go/rest" ) -var checkFunc = doCheck -var exitFunc = os.Exit +var ( + checkFunc = doCheck + exitFunc = os.Exit +) func main() { slackUrl := kingpin.Flag("slack-url", "The Slack Webhook URL").Envar("SLACK_URL").Required().String() @@ -68,6 +71,14 @@ func doCheck(client Client, slackUrl string, ic chan os.Signal, sleepTime time.D if c.Status.LastScheduleTime != nil { since = *c.Status.LastScheduleTime } + // The schedule is evaluated in the location of since, so use the cronjob's time zone (UTC if unset) + if c.Spec.TimeZone != nil { + loc, err := time.LoadLocation(*c.Spec.TimeZone) + if err != nil { + return fmt.Errorf("error loading time zone of %s/%s (%s): %w", c.Namespace, c.Name, *c.Spec.TimeZone, err) + } + since = v1.NewTime(since.In(loc)) + } schedule, err := parser.Parse(c.Spec.Schedule) if err != nil { return fmt.Errorf("error parsing schedule of %s/%s (%s): %w", c.Namespace, c.Name, c.Spec.Schedule, err) diff --git a/main_test.go b/main_test.go index 4b80e7c..deb8a53 100644 --- a/main_test.go +++ b/main_test.go @@ -106,6 +106,7 @@ func Test_doCheck(t *testing.T) { slackResponse string wantErr bool wantOut []string + wantNotOut []string }{ { name: "error getting cronjobs", @@ -181,6 +182,57 @@ func Test_doCheck(t *testing.T) { }, wantErr: true, }, + { + name: "invalid time zone", + args: args{ + client: &brokenClient{ + batchApi: &batchApi{ + cronApi: &cronApi{ + listFn: func(_ context.Context, _ v1.ListOptions) (*cronjobv1.CronJobList, error) { + return &cronjobv1.CronJobList{ + Items: []cronjobv1.CronJob{ + { + Spec: cronjobv1.CronJobSpec{Schedule: "* * * * *", TimeZone: strP("Nowhere/Special")}, + }, + }, + }, nil + }, + }, + }, + }, + }, + wantErr: true, + }, + { + name: "schedule evaluated in cronjob time zone", + args: args{ + client: &brokenClient{ + batchApi: &batchApi{ + cronApi: &cronApi{ + listFn: func(_ context.Context, _ v1.ListOptions) (*cronjobv1.CronJobList, error) { + // Ran 6h ago at its daily local time; read as UTC the next run would be 30 minutes ago + last := time.Now().Add(-6 * time.Hour).Truncate(time.Minute) + local := last.In(time.FixedZone("IST", 5*3600+1800)) // Asia/Kolkata has no DST + return &cronjobv1.CronJobList{ + Items: []cronjobv1.CronJob{ + { + ObjectMeta: v1.ObjectMeta{Name: "some-name", Namespace: "some-ns"}, + Spec: cronjobv1.CronJobSpec{Schedule: fmt.Sprintf("%d %d * * *", local.Minute(), local.Hour()), TimeZone: strP("Asia/Kolkata")}, + Status: cronjobv1.CronJobStatus{LastScheduleTime: &v1.Time{Time: last}}, + }, + }, + }, nil + }, + }, + }, + }, + }, + timeout: 100 * time.Millisecond, + slackResponse: `{"ok": true}`, + wantErr: false, + wantOut: []string{"Checking some-ns/some-name since"}, + wantNotOut: []string{"was not scheduled"}, + }, { name: "only correctly running cronjobs", args: args{ @@ -308,6 +360,11 @@ func Test_doCheck(t *testing.T) { if err := doCheck(tt.args.client, fmt.Sprintf("http://%s", baseURL), ic, 10*time.Millisecond, buff); (err != nil) != tt.wantErr { t.Errorf("doCheck() error = %v, wantErr %v", err, tt.wantErr) } + for _, o := range tt.wantNotOut { + if strings.Contains(buff.String(), o) { + t.Errorf("doCheck() got %s, did not want %s", buff.String(), o) + } + } if len(tt.wantOut) > 0 { for _, o := range tt.wantOut { if !strings.Contains(buff.String(), o) { @@ -404,7 +461,7 @@ func (b batchApi) CronJobs(namespace string) batchv1.CronJobInterface { } func (b batchApi) Jobs(namespace string) batchv1.JobInterface { - //TODO implement me + // TODO implement me panic("implement me") } @@ -463,3 +520,7 @@ var _ batchv1.CronJobInterface = &cronApi{} func boolP(b bool) *bool { return &b } + +func strP(s string) *string { + return &s +}