Files
cron-checker/main_test.go
T
argoyle 52d52a222c
Unbound Release / Check Preconditions (push) Successful in 3s
Unbound Release / Create Tag (push) Skipped
Unbound Release / Create Release (push) Successful in 15s
Unbound Release / Generate Changelog and Handle PR (push) Successful in 17s
Release / release (push) Successful in 21s
cron-checker / test (push) Successful in 2m12s
cron-checker / vulnerabilities (push) Successful in 2m15s
cron-checker / build (push) Successful in 50s
fix: evaluate schedules in the cronjob's spec.timeZone (#428)
Schedules were always evaluated in UTC because `schedule.Next` uses the location of `lastScheduleTime`, which the API returns in UTC. A CronJob with `spec.timeZone` set was therefore reported as not running every day. For example, Paidit's `prod/dayclose` runs at `0 7 * * *` Europe/Stockholm: it ran at 05:00Z, but cron-checker expected 07:00Z and alerted every minute from 09:02 local time.

- Convert `since` to the CronJob's `spec.timeZone` before computing the next run. CronJobs without a `timeZone` behave as before.
- An invalid time zone returns an error, the same way an invalid schedule does.
- Embed `time/tzdata`, because the image is `FROM scratch` and has no zoneinfo.
- Tests: an invalid time zone, and a time-zone case that fails without the fix.
- Bumped golang.org/x/net to v0.60.0. govulncheck was already failing on main with five x/net advisories, which blocks the build job.
- Bumped the go toolchain to 1.27.2 for eight stdlib advisories. This is the same change as Renovate #427, which will close once this is merged.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01C8SW6vsbkrTKjLNatQ8JTg
Reviewed-on: https://gitea.unbound.se/unboundsoftware/cron-checker/pulls/428
2026-10-09 07:54:30 +00:00

527 lines
14 KiB
Go

package main
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"github.com/sanity-io/litter"
cronjobv1 "k8s.io/api/batch/v1"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"k8s.io/apimachinery/pkg/watch"
applyv1 "k8s.io/client-go/applyconfigurations/batch/v1"
"k8s.io/client-go/discovery"
"k8s.io/client-go/kubernetes"
batchv1 "k8s.io/client-go/kubernetes/typed/batch/v1"
"k8s.io/client-go/rest"
)
func Test_Main(t *testing.T) {
tests := []struct {
name string
connectFunc func(provider ConfigProvider) (Client, error)
exitFunc func(code int)
}{
{
name: "error connecting to K8S",
connectFunc: func(ConfigProvider) (Client, error) {
return nil, errors.New("error")
},
exitFunc: func(code int) {
if code != 1 {
t.Errorf("main() got %d, want 1", code)
}
},
},
}
for _, tt := range tests {
os.Args = []string{"dummy", "--slack-url", "https://dummy.example.org"}
t.Run(tt.name, func(t *testing.T) {
exitFunc = tt.exitFunc
main()
})
}
}
func Test_doMain(t *testing.T) {
type args struct {
slackUrl string
provider ClientProvider
}
tests := []struct {
name string
args args
checkFunc func(client Client, slackUrl string, ic chan os.Signal, sleepTime time.Duration, out io.Writer) error
want int
}{
{
name: "error checking",
args: args{
provider: &brokenClientProvider{},
},
checkFunc: func(client Client, slackUrl string, ic chan os.Signal, sleepTime time.Duration, out io.Writer) error {
return errors.New("error")
},
want: 1,
},
{
name: "success",
args: args{
provider: &brokenClientProvider{},
},
checkFunc: func(client Client, slackUrl string, ic chan os.Signal, sleepTime time.Duration, out io.Writer) error {
return nil
},
want: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
checkFunc = tt.checkFunc
if got := doMain(tt.args.slackUrl, tt.args.provider); got != tt.want {
t.Errorf("doMain() = %v, want %v", got, tt.want)
}
})
}
}
func Test_doCheck(t *testing.T) {
type args struct {
client Client
}
tests := []struct {
name string
args args
timeout time.Duration
slackResponse string
wantErr bool
wantOut []string
wantNotOut []string
}{
{
name: "error getting cronjobs",
args: args{
client: &brokenClient{
batchApi: &batchApi{
cronApi: &cronApi{
listFn: func(_ context.Context, _ v1.ListOptions) (*cronjobv1.CronJobList, error) {
return nil, errors.New("error")
},
},
},
},
},
wantErr: true,
},
{
name: "no cronjobs",
args: args{
client: &brokenClient{
batchApi: &batchApi{
cronApi: &cronApi{
listFn: func(_ context.Context, _ v1.ListOptions) (*cronjobv1.CronJobList, error) {
return &cronjobv1.CronJobList{}, nil
},
},
},
},
},
timeout: time.Second,
wantErr: false,
},
{
name: "suspended cronjobs are ignored",
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{Suspend: boolP(true)},
},
},
}, nil
},
},
},
},
},
timeout: time.Second,
wantErr: false,
},
{
name: "invalid cron schedule",
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: "abc"},
},
},
}, nil
},
},
},
},
},
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{
client: &brokenClient{
batchApi: &batchApi{
cronApi: &cronApi{
listFn: func(_ context.Context, _ v1.ListOptions) (*cronjobv1.CronJobList, error) {
return &cronjobv1.CronJobList{
Items: []cronjobv1.CronJob{
{
ObjectMeta: v1.ObjectMeta{CreationTimestamp: v1.Time{Time: time.Now()}},
Spec: cronjobv1.CronJobSpec{Schedule: "* * * * *", Suspend: boolP(false)},
},
{
Spec: cronjobv1.CronJobSpec{Schedule: "* * * * *"},
Status: cronjobv1.CronJobStatus{LastScheduleTime: &v1.Time{Time: time.Now()}},
},
},
}, nil
},
},
},
},
},
timeout: time.Second,
wantErr: false,
},
{
name: "error in Slack call",
args: args{
client: &brokenClient{
batchApi: &batchApi{
cronApi: &cronApi{
listFn: func(_ context.Context, _ v1.ListOptions) (*cronjobv1.CronJobList, error) {
return &cronjobv1.CronJobList{
Items: []cronjobv1.CronJob{
{
ObjectMeta: v1.ObjectMeta{Name: "some-name", Namespace: "some-ns"},
Spec: cronjobv1.CronJobSpec{Schedule: "* * * * *"},
Status: cronjobv1.CronJobStatus{LastScheduleTime: &v1.Time{Time: time.Now().Add(-3 * time.Minute)}},
},
},
}, nil
},
},
},
},
},
timeout: time.Second,
slackResponse: "dummy",
wantErr: false,
wantOut: []string{"Checking some-ns/some-name since", "some-ns/some-name was not scheduled. Sending Slack notification.", "Unable to send Slack notification: slack: request failed statuscode: 200, message: invalid character 'd' looking for beginning of value"},
},
{
name: "Slack response not ok",
args: args{
client: &brokenClient{
batchApi: &batchApi{
cronApi: &cronApi{
listFn: func(_ context.Context, _ v1.ListOptions) (*cronjobv1.CronJobList, error) {
return &cronjobv1.CronJobList{
Items: []cronjobv1.CronJob{
{
ObjectMeta: v1.ObjectMeta{Name: "some-name", Namespace: "some-ns"},
Spec: cronjobv1.CronJobSpec{Schedule: "* * * * *"},
Status: cronjobv1.CronJobStatus{LastScheduleTime: &v1.Time{Time: time.Now().Add(-3 * time.Minute)}},
},
},
}, nil
},
},
},
},
},
timeout: time.Second,
slackResponse: `{"ok": false, "error": "Something went wrong"}`,
wantErr: false,
wantOut: []string{"Checking some-ns/some-name since", "some-ns/some-name was not scheduled. Sending Slack notification.", "Unable to send Slack notification: slack: request failed statuscode: 200, message: Something went wrong"},
},
{
name: "Slack response ok",
args: args{
client: &brokenClient{
batchApi: &batchApi{
cronApi: &cronApi{
listFn: func(_ context.Context, _ v1.ListOptions) (*cronjobv1.CronJobList, error) {
return &cronjobv1.CronJobList{
Items: []cronjobv1.CronJob{
{
ObjectMeta: v1.ObjectMeta{Name: "some-name", Namespace: "some-ns"},
Spec: cronjobv1.CronJobSpec{Schedule: "* * * * *"},
Status: cronjobv1.CronJobStatus{LastScheduleTime: &v1.Time{Time: time.Now().Add(-3 * time.Minute)}},
},
},
}, nil
},
},
},
},
},
timeout: time.Second,
slackResponse: `{"ok": true}`,
wantErr: false,
wantOut: []string{"Checking some-ns/some-name since", "some-ns/some-name was not scheduled. Sending Slack notification."},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ic := make(chan os.Signal, 1)
if tt.timeout > 0 {
timeout := tt.timeout
fmt.Printf("Waiting %s before terminating\n", timeout.String())
go func() {
time.Sleep(timeout)
fmt.Println("Done waiting, terminating")
ic <- os.Interrupt
}()
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(tt.slackResponse))
}))
defer server.Close()
baseURL := server.Listener.Addr().String()
buff := &bytes.Buffer{}
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) {
t.Errorf("doCheck() got %s, want %s", buff.String(), o)
}
}
}
})
}
}
func TestDefaultProvider_Provide(t *testing.T) {
type fields struct {
provider ConfigProvider
}
tests := []struct {
name string
fields fields
want Client
wantErr bool
}{
{
name: "not in cluster",
fields: fields{provider: &InClusterProvider{}},
want: nil,
wantErr: true,
},
{
name: "dummy config",
fields: fields{provider: &dummyProvider{}},
want: &kubernetes.Clientset{
DiscoveryClient: &discovery.DiscoveryClient{
LegacyPrefix: "/api",
},
},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
d := DefaultProvider{
provider: tt.fields.provider,
}
got, err := d.Provide()
if (err != nil) != tt.wantErr {
t.Errorf("Provide() error = %v, wantErr %v", err, tt.wantErr)
return
}
gotDump := litter.Sdump(got)
wantDump := litter.Sdump(tt.want)
if gotDump != wantDump {
t.Errorf("Provide() got = %v, want %v", gotDump, wantDump)
}
})
}
}
type dummyProvider struct{}
func (d dummyProvider) Provide() (*rest.Config, error) {
return &rest.Config{}, nil
}
var _ ConfigProvider = &dummyProvider{}
type brokenClientProvider struct{}
func (b brokenClientProvider) Provide() (Client, error) {
return &brokenClient{}, nil
}
var _ ClientProvider = &brokenClientProvider{}
type brokenClient struct {
batchApi batchv1.BatchV1Interface
}
func (b brokenClient) BatchV1() batchv1.BatchV1Interface {
return b.batchApi
}
var _ Client = &brokenClient{}
type batchApi struct {
cronApi batchv1.CronJobInterface
}
func (b batchApi) RESTClient() rest.Interface {
panic("implement me")
}
func (b batchApi) CronJobs(namespace string) batchv1.CronJobInterface {
return b.cronApi
}
func (b batchApi) Jobs(namespace string) batchv1.JobInterface {
// TODO implement me
panic("implement me")
}
var _ batchv1.BatchV1Interface = &batchApi{}
type cronApi struct {
listFn func(ctx context.Context, opts v1.ListOptions) (*cronjobv1.CronJobList, error)
}
func (c cronApi) List(ctx context.Context, opts v1.ListOptions) (*cronjobv1.CronJobList, error) {
return c.listFn(ctx, opts)
}
func (c cronApi) Create(ctx context.Context, cronJob *cronjobv1.CronJob, opts v1.CreateOptions) (*cronjobv1.CronJob, error) {
panic("implement me")
}
func (c cronApi) Update(ctx context.Context, cronJob *cronjobv1.CronJob, opts v1.UpdateOptions) (*cronjobv1.CronJob, error) {
panic("implement me")
}
func (c cronApi) UpdateStatus(ctx context.Context, cronJob *cronjobv1.CronJob, opts v1.UpdateOptions) (*cronjobv1.CronJob, error) {
panic("implement me")
}
func (c cronApi) Delete(ctx context.Context, name string, opts v1.DeleteOptions) error {
panic("implement me")
}
func (c cronApi) DeleteCollection(ctx context.Context, opts v1.DeleteOptions, listOpts v1.ListOptions) error {
panic("implement me")
}
func (c cronApi) Get(ctx context.Context, name string, opts v1.GetOptions) (*cronjobv1.CronJob, error) {
panic("implement me")
}
func (c cronApi) Watch(ctx context.Context, opts v1.ListOptions) (watch.Interface, error) {
panic("implement me")
}
func (c cronApi) Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts v1.PatchOptions, subresources ...string) (result *cronjobv1.CronJob, err error) {
panic("implement me")
}
func (c cronApi) Apply(ctx context.Context, cronJob *applyv1.CronJobApplyConfiguration, opts v1.ApplyOptions) (result *cronjobv1.CronJob, err error) {
panic("implement me")
}
func (c cronApi) ApplyStatus(ctx context.Context, cronJob *applyv1.CronJobApplyConfiguration, opts v1.ApplyOptions) (result *cronjobv1.CronJob, err error) {
panic("implement me")
}
var _ batchv1.CronJobInterface = &cronApi{}
func boolP(b bool) *bool {
return &b
}
func strP(s string) *string {
return &s
}