diff --git a/api/v1alpha1/rollout_types.go b/api/v1alpha1/rollout_types.go index d9baafc..4e17851 100644 --- a/api/v1alpha1/rollout_types.go +++ b/api/v1alpha1/rollout_types.go @@ -31,6 +31,9 @@ type RolloutSpec struct { // Important: Run "make" to regenerate code after modifying this file // ObjectRef indicates workload ObjectRef ObjectRef `json:"objectRef"` + //AllowRunTime define allow run time for the rollout + //+optional + AllowRunTime AllowRunTime `json:"allowRunTime,omitempty"` // rollout strategy Strategy RolloutStrategy `json:"strategy"` // RolloutID should be changed before each workload revision publication. @@ -70,6 +73,33 @@ type WorkloadRef struct { SourceRevisionName string `json:"sourceRevisionName"` }*/ +type AllowRunTime struct { + // TimeZone allowed user set the time zone + // +optional + TimeZone *TimeZone `json:"timeZone,omitempty"` + // TimeSlices define some time slices within which the CanaryStrategy is allowed to run in every day + // if not define, TimeSlices is all day + // +optional + TimeSlices []TimeSlice `json:"timeSlices,omitempty"` +} + +type TimeZone struct { + //Name of the time zone + Name string `json:"name"` + //Offset + // +kubebuilder:validation:Minimum=-43200 + // +kubebuilder:validation:Maximum=43200 + Offset int `json:"offset"` +} + +//TimeSlice define the start time and end time +type TimeSlice struct { + //StartTime is this TimeSlice start time + StartTime string `json:"startTime"` + //EndTime is this TimeSlice end time + EndTime string `json:"endTime"` +} + // RolloutStrategy defines strategy to apply during next rollout type RolloutStrategy struct { // Paused indicates that the Rollout is paused. diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 39eb6a6..c0aa0f0 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -26,6 +26,31 @@ import ( "k8s.io/apimachinery/pkg/util/intstr" ) +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AllowRunTime) DeepCopyInto(out *AllowRunTime) { + *out = *in + if in.TimeZone != nil { + in, out := &in.TimeZone, &out.TimeZone + *out = new(TimeZone) + **out = **in + } + if in.TimeSlices != nil { + in, out := &in.TimeSlices, &out.TimeSlices + *out = make([]TimeSlice, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AllowRunTime. +func (in *AllowRunTime) DeepCopy() *AllowRunTime { + if in == nil { + return nil + } + out := new(AllowRunTime) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *BatchRelease) DeepCopyInto(out *BatchRelease) { *out = *in @@ -428,6 +453,7 @@ func (in *RolloutPause) DeepCopy() *RolloutPause { func (in *RolloutSpec) DeepCopyInto(out *RolloutSpec) { *out = *in in.ObjectRef.DeepCopyInto(&out.ObjectRef) + in.AllowRunTime.DeepCopyInto(&out.AllowRunTime) in.Strategy.DeepCopyInto(&out.Strategy) } @@ -488,6 +514,36 @@ func (in *RolloutStrategy) DeepCopy() *RolloutStrategy { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TimeSlice) DeepCopyInto(out *TimeSlice) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TimeSlice. +func (in *TimeSlice) DeepCopy() *TimeSlice { + if in == nil { + return nil + } + out := new(TimeSlice) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TimeZone) DeepCopyInto(out *TimeZone) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TimeZone. +func (in *TimeZone) DeepCopy() *TimeZone { + if in == nil { + return nil + } + out := new(TimeZone) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *TrafficRouting) DeepCopyInto(out *TrafficRouting) { *out = *in diff --git a/config/crd/bases/rollouts.kruise.io_rollouts.yaml b/config/crd/bases/rollouts.kruise.io_rollouts.yaml index c7626aa..7bee9fb 100644 --- a/config/crd/bases/rollouts.kruise.io_rollouts.yaml +++ b/config/crd/bases/rollouts.kruise.io_rollouts.yaml @@ -56,6 +56,43 @@ spec: spec: description: RolloutSpec defines the desired state of Rollout properties: + allowRunTime: + description: AllowRunTime define allow run time for the rollout + properties: + timeSlices: + description: TimeSlices define some time slices within which the + CanaryStrategy is allowed to run in every day if not define, + TimeSlices is all day + items: + description: TimeSlice define the start time and end time + properties: + endTime: + description: EndTime is this TimeSlice end time + type: string + startTime: + description: StartTime is this TimeSlice start time + type: string + required: + - endTime + - startTime + type: object + type: array + timeZone: + description: TimeZone allowed user set the time zone + properties: + name: + description: Name of the time zone + type: string + offset: + description: Offset + maximum: 43200 + minimum: -43200 + type: integer + required: + - name + - offset + type: object + type: object objectRef: description: 'INSERT ADDITIONAL SPEC FIELDS - desired state of cluster Important: Run "make" to regenerate code after modifying this file diff --git a/pkg/controller/rollout/canary.go b/pkg/controller/rollout/canary.go index b430e1f..69f4f66 100644 --- a/pkg/controller/rollout/canary.go +++ b/pkg/controller/rollout/canary.go @@ -138,9 +138,28 @@ func (r *rolloutContext) doCanaryUpgrade() (bool, error) { return false, nil }*/ - // verify whether batchRelease configuration is the latest + // verify the step run time (now) whether in time slices steps := len(r.rollout.Spec.Strategy.Canary.Steps) canaryStatus := r.newStatus.CanaryStatus + cond := util.GetRolloutCondition(*r.newStatus, rolloutv1alpha1.RolloutConditionProgressing) + expectedTime, ok := r.isAllowRun(time.Now()) + if !ok { + localTime := expectedTime + if &r.rollout.Spec.AllowRunTime != nil { + localTime.In(util.TimeZone(r.rollout.Spec.AllowRunTime.TimeZone)) + } + msg := fmt.Sprintf("Rollout (%d/%d) will be start at time %s(%s), because now is not in time slices", + canaryStatus.CurrentStepIndex, steps, + expectedTime.Format(util.DateTimeLayout), + localTime.Format(util.DateTimeZoneLayout)) + klog.Info(msg) + cond.Message = msg + r.newStatus.Message = cond.Message + r.recheckTime = &expectedTime + return false, nil + } + + // verify whether batchRelease configuration is the latest isLatest, err := r.batchControl.Verify(canaryStatus.CurrentStepIndex) if err != nil { return false, err @@ -158,7 +177,6 @@ func (r *rolloutContext) doCanaryUpgrade() (bool, error) { return false, nil } batchData := util.DumpJSON(batch.Status) - cond := util.GetRolloutCondition(*r.newStatus, rolloutv1alpha1.RolloutConditionProgressing) cond.Message = fmt.Sprintf("Rollout is in step(%d/%d), and upgrade workload new versions", canaryStatus.CurrentStepIndex, steps) r.newStatus.Message = cond.Message // promote workload next batch release diff --git a/pkg/controller/rollout/context.go b/pkg/controller/rollout/context.go index d834205..62c93b5 100644 --- a/pkg/controller/rollout/context.go +++ b/pkg/controller/rollout/context.go @@ -108,3 +108,7 @@ func getRolloutID(workload *util.Workload, rollout *rolloutv1alpha1.Rollout) str } return "" } + +func (r *rolloutContext) isAllowRun(expectedTime time.Time) (time.Time, bool) { + return util.TimeInSlice(expectedTime, &r.rollout.Spec.AllowRunTime) +} diff --git a/pkg/util/time_utils.go b/pkg/util/time_utils.go new file mode 100644 index 0000000..b750ee7 --- /dev/null +++ b/pkg/util/time_utils.go @@ -0,0 +1,85 @@ +/* +Copyright 2022 The KubePort Authors. +*/ + +package util + +import ( + "fmt" + "time" + + rolloutv1alpha1 "github.com/openkruise/rollouts/api/v1alpha1" + "k8s.io/klog/v2" +) + +const ( + DateTimeZoneLayout = "2006-01-02 15:04:05 MST" + DateTimeLayout = "2006-01-02 15:04:05" + DateLayout = "2006-01-02" +) + +//ValidateTime used to validate _time whether right +func ValidateTime(date, _time string, zone *time.Location) (time.Time, error) { + if zone == nil { + zone = time.Local + } + if date == "" { + date = DateLayout + } + return time.ParseInLocation(DateTimeLayout, fmt.Sprintf("%s %s", date, _time), zone) +} + +func TimeZone(zone *rolloutv1alpha1.TimeZone) *time.Location { + if zone != nil { + return time.FixedZone(zone.Name, zone.Offset) + } + return time.Local +} + +//TimeInSlice used to validate the expectedTime whether in the timeSlices. +//it returns expectedTime and 'false' if the timeSlices is wrong,so you have to make sure the time Slice is correct. +//it returns expectedTime and 'true' if the expectedTime is in this timeSlices. +//it returns adjacent time and 'false' if the expectedTime is not in this timeSlices. +func TimeInSlice(expectedTime time.Time, allowRunTime *rolloutv1alpha1.AllowRunTime) (time.Time, bool) { + if allowRunTime == nil { + return expectedTime, true + } + if len(allowRunTime.TimeSlices) == 0 { + return expectedTime, true + } + var ( + err error + start time.Time + end time.Time + minSub = time.Hour * 48 + ) + + date := expectedTime.Format(DateLayout) + timeZone := TimeZone(allowRunTime.TimeZone) + + for i, timeSlice := range allowRunTime.TimeSlices { + start, err = ValidateTime(date, timeSlice.StartTime, timeZone) + if err != nil { + klog.V(5).Infof("timeSlices[%d] StartTime is err %s", i, err.Error()) + return expectedTime, false + } + end, err = ValidateTime(date, timeSlice.EndTime, timeZone) + if err != nil { + klog.V(5).Infof("timeSlices[%d] EndTime is err %s", i, err.Error()) + return expectedTime, false + } + + if expectedTime.After(start) && expectedTime.Before(end) { + return expectedTime, true + } + + subTime := start.Sub(expectedTime) + if subTime < 0 { + subTime += time.Hour * 24 + } + if subTime < minSub { + minSub = subTime + } + } + return expectedTime.Add(minSub), false +} diff --git a/pkg/util/time_utils_test.go b/pkg/util/time_utils_test.go new file mode 100644 index 0000000..849b2f9 --- /dev/null +++ b/pkg/util/time_utils_test.go @@ -0,0 +1,152 @@ +/* +Copyright 2022 The KubePort Authors. +*/ + +package util + +import ( + "testing" + "time" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + rolloutv1alpha1 "github.com/openkruise/rollouts/api/v1alpha1" +) + +var allowTime = &rolloutv1alpha1.AllowRunTime{ + TimeZone: nil, + TimeSlices: []rolloutv1alpha1.TimeSlice{ + { + StartTime: "00:00:00", + EndTime: "2:00:00", + }, + { + StartTime: "10:00:00", + EndTime: "12:00:00", + }, + { + StartTime: "16:00:00", + EndTime: "20:00:00", + }, + }, +} + +func TestTimeInSlice(t *testing.T) { + RegisterFailHandler(Fail) + test := []struct { + Name string + TestTime string + ExpectedTime string + ExpectedRes bool + }{ + { + Name: "in current slice", + TestTime: "2022-08-08 1:03:03", + ExpectedTime: "2022-08-08 1:03:03", + ExpectedRes: true, + }, + { + Name: "in current day", + TestTime: "2022-08-08 13:00:00", + ExpectedTime: "2022-08-08 16:00:00", + ExpectedRes: false, + }, + { + Name: "in next day", + TestTime: "2022-08-08 22:03:03", + ExpectedTime: "2022-08-09 00:00:00", + ExpectedRes: false, + }, + } + + for _, s := range test { + t.Run(s.Name, func(t *testing.T) { + testTime, _ := time.ParseInLocation(DateTimeLayout, s.TestTime, time.Local) + expectedTime, _ := time.ParseInLocation(DateTimeLayout, s.ExpectedTime, time.Local) + resTime, res := TimeInSlice(testTime, allowTime) + Expect(expectedTime.Unix()).Should(Equal(resTime.Unix())) + Expect(s.ExpectedRes).Should(Equal(res)) + }) + } +} + +func TestTimeZone(t *testing.T) { + RegisterFailHandler(Fail) + test := []struct { + name string + data *rolloutv1alpha1.TimeZone + expect *time.Location + }{ + { + name: "local", + data: nil, + expect: time.Local, + }, + { + name: "UTC", + data: &rolloutv1alpha1.TimeZone{ + Name: "UTC", + Offset: 28800, + }, + expect: time.FixedZone("UTC", 28800), + }, + } + for _, s := range test { + t.Run(s.name, func(t *testing.T) { + zone := TimeZone(s.data) + now := time.Now() + Expect(now.In(zone).Unix()).Should(Equal(now.In(s.expect).Unix())) + }) + } + +} + +func TestValidateTime(t *testing.T) { + RegisterFailHandler(Fail) + test := []struct { + name string + date string + time string + expect string + }{ + { + name: "right: date not empty", + date: "2022-08-08", + time: "00:00:00", + expect: "2022-08-08 00:00:00", + }, + { + name: "right: date is empty", + date: "", + time: "01:00:00", + expect: "2006-01-02 01:00:00", + }, + { + name: "wrong: time more then 24h", + date: "", + time: "25:00:00", + }, + { + name: "wrong: time less then 0h", + date: "", + time: "-01:00:00", + }, + { + name: "wrong: time is incomplete", + date: "", + time: "21:00", + }, + } + for _, s := range test { + t.Run(s.name, func(t *testing.T) { + resTime, err := ValidateTime(s.date, s.time, nil) + if s.expect != "" { + expectedTime, _ := time.ParseInLocation(DateTimeLayout, s.expect, time.Local) + Expect(expectedTime.Unix()).Should(Equal(resTime.Unix())) + } else { + Expect(len(err.Error()) != 0).Should(BeTrue()) + t.Log(err) + } + }) + } +} diff --git a/pkg/webhook/rollout/validating/rollout_create_update_handler.go b/pkg/webhook/rollout/validating/rollout_create_update_handler.go index 60d5765..910cb8f 100644 --- a/pkg/webhook/rollout/validating/rollout_create_update_handler.go +++ b/pkg/webhook/rollout/validating/rollout_create_update_handler.go @@ -140,6 +140,7 @@ func (h *RolloutCreateUpdateHandler) validateRolloutConflict(rollout *appsv1alph func validateRolloutSpec(rollout *appsv1alpha1.Rollout, fldPath *field.Path) field.ErrorList { errList := validateRolloutSpecObjectRef(&rollout.Spec.ObjectRef, fldPath.Child("ObjectRef")) + errList = append(errList, validateRolloutSpecAllowRunTime(&rollout.Spec.AllowRunTime, fldPath.Child("AllowRunTime"))...) errList = append(errList, validateRolloutSpecStrategy(&rollout.Spec.Strategy, fldPath.Child("Strategy"))...) return errList } @@ -156,6 +157,35 @@ func validateRolloutSpecObjectRef(objectRef *appsv1alpha1.ObjectRef, fldPath *fi return nil } +func validateRolloutSpecAllowRunTime(allowRunTime *appsv1alpha1.AllowRunTime, fldPath *field.Path) field.ErrorList { + if allowRunTime != nil { + return validateRolloutSpecTimeSlices(allowRunTime.TimeSlices, fldPath.Child("TimeSlices")) + } + return field.ErrorList{} +} +func validateRolloutSpecTimeSlices(timeSlices []appsv1alpha1.TimeSlice, fldPath *field.Path) field.ErrorList { + if len(timeSlices) == 0 { + return field.ErrorList{} + } + errList := field.ErrorList{} + for i, t := range timeSlices { + //Parse time, TimeDuration need >= 0 && <= 86400 + start, err := util.ValidateTime("", t.StartTime, nil) + if err != nil { + return append(errList, field.Invalid(fldPath.Index(i).Child("StartTime"), t.StartTime, err.Error())) + } + end, err := util.ValidateTime("", t.EndTime, nil) + if err != nil { + return append(errList, field.Invalid(fldPath.Index(i).Child("EndTime"), t.EndTime, err.Error())) + } + //startTime need less than endTime + if start.After(end) { + return append(errList, field.Invalid(fldPath.Index(i).Child("timeRange"), t, "startTime need less then endTime")) + } + } + return errList +} + func validateRolloutSpecStrategy(strategy *appsv1alpha1.RolloutStrategy, fldPath *field.Path) field.ErrorList { return validateRolloutSpecCanaryStrategy(strategy.Canary, fldPath.Child("Canary")) } diff --git a/pkg/webhook/rollout/validating/rollout_create_update_handler_test.go b/pkg/webhook/rollout/validating/rollout_create_update_handler_test.go index 1fe8f3c..e4af201 100644 --- a/pkg/webhook/rollout/validating/rollout_create_update_handler_test.go +++ b/pkg/webhook/rollout/validating/rollout_create_update_handler_test.go @@ -35,6 +35,22 @@ var ( Name: "deployment-demo", }, }, + AllowRunTime: appsv1alpha1.AllowRunTime{ + TimeSlices: []appsv1alpha1.TimeSlice{ + { + StartTime: "00:00:00", + EndTime: "2:00:00", + }, + { + StartTime: "10:00:00", + EndTime: "12:00:00", + }, + { + StartTime: "16:00:00", + EndTime: "20:00:00", + }, + }, + }, Strategy: appsv1alpha1.RolloutStrategy{ Canary: &appsv1alpha1.CanaryStrategy{ Steps: []appsv1alpha1.CanaryStep{ @@ -95,6 +111,15 @@ func TestRolloutValidateCreate(t *testing.T) { return []client.Object{rollout.DeepCopy()} }, }, + { + Name: "Normal case : time slices is nil", + Succeed: true, + GetObject: func() []client.Object { + object := rollout.DeepCopy() + object.Spec.AllowRunTime.TimeSlices = nil + return []client.Object{object} + }, + }, /*********************************************************** The following cases may lead to controller panic **********************************************************/ @@ -119,6 +144,44 @@ func TestRolloutValidateCreate(t *testing.T) { /**************************************************************** The following cases may lead to that controller cannot work ***************************************************************/ + { + Name: "TimeSlices time is empty", + Succeed: false, + GetObject: func() []client.Object { + object := rollout.DeepCopy() + object.Spec.AllowRunTime.TimeSlices[0].StartTime = "" + object.Spec.AllowRunTime.TimeSlices[0].EndTime = "" + return []client.Object{object} + }, + }, + { + Name: "TimeSlices time is incomplete", + Succeed: false, + GetObject: func() []client.Object { + object := rollout.DeepCopy() + object.Spec.AllowRunTime.TimeSlices[0].StartTime = "00:00" + return []client.Object{object} + }, + }, + { + Name: "TimeSlices start time more than end time", + Succeed: false, + GetObject: func() []client.Object { + object := rollout.DeepCopy() + object.Spec.AllowRunTime.TimeSlices[0].StartTime = "02:00:00" + object.Spec.AllowRunTime.TimeSlices[0].EndTime = "00:00:00" + return []client.Object{object} + }, + }, + { + Name: "TimeSlices time out of range", + Succeed: false, + GetObject: func() []client.Object { + object := rollout.DeepCopy() + object.Spec.AllowRunTime.TimeSlices[0].EndTime = "26:00:00" + return []client.Object{object} + }, + }, { Name: "Service name is empty", Succeed: false,