From 7110d8c14b31b87301993bc8a3429a946ddcc544 Mon Sep 17 00:00:00 2001 From: Joakim Olsson Date: Sun, 6 Sep 2020 13:20:55 +0200 Subject: [PATCH] chore: refactor and add tests --- go.mod | 3 +- go.sum | 5 + main.go | 97 +++++++---- main_test.go | 450 +++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 527 insertions(+), 28 deletions(-) create mode 100644 main_test.go diff --git a/go.mod b/go.mod index 8335ab8..b49d9c4 100644 --- a/go.mod +++ b/go.mod @@ -7,10 +7,11 @@ require ( github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d // indirect github.com/multiplay/go-slack v0.0.0-20200412115802-09e9e2b93fe2 github.com/robfig/cron v1.2.0 + github.com/sanity-io/litter v1.3.0 golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d // indirect golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e // indirect gopkg.in/alecthomas/kingpin.v2 v2.2.6 - k8s.io/api v0.18.8 // indirect + k8s.io/api v0.18.8 k8s.io/apimachinery v0.18.8 k8s.io/client-go v0.18.0 k8s.io/utils v0.0.0-20200821003339-5e75c0163111 // indirect diff --git a/go.sum b/go.sum index d998ceb..19dee9d 100644 --- a/go.sum +++ b/go.sum @@ -17,6 +17,7 @@ github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuy github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d h1:UQZhZ2O0vMHr2cI+DC1Mbh0TJxzA3RcLoMsFw+aXw7E= github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/davecgh/go-spew v0.0.0-20161028175848-04cdfd42973b/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -94,15 +95,19 @@ github.com/onsi/gomega v0.0.0-20170829124025-dcabb60a477c/go.mod h1:C1qb7wdrVGGV github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/robfig/cron v1.2.0 h1:ZjScXvvxeQ63Dbyxy76Fj3AT3Ut0aKsyd2/tl3DTMuQ= github.com/robfig/cron v1.2.0/go.mod h1:JGuDeoQd7Z6yL4zQhZ3OPEVHB7fL6Ka6skscFHfmt2k= +github.com/sanity-io/litter v1.3.0 h1:5ZO+weUsqdSWMUng5JnpkW/Oz8iTXiIdeumhQr1sSjs= +github.com/sanity-io/litter v1.3.0/go.mod h1:5Z71SvaYy5kcGtyglXOC9rrUi3c1E8CamFWjQsazTh0= github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk= github.com/spf13/pflag v0.0.0-20170130214245-9ff6c6923cff/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v0.0.0-20161117074351-18a02ba4a312/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= diff --git a/main.go b/main.go index 321a97b..581564f 100644 --- a/main.go +++ b/main.go @@ -7,8 +7,10 @@ import ( "github.com/multiplay/go-slack/webhook" "github.com/robfig/cron" "gopkg.in/alecthomas/kingpin.v2" + "io" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/kubernetes" + "k8s.io/client-go/kubernetes/typed/batch/v1beta1" "k8s.io/client-go/rest" "os" "os/signal" @@ -16,39 +18,50 @@ import ( "time" ) +var checkFunc = doCheck +var exitFunc = os.Exit + func main() { slackUrl := kingpin.Flag("slack-url", "The Slack Webhook URL").Envar("SLACK_URL").Required().String() kingpin.Parse() - config, err := rest.InClusterConfig() - if err != nil { - panic(err.Error()) - } - clientset, err := kubernetes.NewForConfig(config) - if err != nil { - panic(err.Error()) - } + exitFunc(doMain(*slackUrl, &DefaultProvider{&InClusterProvider{}})) +} - slack := webhook.New(*slackUrl) - - parser := cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow) +func doMain(slackUrl string, provider ClientProvider) int { + client, err := provider.Provide() + if err != nil { + fmt.Printf("Unable to connect to K8S: %s\n", err) + return 1 + } ic := make(chan os.Signal, 1) signal.Notify(ic, os.Interrupt, syscall.SIGTERM) + if err := checkFunc(client, slackUrl, ic, 60*time.Second, os.Stdout); err != nil { + fmt.Printf("Error checking jobs: %s\n", err) + return 1 + } + return 0 +} + +func doCheck(client Client, slackUrl string, ic chan os.Signal, sleepTime time.Duration, out io.Writer) error { + slack := webhook.New(slackUrl) + + parser := cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow) + for { select { case <-ic: - fmt.Printf("Got SIGTERM signal, exiting\n") - break + _, _ = fmt.Fprintf(out, "Got SIGTERM signal, exiting\n") + return nil default: - cronjobs, err := clientset.BatchV1beta1().CronJobs("").List(context.Background(), v1.ListOptions{}) + cronJobs, err := client.BatchV1beta1().CronJobs("").List(context.Background(), v1.ListOptions{}) if err != nil { - fmt.Printf("Error getting cronjobs: %s", err) - os.Exit(1) + return fmt.Errorf("error getting cronjobs: %w", err) } limit := time.Now().Add(-120 * time.Second) - for _, c := range cronjobs.Items { + for _, c := range cronJobs.Items { if c.Spec.Suspend == nil || !*c.Spec.Suspend { since := c.CreationTimestamp if c.Status.LastScheduleTime != nil { @@ -56,28 +69,58 @@ func main() { } schedule, err := parser.Parse(c.Spec.Schedule) if err != nil { - fmt.Printf("Error parsing schedule of %s/%s (%s): %s", c.Namespace, c.Name, c.Spec.Schedule, err) - os.Exit(1) + return fmt.Errorf("error parsing schedule of %s/%s (%s): %w", c.Namespace, c.Name, c.Spec.Schedule, err) } next := schedule.Next(since.Time) - fmt.Printf("Checking %s/%s since %s, next schedule %s, limit %s.\n", c.Namespace, c.Name, since.Format(time.RFC3339), next.Format(time.RFC3339), limit.Format(time.RFC3339)) + _, _ = fmt.Fprintf(out, "Checking %s/%s since %s, next schedule %s, limit %s.\n", c.Namespace, c.Name, since.Format(time.RFC3339), next.Format(time.RFC3339), limit.Format(time.RFC3339)) if next.Before(limit) { - fmt.Printf("%s was not scheduled. Sending Slack notification.\n", c.Name) + _, _ = fmt.Fprintf(out, "%s/%s was not scheduled. Sending Slack notification.\n", c.Namespace, c.Name) m := &chat.Message{ Text: fmt.Sprintf("Cronjob %s/%s is not running according to schedule (%s). Last scheduled: %s", c.Namespace, c.Name, c.Spec.Schedule, since.Format(time.RFC3339)), Username: "cron-checker", } - resp, err := m.Send(slack) + _, err := m.Send(slack) if err != nil { - fmt.Printf("Unable to send Slack notification: %s", err) - } - if !resp.OK { - fmt.Printf("Unable to send Slack notification: %s", resp.Error) + _, _ = fmt.Fprintf(out, "Unable to send Slack notification: %s\n", err) } } } } - time.Sleep(60 * time.Second) + time.Sleep(sleepTime) } } } + +type Client interface { + BatchV1beta1() v1beta1.BatchV1beta1Interface +} + +type ClientProvider interface { + Provide() (Client, error) +} + +type DefaultProvider struct { + provider ConfigProvider +} + +func (d DefaultProvider) Provide() (Client, error) { + config, err := d.provider.Provide() + if err != nil { + return nil, err + } + return kubernetes.NewForConfig(config) +} + +var _ ClientProvider = &DefaultProvider{} + +type ConfigProvider interface { + Provide() (*rest.Config, error) +} + +type InClusterProvider struct{} + +func (i InClusterProvider) Provide() (*rest.Config, error) { + return rest.InClusterConfig() +} + +var _ ConfigProvider = &InClusterProvider{} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..1055114 --- /dev/null +++ b/main_test.go @@ -0,0 +1,450 @@ +package main + +import ( + "bytes" + "context" + "errors" + "fmt" + "github.com/sanity-io/litter" + "io" + beta1 "k8s.io/api/batch/v1beta1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/watch" + "k8s.io/client-go/discovery" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/kubernetes/typed/batch/v1beta1" + "k8s.io/client-go/rest" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" +) + +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 + }{ + { + name: "error getting cronjobs", + args: args{ + client: &brokenClient{ + batchApi: &batchApi{ + cronApi: &cronApi{ + listFn: func(_ context.Context, _ v1.ListOptions) (*beta1.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) (*beta1.CronJobList, error) { + return &beta1.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) (*beta1.CronJobList, error) { + return &beta1.CronJobList{ + Items: []beta1.CronJob{ + { + Spec: beta1.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) (*beta1.CronJobList, error) { + return &beta1.CronJobList{ + Items: []beta1.CronJob{ + { + Spec: beta1.CronJobSpec{Schedule: "abc"}, + }, + }, + }, nil + }, + }, + }, + }, + }, + wantErr: true, + }, + { + name: "only correctly running cronjobs", + args: args{ + client: &brokenClient{ + batchApi: &batchApi{ + cronApi: &cronApi{ + listFn: func(_ context.Context, _ v1.ListOptions) (*beta1.CronJobList, error) { + return &beta1.CronJobList{ + Items: []beta1.CronJob{ + { + ObjectMeta: v1.ObjectMeta{CreationTimestamp: v1.Time{Time: time.Now()}}, + Spec: beta1.CronJobSpec{Schedule: "* * * * *", Suspend: boolP(false)}, + }, + { + Spec: beta1.CronJobSpec{Schedule: "* * * * *"}, + Status: beta1.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) (*beta1.CronJobList, error) { + return &beta1.CronJobList{ + Items: []beta1.CronJob{ + { + ObjectMeta: v1.ObjectMeta{Name: "some-name", Namespace: "some-ns"}, + Spec: beta1.CronJobSpec{Schedule: "* * * * *"}, + Status: beta1.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) (*beta1.CronJobList, error) { + return &beta1.CronJobList{ + Items: []beta1.CronJob{ + { + ObjectMeta: v1.ObjectMeta{Name: "some-name", Namespace: "some-ns"}, + Spec: beta1.CronJobSpec{Schedule: "* * * * *"}, + Status: beta1.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) (*beta1.CronJobList, error) { + return &beta1.CronJobList{ + Items: []beta1.CronJob{ + { + ObjectMeta: v1.ObjectMeta{Name: "some-name", Namespace: "some-ns"}, + Spec: beta1.CronJobSpec{Schedule: "* * * * *"}, + Status: beta1.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) + } + 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 v1beta1.BatchV1beta1Interface +} + +func (b brokenClient) BatchV1beta1() v1beta1.BatchV1beta1Interface { + return b.batchApi +} + +var _ Client = &brokenClient{} + +type batchApi struct { + cronApi v1beta1.CronJobInterface +} + +func (b batchApi) RESTClient() rest.Interface { + panic("implement me") +} + +func (b batchApi) CronJobs(namespace string) v1beta1.CronJobInterface { + return b.cronApi +} + +var _ v1beta1.BatchV1beta1Interface = &batchApi{} + +type cronApi struct { + listFn func(ctx context.Context, opts v1.ListOptions) (*beta1.CronJobList, error) +} + +func (c cronApi) List(ctx context.Context, opts v1.ListOptions) (*beta1.CronJobList, error) { + return c.listFn(ctx, opts) +} + +func (c cronApi) Create(ctx context.Context, cronJob *beta1.CronJob, opts v1.CreateOptions) (*beta1.CronJob, error) { + panic("implement me") +} + +func (c cronApi) Update(ctx context.Context, cronJob *beta1.CronJob, opts v1.UpdateOptions) (*beta1.CronJob, error) { + panic("implement me") +} + +func (c cronApi) UpdateStatus(ctx context.Context, cronJob *beta1.CronJob, opts v1.UpdateOptions) (*beta1.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) (*beta1.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 *beta1.CronJob, err error) { + panic("implement me") +} + +var _ v1beta1.CronJobInterface = &cronApi{} + +func boolP(b bool) *bool { + return &b +}