/* Copyright 2017 Heptio Inc. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package controller import ( "testing" "time" "github.com/robfig/cron" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/util/clock" "k8s.io/client-go/kubernetes/scheme" core "k8s.io/client-go/testing" "k8s.io/client-go/tools/cache" api "github.com/heptio/ark/pkg/apis/ark/v1" "github.com/heptio/ark/pkg/generated/clientset/fake" informers "github.com/heptio/ark/pkg/generated/informers/externalversions" . "github.com/heptio/ark/pkg/util/test" ) func TestProcessSchedule(t *testing.T) { tests := []struct { name string scheduleKey string schedule *api.Schedule fakeClockTime string expectedErr bool expectedSchedulePhaseUpdate *api.Schedule expectedScheduleLastBackupUpdate *api.Schedule expectedBackupCreate *api.Backup }{ { name: "invalid key returns error", scheduleKey: "invalid/key/value", expectedErr: true, }, { name: "missing schedule returns early without an error", scheduleKey: "foo/bar", expectedErr: false, }, { name: "schedule with phase FailedValidation does not get processed", schedule: NewTestSchedule("ns", "name").WithPhase(api.SchedulePhaseFailedValidation).Schedule, expectedErr: false, }, { name: "schedule with phase New gets validated and failed if invalid", schedule: NewTestSchedule("ns", "name").WithPhase(api.SchedulePhaseNew).Schedule, expectedErr: false, expectedSchedulePhaseUpdate: NewTestSchedule("ns", "name").WithPhase(api.SchedulePhaseFailedValidation). WithValidationError("Schedule must be a non-empty valid Cron expression").Schedule, }, { name: "schedule with phase gets validated and failed if invalid", schedule: NewTestSchedule("ns", "name").Schedule, expectedErr: false, expectedSchedulePhaseUpdate: NewTestSchedule("ns", "name").WithPhase(api.SchedulePhaseFailedValidation). WithValidationError("Schedule must be a non-empty valid Cron expression").Schedule, }, { name: "schedule with phase Enabled gets re-validated and failed if invalid", schedule: NewTestSchedule("ns", "name").WithPhase(api.SchedulePhaseEnabled).Schedule, expectedErr: false, expectedSchedulePhaseUpdate: NewTestSchedule("ns", "name").WithPhase(api.SchedulePhaseFailedValidation). WithValidationError("Schedule must be a non-empty valid Cron expression").Schedule, }, { name: "schedule with phase New gets validated and triggers a backup", schedule: NewTestSchedule("ns", "name").WithPhase(api.SchedulePhaseNew).WithCronSchedule("@every 5m").Schedule, fakeClockTime: "2017-01-01 12:00:00", expectedErr: false, expectedSchedulePhaseUpdate: NewTestSchedule("ns", "name").WithPhase(api.SchedulePhaseEnabled).WithCronSchedule("@every 5m").Schedule, expectedBackupCreate: NewTestBackup().WithNamespace("ns").WithName("name-20170101120000").WithLabel("ark-schedule", "name").Backup, expectedScheduleLastBackupUpdate: NewTestSchedule("ns", "name").WithPhase(api.SchedulePhaseEnabled). WithCronSchedule("@every 5m").WithLastBackupTime("2017-01-01 12:00:00").Schedule, }, { name: "schedule with phase Enabled gets re-validated and triggers a backup if valid", schedule: NewTestSchedule("ns", "name").WithPhase(api.SchedulePhaseEnabled).WithCronSchedule("@every 5m").Schedule, fakeClockTime: "2017-01-01 12:00:00", expectedErr: false, expectedBackupCreate: NewTestBackup().WithNamespace("ns").WithName("name-20170101120000").WithLabel("ark-schedule", "name").Backup, expectedScheduleLastBackupUpdate: NewTestSchedule("ns", "name").WithPhase(api.SchedulePhaseEnabled). WithCronSchedule("@every 5m").WithLastBackupTime("2017-01-01 12:00:00").Schedule, }, { name: "schedule that's already run gets LastBackup updated", schedule: NewTestSchedule("ns", "name").WithPhase(api.SchedulePhaseEnabled). WithCronSchedule("@every 5m").WithLastBackupTime("2000-01-01 00:00:00").Schedule, fakeClockTime: "2017-01-01 12:00:00", expectedErr: false, expectedBackupCreate: NewTestBackup().WithNamespace("ns").WithName("name-20170101120000").WithLabel("ark-schedule", "name").Backup, expectedScheduleLastBackupUpdate: NewTestSchedule("ns", "name").WithPhase(api.SchedulePhaseEnabled). WithCronSchedule("@every 5m").WithLastBackupTime("2017-01-01 12:00:00").Schedule, }, } // flag.Set("logtostderr", "true") // flag.Set("v", "4") for _, test := range tests { t.Run(test.name, func(t *testing.T) { var ( client = fake.NewSimpleClientset() sharedInformers = informers.NewSharedInformerFactory(client, 0) ) c := NewScheduleController( client.ArkV1(), client.ArkV1(), sharedInformers.Ark().V1().Schedules(), time.Duration(0), ) var ( testTime time.Time err error ) if test.fakeClockTime != "" { testTime, err = time.Parse("2006-01-02 15:04:05", test.fakeClockTime) require.NoError(t, err, "unable to parse test.fakeClockTime: %v", err) } c.clock = clock.NewFakeClock(testTime) if test.schedule != nil { sharedInformers.Ark().V1().Schedules().Informer().GetStore().Add(test.schedule) // this is necessary so the Update() call returns the appropriate object client.PrependReactor("update", "schedules", func(action core.Action) (bool, runtime.Object, error) { obj := action.(core.UpdateAction).GetObject() // need to deep copy so we can test the schedule state for each call to update copy, err := scheme.Scheme.DeepCopy(obj) if err != nil { return false, nil, err } ret := copy.(runtime.Object) return true, ret, nil }) } key := test.scheduleKey if key == "" && test.schedule != nil { key, err = cache.MetaNamespaceKeyFunc(test.schedule) require.NoError(t, err, "error getting key from test.schedule: %v", err) } err = c.processSchedule(key) assert.Equal(t, test.expectedErr, err != nil, "got error %v", err) expectedActions := make([]core.Action, 0) if upd := test.expectedSchedulePhaseUpdate; upd != nil { action := core.NewUpdateAction( api.SchemeGroupVersion.WithResource("schedules"), upd.Namespace, upd) expectedActions = append(expectedActions, action) } if created := test.expectedBackupCreate; created != nil { action := core.NewCreateAction( api.SchemeGroupVersion.WithResource("backups"), created.Namespace, created) expectedActions = append(expectedActions, action) } if upd := test.expectedScheduleLastBackupUpdate; upd != nil { action := core.NewUpdateAction( api.SchemeGroupVersion.WithResource("schedules"), upd.Namespace, upd) expectedActions = append(expectedActions, action) } assert.Equal(t, expectedActions, client.Actions()) }) } } func TestGetNextRunTime(t *testing.T) { tests := []struct { name string schedule *api.Schedule lastRanOffset string expectedDue bool expectedNextRunTimeOffset string }{ { name: "first run", schedule: &api.Schedule{Spec: api.ScheduleSpec{Schedule: "@every 5m"}}, expectedDue: true, expectedNextRunTimeOffset: "5m", }, { name: "just ran", schedule: &api.Schedule{Spec: api.ScheduleSpec{Schedule: "@every 5m"}}, lastRanOffset: "0s", expectedDue: false, expectedNextRunTimeOffset: "5m", }, { name: "almost but not quite time to run", schedule: &api.Schedule{Spec: api.ScheduleSpec{Schedule: "@every 5m"}}, lastRanOffset: "4m59s", expectedDue: false, expectedNextRunTimeOffset: "5m", }, { name: "time to run again", schedule: &api.Schedule{Spec: api.ScheduleSpec{Schedule: "@every 5m"}}, lastRanOffset: "5m", expectedDue: true, expectedNextRunTimeOffset: "5m", }, { name: "several runs missed", schedule: &api.Schedule{Spec: api.ScheduleSpec{Schedule: "@every 5m"}}, lastRanOffset: "5h", expectedDue: true, expectedNextRunTimeOffset: "5m", }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { cronSchedule, err := cron.Parse(test.schedule.Spec.Schedule) require.NoError(t, err, "unable to parse test.schedule.Spec.Schedule: %v", err) testClock := clock.NewFakeClock(time.Now()) if test.lastRanOffset != "" { offsetDuration, err := time.ParseDuration(test.lastRanOffset) require.NoError(t, err, "unable to parse test.lastRanOffset: %v", err) test.schedule.Status.LastBackup = metav1.Time{Time: testClock.Now().Add(-offsetDuration)} } nextRunTimeOffset, err := time.ParseDuration(test.expectedNextRunTimeOffset) if err != nil { panic(err) } expectedNextRunTime := test.schedule.Status.LastBackup.Add(nextRunTimeOffset) due, nextRunTime := getNextRunTime(test.schedule, cronSchedule, testClock.Now()) assert.Equal(t, test.expectedDue, due) // ignore diffs of under a second. the cron library does some rounding. assert.WithinDuration(t, expectedNextRunTime, nextRunTime, time.Second) }) } } func TestGetBackup(t *testing.T) { tests := []struct { name string schedule *api.Schedule testClockTime string expectedBackup *api.Backup }{ { name: "ensure name is formatted correctly (AM time)", schedule: &api.Schedule{ ObjectMeta: metav1.ObjectMeta{ Namespace: "foo", Name: "bar", }, Spec: api.ScheduleSpec{ Template: api.BackupSpec{}, }, }, testClockTime: "2017-07-25 09:15:00", expectedBackup: &api.Backup{ ObjectMeta: metav1.ObjectMeta{ Namespace: "foo", Name: "bar-20170725091500", }, Spec: api.BackupSpec{}, }, }, { name: "ensure name is formatted correctly (PM time)", schedule: &api.Schedule{ ObjectMeta: metav1.ObjectMeta{ Namespace: "foo", Name: "bar", }, Spec: api.ScheduleSpec{ Template: api.BackupSpec{}, }, }, testClockTime: "2017-07-25 14:15:00", expectedBackup: &api.Backup{ ObjectMeta: metav1.ObjectMeta{ Namespace: "foo", Name: "bar-20170725141500", }, Spec: api.BackupSpec{}, }, }, { name: "ensure schedule backup template is copied", schedule: &api.Schedule{ ObjectMeta: metav1.ObjectMeta{ Namespace: "foo", Name: "bar", }, Spec: api.ScheduleSpec{ Template: api.BackupSpec{ IncludedNamespaces: []string{"ns-1", "ns-2"}, ExcludedNamespaces: []string{"ns-3"}, IncludedResources: []string{"foo", "bar"}, ExcludedResources: []string{"baz"}, LabelSelector: &metav1.LabelSelector{MatchLabels: map[string]string{"label": "value"}}, SnapshotVolumes: true, TTL: metav1.Duration{Duration: time.Duration(300)}, }, }, }, testClockTime: "2017-07-25 09:15:00", expectedBackup: &api.Backup{ ObjectMeta: metav1.ObjectMeta{ Namespace: "foo", Name: "bar-20170725091500", }, Spec: api.BackupSpec{ IncludedNamespaces: []string{"ns-1", "ns-2"}, ExcludedNamespaces: []string{"ns-3"}, IncludedResources: []string{"foo", "bar"}, ExcludedResources: []string{"baz"}, LabelSelector: &metav1.LabelSelector{MatchLabels: map[string]string{"label": "value"}}, SnapshotVolumes: true, TTL: metav1.Duration{Duration: time.Duration(300)}, }, }, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { testTime, err := time.Parse("2006-01-02 15:04:05", test.testClockTime) require.NoError(t, err, "unable to parse test.testClockTime: %v", err) backup := getBackup(test.schedule, clock.NewFakeClock(testTime).Now()) assert.Equal(t, test.expectedBackup.Namespace, backup.Namespace) assert.Equal(t, test.expectedBackup.Name, backup.Name) assert.Equal(t, test.expectedBackup.Spec, backup.Spec) }) } }