diff --git a/apis/actions.github.com/v1alpha1/autoscalingrunnerset_types.go b/apis/actions.github.com/v1alpha1/autoscalingrunnerset_types.go index d9de4216..237c0493 100644 --- a/apis/actions.github.com/v1alpha1/autoscalingrunnerset_types.go +++ b/apis/actions.github.com/v1alpha1/autoscalingrunnerset_types.go @@ -41,6 +41,8 @@ import ( //+kubebuilder:printcolumn:JSONPath=".status.runningEphemeralRunners",name=Running Runners,type=integer //+kubebuilder:printcolumn:JSONPath=".status.finishedEphemeralRunners",name=Finished Runners,type=integer //+kubebuilder:printcolumn:JSONPath=".status.deletingEphemeralRunners",name=Deleting Runners,type=integer +//+kubebuilder:printcolumn:JSONPath=".status.desiredMinRunners",name=Desired Minimum Runners,type=integer +//+kubebuilder:printcolumn:JSONPath=".status.scheduledOverridesSummary",name=Schedule,type=string // AutoscalingRunnerSet is the Schema for the autoscalingrunnersets API type AutoscalingRunnerSet struct { @@ -84,6 +86,13 @@ type AutoscalingRunnerSetSpec struct { // +optional // +kubebuilder:validation:Minimum:=0 MinRunners *int `json:"minRunners,omitempty"` + + // +optional + // ScheduledOverrides is the list of ScheduledOverride. + // It can be used to override a few fields of AutoscalingRunnerSetSpec on schedule. + // The earlier a scheduled override is, the higher it is prioritized. + // +optional + ScheduledOverrides []ScheduledOverride `json:"scheduledOverrides,omitempty"` } type GitHubServerTLSConfig struct { @@ -232,6 +241,40 @@ type ProxyServerConfig struct { CredentialSecretRef string `json:"credentialSecretRef,omitempty"` } +// ScheduledOverride can be used to override a few fields of AutoscalingRunnerSetSpec on schedule. +// A schedule can optionally be recurring, so that the corresponding override happens every day, week, month, or year. +type ScheduledOverride struct { + // StartTime is the time at which the first override starts. + StartTime metav1.Time `json:"startTime"` + + // EndTime is the time at which the first override ends. + EndTime metav1.Time `json:"endTime"` + + // MinRunners is the number of runners while overriding. + // If omitted, it doesn't override minRunners. + // +optional + // +nullable + // +kubebuilder:validation:Minimum=0 + MinRunners *int `json:"minRunners,omitempty"` + + // +optional + RecurrenceRule RecurrenceRule `json:"recurrenceRule,omitempty"` +} + +type RecurrenceRule struct { + // Frequency is the name of a predefined interval of each recurrence. + // The valid values are "Daily", "Weekly", "Monthly", and "Yearly". + // If empty, the corresponding override happens only once. + // +optional + // +kubebuilder:validation:Enum=Daily;Weekly;Monthly;Yearly + Frequency string `json:"frequency,omitempty"` + + // UntilTime is the time of the final recurrence. + // If empty, the schedule recurs forever. + // +optional + UntilTime metav1.Time `json:"untilTime,omitempty"` +} + // AutoscalingRunnerSetStatus defines the observed state of AutoscalingRunnerSet type AutoscalingRunnerSetStatus struct { // +optional @@ -248,6 +291,12 @@ type AutoscalingRunnerSetStatus struct { RunningEphemeralRunners int `json:"runningEphemeralRunners"` // +optional FailedEphemeralRunners int `json:"failedEphemeralRunners"` + + // +optional + // +kubebuilder:validation:Minimum:=0 + DesiredMinRunners int `json:"desiredMinRunners"` + // +optional + ScheduledOverridesSummary *string `json:"scheduledOverridesSummary,omitempty"` } func (ars *AutoscalingRunnerSet) ListenerSpecHash() string { diff --git a/charts/gha-runner-scale-set/templates/autoscalingrunnerset.yaml b/charts/gha-runner-scale-set/templates/autoscalingrunnerset.yaml index c5ad2e38..dd83d7a6 100644 --- a/charts/gha-runner-scale-set/templates/autoscalingrunnerset.yaml +++ b/charts/gha-runner-scale-set/templates/autoscalingrunnerset.yaml @@ -106,6 +106,11 @@ spec: minRunners: {{ .Values.minRunners | int }} {{- end }} + {{- if and .Values.scheduledOverrides (kindIs "slice" .Values.scheduledOverrides) }} + scheduledOverrides: + {{- .Values.scheduledOverrides | toYaml | nindent 4 }} + {{- end }} + {{- with .Values.listenerTemplate}} listenerTemplate: {{- toYaml . | nindent 4}} diff --git a/charts/gha-runner-scale-set/values.yaml b/charts/gha-runner-scale-set/values.yaml index 7c32b436..631d75d2 100644 --- a/charts/gha-runner-scale-set/values.yaml +++ b/charts/gha-runner-scale-set/values.yaml @@ -58,6 +58,11 @@ githubConfigSecret: ## calculated as a sum of minRunners and the number of jobs assigned to the scale set. # minRunners: 0 +## scheduledOverrides is a list of scheduled overrides to the minReplicas. +## See docs for more info: https://github.com/actions/actions-runner-controller/blob/master/docs/automatically-scaling-runners.md#scheduled-overrides +## Please note that the field minReplicas was renamed to minRunners +# scheduledOverrides: [] + # runnerGroup: "default" ## name of the runner scale set to create. Defaults to the helm release name @@ -205,7 +210,6 @@ template: - name: runner image: ghcr.io/actions/actions-runner:latest command: ["/home/runner/run.sh"] - ## Optional controller service account that needs to have required Role and RoleBinding ## to operate this gha-runner-scale-set installation. ## The helm chart will try to find the controller deployment and its service account at installation time. diff --git a/controllers/actions.github.com/autoscalingrunnerset_controller.go b/controllers/actions.github.com/autoscalingrunnerset_controller.go index f6ea15f4..caaca3e5 100644 --- a/controllers/actions.github.com/autoscalingrunnerset_controller.go +++ b/controllers/actions.github.com/autoscalingrunnerset_controller.go @@ -22,6 +22,7 @@ import ( "sort" "strconv" "strings" + "time" "github.com/actions/actions-runner-controller/apis/actions.github.com/v1alpha1" "github.com/actions/actions-runner-controller/build" @@ -244,10 +245,17 @@ func (r *AutoscalingRunnerSetReconciler) Reconcile(ctx context.Context, req ctrl log.Info("AutoscalingListener does not exist.") } + desiredMinRunnersChanged := false + // If the listener's minRunners value is different from the autoscalingRunnerSet desired minRunners, we recreate the listener + if listenerFound && listener.Spec.MinRunners != autoscalingRunnerSet.Status.DesiredMinRunners { + log.Info("RunnerScaleSetListener minRunners is different from the autoscalingRunnerSet desired minRunners", "current", listener.Spec.MinRunners, "desired", autoscalingRunnerSet.Status.DesiredMinRunners) + desiredMinRunnersChanged = true + } + // Our listener pod is out of date, so we need to delete it to get a new recreate. listenerValuesHashChanged := listener.Annotations[annotationKeyValuesHash] != autoscalingRunnerSet.Annotations[annotationKeyValuesHash] listenerSpecHashChanged := listener.Annotations[annotationKeyRunnerSpecHash] != autoscalingRunnerSet.ListenerSpecHash() - if listenerFound && (listenerValuesHashChanged || listenerSpecHashChanged) { + if listenerFound && (listenerValuesHashChanged || listenerSpecHashChanged || desiredMinRunnersChanged) { log.Info("RunnerScaleSetListener is out of date. Deleting it so that it is recreated", "name", listener.Name) if err := r.Delete(ctx, listener); err != nil { if kerrors.IsNotFound(err) { @@ -300,20 +308,61 @@ func (r *AutoscalingRunnerSetReconciler) Reconcile(ctx context.Context, req ctrl return r.createAutoScalingListenerForRunnerSet(ctx, autoscalingRunnerSet, latestRunnerSet, log) } - // Update the status of autoscaling runner set. - if latestRunnerSet.Status.CurrentReplicas != autoscalingRunnerSet.Status.CurrentRunners { - if err := patchSubResource(ctx, r.Status(), autoscalingRunnerSet, func(obj *v1alpha1.AutoscalingRunnerSet) { - obj.Status.CurrentRunners = latestRunnerSet.Status.CurrentReplicas - obj.Status.PendingEphemeralRunners = latestRunnerSet.Status.PendingEphemeralRunners - obj.Status.RunningEphemeralRunners = latestRunnerSet.Status.RunningEphemeralRunners - obj.Status.FailedEphemeralRunners = latestRunnerSet.Status.FailedEphemeralRunners - }); err != nil { - log.Error(err, "Failed to update autoscaling runner set status with current runner count") - return ctrl.Result{}, err - } + minRunners, active, upcoming, err := r.getMinRunners(log, time.Now(), *autoscalingRunnerSet) + if err != nil { + log.Error(err, "Could not compute desired min runners") + + return ctrl.Result{}, err } - return ctrl.Result{}, nil + var overridesSummary string + + currentReplicasOutOfDate := latestRunnerSet.Status.CurrentReplicas != autoscalingRunnerSet.Status.CurrentRunners + minReplicasOutOfDate := autoscalingRunnerSet.Status.DesiredMinRunners != minRunners + + // Update the status of autoscaling runner set. + if minReplicasOutOfDate || currentReplicasOutOfDate { + if err := patchSubResource(ctx, r.Status(), autoscalingRunnerSet, func(obj *v1alpha1.AutoscalingRunnerSet) { + if currentReplicasOutOfDate { + obj.Status.CurrentRunners = latestRunnerSet.Status.CurrentReplicas + obj.Status.PendingEphemeralRunners = latestRunnerSet.Status.PendingEphemeralRunners + obj.Status.RunningEphemeralRunners = latestRunnerSet.Status.RunningEphemeralRunners + obj.Status.FailedEphemeralRunners = latestRunnerSet.Status.FailedEphemeralRunners + } + + if minReplicasOutOfDate { + obj.Status.DesiredMinRunners = minRunners + + if (active != nil && upcoming == nil) || (active != nil && upcoming != nil && active.Period.EndTime.Before(upcoming.Period.StartTime)) { + var after int + if obj.Status.DesiredMinRunners >= 0 { + after = obj.Status.DesiredMinRunners + } + + overridesSummary = fmt.Sprintf("min=%d status=active endTime=%s", after, active.Period.EndTime) + } + + if active == nil && upcoming != nil || (active != nil && upcoming != nil && active.Period.EndTime.After(upcoming.Period.StartTime)) { + if upcoming.ScheduledOverride.MinRunners != nil { + overridesSummary = fmt.Sprintf("min=%d status=upcoming startTime=%s", *upcoming.ScheduledOverride.MinRunners, upcoming.Period.StartTime) + } + } + + if overridesSummary != "" { + obj.Status.ScheduledOverridesSummary = &overridesSummary + } else { + obj.Status.ScheduledOverridesSummary = nil + } + } + }); err != nil { + log.Error(err, "Failed to update autoscaling runner set status") + return ctrl.Result{}, err + } + + return ctrl.Result{}, nil + } + + return ctrl.Result{RequeueAfter: DefaultScaleSetHealthyRequeueAfter}, nil } // Prevents overprovisioning of runners. @@ -766,6 +815,84 @@ func (r *AutoscalingRunnerSetReconciler) SetupWithManager(mgr ctrl.Manager) erro Complete(r) } +type Override struct { + ScheduledOverride v1alpha1.ScheduledOverride + Period Period +} + +func (r *AutoscalingRunnerSetReconciler) matchScheduledOverrides(log logr.Logger, now time.Time, autoscalingRunnerSet v1alpha1.AutoscalingRunnerSet) (*int, *Override, *Override, error) { + var minRunners *int + var active, upcoming *Override + + for _, o := range autoscalingRunnerSet.Spec.ScheduledOverrides { + log.V(1).Info( + "Checking scheduled override", + "now", now, + "startTime", o.StartTime, + "endTime", o.EndTime, + "frequency", o.RecurrenceRule.Frequency, + "untilTime", o.RecurrenceRule.UntilTime, + ) + + a, u, err := MatchSchedule( + now, o.StartTime.Time, o.EndTime.Time, + RecurrenceRule{ + Frequency: o.RecurrenceRule.Frequency, + UntilTime: o.RecurrenceRule.UntilTime.Time, + }, + ) + if err != nil { + return minRunners, nil, nil, err + } + + // Use the first when there are two or more active scheduled overrides, + // as the spec defines that the earlier scheduled override is prioritized higher than later ones. + if a != nil && active == nil { + active = &Override{Period: *a, ScheduledOverride: o} + + if o.MinRunners != nil { + minRunners = o.MinRunners + + log.V(1).Info( + "Found active scheduled override", + "activeStartTime", a.StartTime, + "activeEndTime", a.EndTime, + "activeMinRunners", minRunners, + ) + } + } + + if u != nil && (upcoming == nil || u.StartTime.Before(upcoming.Period.StartTime)) { + upcoming = &Override{Period: *u, ScheduledOverride: o} + + log.V(1).Info( + "Found upcoming scheduled override", + "upcomingStartTime", u.StartTime, + "upcomingEndTime", u.EndTime, + "upcomingMinRunners", o.MinRunners, + ) + } + } + + return minRunners, active, upcoming, nil +} + +func (r *AutoscalingRunnerSetReconciler) getMinRunners(log logr.Logger, now time.Time, autoscalingRunnerSet v1alpha1.AutoscalingRunnerSet) (int, *Override, *Override, error) { + var minRunners int + if autoscalingRunnerSet.Spec.MinRunners != nil && *autoscalingRunnerSet.Spec.MinRunners >= 0 { + minRunners = *autoscalingRunnerSet.Spec.MinRunners + } + + m, active, upcoming, err := r.matchScheduledOverrides(log, now, autoscalingRunnerSet) + if err != nil { + return 0, nil, nil, err + } else if m != nil { + minRunners = *m + } + + return minRunners, active, upcoming, nil +} + type autoscalingRunnerSetFinalizerDependencyCleaner struct { // configuration fields client client.Client diff --git a/controllers/actions.github.com/autoscalingrunnerset_controller_test.go b/controllers/actions.github.com/autoscalingrunnerset_controller_test.go index 5609fe41..2d7f00b6 100644 --- a/controllers/actions.github.com/autoscalingrunnerset_controller_test.go +++ b/controllers/actions.github.com/autoscalingrunnerset_controller_test.go @@ -585,6 +585,101 @@ var _ = Describe("Test AutoScalingRunnerSet controller", Ordered, func() { }) }) + Context("When a scheduled override is active in an AutoscalingRunnerSet", func() { + It("It should override min runners in the AutoscalingListener", func() { + // Wait till the listener is created + listener := new(v1alpha1.AutoscalingListener) + Eventually( + func() error { + return k8sClient.Get(ctx, client.ObjectKey{Name: scaleSetListenerName(autoscalingRunnerSet), Namespace: autoscalingRunnerSet.Namespace}, listener) + }, + autoscalingRunnerSetTestTimeout, + autoscalingRunnerSetTestInterval, + ).Should(Succeed(), "Listener should be created") + + runnerSetList := new(v1alpha1.EphemeralRunnerSetList) + err := k8sClient.List(ctx, runnerSetList, client.InNamespace(autoscalingRunnerSet.Namespace)) + Expect(err).NotTo(HaveOccurred(), "failed to list EphemeralRunnerSet") + Expect(len(runnerSetList.Items)).To(Equal(1), "There should be 1 EphemeralRunnerSet") + runnerSet := runnerSetList.Items[0] + + minOverride := 0 + + // Patch the AutoScalingRunnerSet with a scheduled override + patched := autoscalingRunnerSet.DeepCopy() + patched.Spec.ScheduledOverrides = []v1alpha1.ScheduledOverride{ + { + StartTime: metav1.Now(), + EndTime: metav1.NewTime(metav1.Now().Add(1 * time.Hour)), + MinRunners: &minOverride, + }, + } + err = k8sClient.Patch(ctx, patched, client.MergeFrom(autoscalingRunnerSet)) + Expect(err).NotTo(HaveOccurred(), "failed to patch AutoScalingRunnerSet") + autoscalingRunnerSet = patched.DeepCopy() + + // We should not re-create a new EphemeralRunnerSet + Consistently( + func() (string, error) { + runnerSetList := new(v1alpha1.EphemeralRunnerSetList) + err := k8sClient.List(ctx, runnerSetList, client.InNamespace(autoscalingRunnerSet.Namespace)) + if err != nil { + return "", err + } + + if len(runnerSetList.Items) != 1 { + return "", fmt.Errorf("We should have only 1 EphemeralRunnerSet, but got %v", len(runnerSetList.Items)) + } + + return string(runnerSetList.Items[0].UID), nil + }, + autoscalingRunnerSetTestTimeout, + autoscalingRunnerSetTestInterval).Should(BeEquivalentTo(string(runnerSet.UID)), "New EphemeralRunnerSet should not be created") + + // We should only re-create a new listener + Eventually( + func() (string, error) { + listener := new(v1alpha1.AutoscalingListener) + err := k8sClient.Get(ctx, client.ObjectKey{Name: scaleSetListenerName(autoscalingRunnerSet), Namespace: autoscalingRunnerSet.Namespace}, listener) + if err != nil { + return "", err + } + + return string(listener.UID), nil + }, + autoscalingRunnerSetTestTimeout, + autoscalingRunnerSetTestInterval).ShouldNot(BeEquivalentTo(string(listener.UID)), "New Listener should be created") + + // Check if the listener has the min runners override + Expect(listener.Spec.MinRunners).To(Equal(minOverride), "Listener should have the min runners override") + + desiredScheduledOverridesSummary := fmt.Sprintf("min=%d status=active endTime=%s", *autoscalingRunnerSet.Spec.ScheduledOverrides[0].MinRunners, autoscalingRunnerSet.Spec.ScheduledOverrides[0].EndTime.Time) + + desiredStatus := v1alpha1.AutoscalingRunnerSetStatus{ + CurrentRunners: 0, + State: "", + PendingEphemeralRunners: 0, + RunningEphemeralRunners: 0, + FailedEphemeralRunners: 0, + ScheduledOverridesSummary: &desiredScheduledOverridesSummary, + DesiredMinRunners: 0, + } + + Eventually( + func() (v1alpha1.AutoscalingRunnerSetStatus, error) { + updated := new(v1alpha1.AutoscalingRunnerSet) + err := k8sClient.Get(ctx, client.ObjectKey{Name: autoscalingRunnerSet.Name, Namespace: autoscalingRunnerSet.Namespace}, updated) + if err != nil { + return v1alpha1.AutoscalingRunnerSetStatus{}, fmt.Errorf("failed to get AutoScalingRunnerSet: %w", err) + } + return updated.Status, nil + }, + autoscalingRunnerSetTestTimeout, + autoscalingRunnerSetTestInterval, + ).Should(BeEquivalentTo(desiredStatus), "AutoScalingRunnerSet status should be updated") + }) + }) + It("Should update Status on EphemeralRunnerSet status Update", func() { ars := new(v1alpha1.AutoscalingRunnerSet) Eventually( diff --git a/controllers/actions.github.com/constants.go b/controllers/actions.github.com/constants.go index cbf3309c..8df48c74 100644 --- a/controllers/actions.github.com/constants.go +++ b/controllers/actions.github.com/constants.go @@ -1,6 +1,8 @@ package actionsgithubcom import ( + "time" + "github.com/actions/actions-runner-controller/logging" ) @@ -68,6 +70,9 @@ const DefaultScaleSetListenerLogLevel = string(logging.LogLevelDebug) // DefaultScaleSetListenerLogFormat is the default log format applied const DefaultScaleSetListenerLogFormat = string(logging.LogFormatText) +// DefaultScaleSetHealthyRequeueAfter is the default requeue time for healthy scale sets +const DefaultScaleSetHealthyRequeueAfter = time.Duration(1 * time.Minute) + // ownerKey is field selector matching the owner name of a particular resource const resourceOwnerKey = ".metadata.controller" diff --git a/controllers/actions.github.com/resourcebuilder.go b/controllers/actions.github.com/resourcebuilder.go index 7a26f889..ffdeba88 100644 --- a/controllers/actions.github.com/resourcebuilder.go +++ b/controllers/actions.github.com/resourcebuilder.go @@ -79,14 +79,11 @@ func (b *ResourceBuilder) newAutoScalingListener(autoscalingRunnerSet *v1alpha1. return nil, err } - effectiveMinRunners := 0 + effectiveMinRunners := autoscalingRunnerSet.Status.DesiredMinRunners effectiveMaxRunners := math.MaxInt32 if autoscalingRunnerSet.Spec.MaxRunners != nil { effectiveMaxRunners = *autoscalingRunnerSet.Spec.MaxRunners } - if autoscalingRunnerSet.Spec.MinRunners != nil { - effectiveMinRunners = *autoscalingRunnerSet.Spec.MinRunners - } labels := b.mergeLabels(autoscalingRunnerSet.Labels, map[string]string{ LabelKeyGitHubScaleSetNamespace: autoscalingRunnerSet.Namespace, diff --git a/controllers/actions.github.com/schedule.go b/controllers/actions.github.com/schedule.go new file mode 100644 index 00000000..09bda636 --- /dev/null +++ b/controllers/actions.github.com/schedule.go @@ -0,0 +1,122 @@ +package actionsgithubcom + +import ( + "fmt" + "time" + + "github.com/teambition/rrule-go" +) + +type RecurrenceRule struct { + Frequency string + UntilTime time.Time +} + +type Period struct { + StartTime time.Time + EndTime time.Time +} + +func (r *Period) String() string { + if r == nil { + return "" + } + + return r.StartTime.Format(time.RFC3339) + "-" + r.EndTime.Format(time.RFC3339) +} + +func MatchSchedule(now time.Time, startTime, endTime time.Time, recurrenceRule RecurrenceRule) (*Period, *Period, error) { + return calculateActiveAndUpcomingRecurringPeriods( + now, + startTime, + endTime, + recurrenceRule.Frequency, + recurrenceRule.UntilTime, + ) +} + +func calculateActiveAndUpcomingRecurringPeriods(now, startTime, endTime time.Time, frequency string, untilTime time.Time) (*Period, *Period, error) { + var freqValue rrule.Frequency + + var freqDurationDay int + var freqDurationMonth int + var freqDurationYear int + + switch frequency { + case "Daily": + freqValue = rrule.DAILY + freqDurationDay = 1 + case "Weekly": + freqValue = rrule.WEEKLY + freqDurationDay = 7 + case "Monthly": + freqValue = rrule.MONTHLY + freqDurationMonth = 1 + case "Yearly": + freqValue = rrule.YEARLY + freqDurationYear = 1 + case "": + if now.Before(startTime) { + return nil, &Period{StartTime: startTime, EndTime: endTime}, nil + } + + if now.Before(endTime) { + return &Period{StartTime: startTime, EndTime: endTime}, nil, nil + } + + return nil, nil, nil + default: + return nil, nil, fmt.Errorf(`invalid freq %q: It must be one of "Daily", "Weekly", "Monthly", and "Yearly"`, frequency) + } + + freqDurationLater := time.Date( + now.Year()+freqDurationYear, + time.Month(int(now.Month())+freqDurationMonth), + now.Day()+freqDurationDay, + now.Hour(), now.Minute(), now.Second(), now.Nanosecond(), now.Location(), + ) + + freqDuration := freqDurationLater.Sub(now) + + overrideDuration := endTime.Sub(startTime) + if overrideDuration > freqDuration { + return nil, nil, fmt.Errorf("override's duration %s must be equal to or shorter than the duration implied by freq %q (%s)", overrideDuration, frequency, freqDuration) + } + + rrule, err := rrule.NewRRule(rrule.ROption{ + Freq: freqValue, + Dtstart: startTime, + Until: untilTime, + }) + if err != nil { + return nil, nil, err + } + + overrideDurationBefore := now.Add(-overrideDuration + 1) + activeOverrideStarts := rrule.Between(overrideDurationBefore, now, true) + + var active *Period + + if len(activeOverrideStarts) > 1 { + return nil, nil, fmt.Errorf("[bug] unexpted number of active overrides found: %v", activeOverrideStarts) + } else if len(activeOverrideStarts) == 1 { + active = &Period{ + StartTime: activeOverrideStarts[0], + EndTime: activeOverrideStarts[0].Add(overrideDuration), + } + } + + oneSecondLater := now.Add(1) + upcomingOverrideStarts := rrule.Between(oneSecondLater, freqDurationLater, true) + + var next *Period + + if len(upcomingOverrideStarts) > 0 { + next = &Period{ + StartTime: upcomingOverrideStarts[0], + EndTime: upcomingOverrideStarts[0].Add(overrideDuration), + } + } + + return active, next, nil +} diff --git a/controllers/actions.github.com/schedule_test.go b/controllers/actions.github.com/schedule_test.go new file mode 100644 index 00000000..1c13068a --- /dev/null +++ b/controllers/actions.github.com/schedule_test.go @@ -0,0 +1,617 @@ +package actionsgithubcom + +import ( + "testing" + "time" +) + +func TestCalculateActiveAndUpcomingRecurringPeriods(t *testing.T) { + type recurrence struct { + Start string + End string + Freq string + Until string + } + + type testcase struct { + now string + + recurrence recurrence + + wantActive string + wantUpcoming string + } + + check := func(t *testing.T, tc testcase) { + t.Helper() + + _, err := time.Parse(time.RFC3339, "2021-05-08T00:00:00Z") + if err != nil { + t.Fatal(err) + } + + now, err := time.Parse(time.RFC3339, tc.now) + if err != nil { + t.Fatal(err) + } + + active, upcoming, err := parseAndMatchRecurringPeriod(now, tc.recurrence.Start, tc.recurrence.End, tc.recurrence.Freq, tc.recurrence.Until) + if err != nil { + t.Fatal(err) + } + + if active.String() != tc.wantActive { + t.Errorf("unexpected active: want %q, got %q", tc.wantActive, active) + } + + if upcoming.String() != tc.wantUpcoming { + t.Errorf("unexpected upcoming: want %q, got %q", tc.wantUpcoming, upcoming) + } + } + + t.Run("onetime override about to start", func(t *testing.T) { + t.Helper() + + check(t, testcase{ + recurrence: recurrence{ + Start: "2021-05-01T00:00:00+09:00", + End: "2021-05-03T00:00:00+09:00", + }, + + now: "2021-04-30T23:59:59+09:00", + + wantActive: "", + wantUpcoming: "2021-05-01T00:00:00+09:00-2021-05-03T00:00:00+09:00", + }) + }) + + t.Run("onetime override started", func(t *testing.T) { + t.Helper() + + check(t, testcase{ + recurrence: recurrence{ + Start: "2021-05-01T00:00:00+09:00", + End: "2021-05-03T00:00:00+09:00", + }, + + now: "2021-05-01T00:00:00+09:00", + + wantActive: "2021-05-01T00:00:00+09:00-2021-05-03T00:00:00+09:00", + wantUpcoming: "", + }) + }) + + t.Run("onetime override about to end", func(t *testing.T) { + t.Helper() + + check(t, testcase{ + recurrence: recurrence{ + Start: "2021-05-01T00:00:00+09:00", + End: "2021-05-03T00:00:00+09:00", + }, + + now: "2021-05-02T23:59:59+09:00", + + wantActive: "2021-05-01T00:00:00+09:00-2021-05-03T00:00:00+09:00", + wantUpcoming: "", + }) + }) + + t.Run("onetime override ended", func(t *testing.T) { + t.Helper() + + check(t, testcase{ + recurrence: recurrence{ + Start: "2021-05-01T00:00:00+09:00", + End: "2021-05-03T00:00:00+09:00", + }, + + now: "2021-05-03T00:00:00+09:00", + + wantActive: "", + wantUpcoming: "", + }) + }) + + t.Run("weekly override about to start", func(t *testing.T) { + t.Helper() + + check(t, testcase{ + recurrence: recurrence{ + Start: "2021-05-01T00:00:00+09:00", + End: "2021-05-03T00:00:00+09:00", + Freq: "Weekly", + Until: "2022-05-01T00:00:00+09:00", + }, + + now: "2021-04-30T23:59:59+09:00", + + wantActive: "", + wantUpcoming: "2021-05-01T00:00:00+09:00-2021-05-03T00:00:00+09:00", + }) + }) + + t.Run("weekly override started", func(t *testing.T) { + t.Helper() + + check(t, testcase{ + recurrence: recurrence{ + Start: "2021-05-01T00:00:00+09:00", + End: "2021-05-03T00:00:00+09:00", + Freq: "Weekly", + Until: "2022-05-01T00:00:00+09:00", + }, + + now: "2021-05-01T00:00:00+09:00", + + wantActive: "2021-05-01T00:00:00+09:00-2021-05-03T00:00:00+09:00", + wantUpcoming: "2021-05-08T00:00:00+09:00-2021-05-10T00:00:00+09:00", + }) + }) + + t.Run("weekly override about to end", func(t *testing.T) { + t.Helper() + + check(t, testcase{ + recurrence: recurrence{ + Start: "2021-05-01T00:00:00+09:00", + End: "2021-05-03T00:00:00+09:00", + Freq: "Weekly", + Until: "2022-05-01T00:00:00+09:00", + }, + + now: "2021-05-02T23:59:59+09:00", + + wantActive: "2021-05-01T00:00:00+09:00-2021-05-03T00:00:00+09:00", + wantUpcoming: "2021-05-08T00:00:00+09:00-2021-05-10T00:00:00+09:00", + }) + }) + + t.Run("weekly override ended", func(t *testing.T) { + t.Helper() + + check(t, testcase{ + recurrence: recurrence{ + Start: "2021-05-01T00:00:00+09:00", + End: "2021-05-03T00:00:00+09:00", + Freq: "Weekly", + Until: "2022-05-01T00:00:00+09:00", + }, + + now: "2021-05-03T00:00:00+09:00", + + wantActive: "", + wantUpcoming: "2021-05-08T00:00:00+09:00-2021-05-10T00:00:00+09:00", + }) + }) + + t.Run("weekly override reccurrence about to start", func(t *testing.T) { + t.Helper() + + check(t, testcase{ + recurrence: recurrence{ + Start: "2021-05-01T00:00:00+09:00", + End: "2021-05-03T00:00:00+09:00", + Freq: "Weekly", + Until: "2022-05-01T00:00:00+09:00", + }, + + now: "2021-05-07T23:59:59+09:00", + + wantActive: "", + wantUpcoming: "2021-05-08T00:00:00+09:00-2021-05-10T00:00:00+09:00", + }) + }) + + t.Run("weekly override reccurrence started", func(t *testing.T) { + t.Helper() + + check(t, testcase{ + recurrence: recurrence{ + Start: "2021-05-01T00:00:00+09:00", + End: "2021-05-03T00:00:00+09:00", + Freq: "Weekly", + Until: "2022-05-01T00:00:00+09:00", + }, + + now: "2021-05-08T00:00:00+09:00", + + wantActive: "2021-05-08T00:00:00+09:00-2021-05-10T00:00:00+09:00", + wantUpcoming: "2021-05-15T00:00:00+09:00-2021-05-17T00:00:00+09:00", + }) + }) + + t.Run("weekly override reccurrence about to end", func(t *testing.T) { + t.Helper() + + check(t, testcase{ + recurrence: recurrence{ + Start: "2021-05-01T00:00:00+09:00", + End: "2021-05-03T00:00:00+09:00", + Freq: "Weekly", + Until: "2022-05-01T00:00:00+09:00", + }, + + now: "2021-05-09T23:59:59+09:00", + + wantActive: "2021-05-08T00:00:00+09:00-2021-05-10T00:00:00+09:00", + wantUpcoming: "2021-05-15T00:00:00+09:00-2021-05-17T00:00:00+09:00", + }) + }) + + t.Run("weekly override reccurrence ended", func(t *testing.T) { + t.Helper() + + check(t, testcase{ + recurrence: recurrence{ + Start: "2021-05-01T00:00:00+09:00", + End: "2021-05-03T00:00:00+09:00", + Freq: "Weekly", + Until: "2022-05-01T00:00:00+09:00", + }, + + now: "2021-05-10T00:00:00+09:00", + + wantActive: "", + wantUpcoming: "2021-05-15T00:00:00+09:00-2021-05-17T00:00:00+09:00", + }) + }) + + t.Run("weekly override's last reccurrence about to start", func(t *testing.T) { + t.Helper() + + check(t, testcase{ + recurrence: recurrence{ + Start: "2021-05-01T00:00:00+09:00", + End: "2021-05-03T00:00:00+09:00", + Freq: "Weekly", + Until: "2022-05-01T00:00:00+09:00", + }, + + now: "2022-04-29T23:59:59+09:00", + + wantActive: "", + wantUpcoming: "2022-04-30T00:00:00+09:00-2022-05-02T00:00:00+09:00", + }) + }) + + t.Run("weekly override reccurrence started", func(t *testing.T) { + t.Helper() + + check(t, testcase{ + recurrence: recurrence{ + Start: "2021-05-01T00:00:00+09:00", + End: "2021-05-03T00:00:00+09:00", + Freq: "Weekly", + Until: "2022-05-01T00:00:00+09:00", + }, + + now: "2022-04-30T00:00:00+09:00", + + wantActive: "2022-04-30T00:00:00+09:00-2022-05-02T00:00:00+09:00", + wantUpcoming: "", + }) + }) + + t.Run("weekly override reccurrence about to end", func(t *testing.T) { + t.Helper() + + check(t, testcase{ + recurrence: recurrence{ + Start: "2021-05-01T00:00:00+09:00", + End: "2021-05-03T00:00:00+09:00", + Freq: "Weekly", + Until: "2022-05-01T00:00:00+09:00", + }, + + now: "2022-05-01T23:59:59+09:00", + + wantActive: "2022-04-30T00:00:00+09:00-2022-05-02T00:00:00+09:00", + wantUpcoming: "", + }) + }) + + t.Run("weekly override reccurrence ended", func(t *testing.T) { + t.Helper() + + check(t, testcase{ + recurrence: recurrence{ + Start: "2021-05-01T00:00:00+09:00", + End: "2021-05-03T00:00:00+09:00", + Freq: "Weekly", + Until: "2022-05-01T00:00:00+09:00", + }, + + now: "2022-05-02T00:00:00+09:00", + + wantActive: "", + wantUpcoming: "", + }) + }) + + t.Run("weekly override repeated forever started", func(t *testing.T) { + t.Helper() + + check(t, testcase{ + recurrence: recurrence{ + Start: "2021-05-01T00:00:00+09:00", + End: "2021-05-03T00:00:00+09:00", + Freq: "Weekly", + }, + + now: "2021-05-08T00:00:00+09:00", + + wantActive: "2021-05-08T00:00:00+09:00-2021-05-10T00:00:00+09:00", + wantUpcoming: "2021-05-15T00:00:00+09:00-2021-05-17T00:00:00+09:00", + }) + }) + + t.Run("monthly override started", func(t *testing.T) { + t.Helper() + + check(t, testcase{ + recurrence: recurrence{ + Start: "2021-05-01T00:00:00+09:00", + End: "2021-05-03T00:00:00+09:00", + Freq: "Monthly", + Until: "2022-05-01T00:00:00+09:00", + }, + + now: "2021-05-01T00:00:00+09:00", + + wantActive: "2021-05-01T00:00:00+09:00-2021-05-03T00:00:00+09:00", + wantUpcoming: "2021-06-01T00:00:00+09:00-2021-06-03T00:00:00+09:00", + }) + }) + + t.Run("monthly override recurrence started", func(t *testing.T) { + t.Helper() + + check(t, testcase{ + recurrence: recurrence{ + Start: "2021-05-01T00:00:00+09:00", + End: "2021-05-03T00:00:00+09:00", + Freq: "Monthly", + Until: "2022-05-01T00:00:00+09:00", + }, + + now: "2021-06-01T00:00:00+09:00", + + wantActive: "2021-06-01T00:00:00+09:00-2021-06-03T00:00:00+09:00", + wantUpcoming: "2021-07-01T00:00:00+09:00-2021-07-03T00:00:00+09:00", + }) + }) + + t.Run("monthly override's last reccurence about to start", func(t *testing.T) { + t.Helper() + + check(t, testcase{ + recurrence: recurrence{ + Start: "2021-05-01T00:00:00+09:00", + End: "2021-05-03T00:00:00+09:00", + Freq: "Monthly", + Until: "2022-05-01T00:00:00+09:00", + }, + + now: "2022-04-30T23:59:59+09:00", + + wantActive: "", + wantUpcoming: "2022-05-01T00:00:00+09:00-2022-05-03T00:00:00+09:00", + }) + }) + + t.Run("monthly override's last reccurence started", func(t *testing.T) { + t.Helper() + + check(t, testcase{ + recurrence: recurrence{ + Start: "2021-05-01T00:00:00+09:00", + End: "2021-05-03T00:00:00+09:00", + Freq: "Monthly", + Until: "2022-05-01T00:00:00+09:00", + }, + + now: "2022-05-01T00:00:00+09:00", + + wantActive: "2022-05-01T00:00:00+09:00-2022-05-03T00:00:00+09:00", + wantUpcoming: "", + }) + }) + + t.Run("monthly override's last reccurence started", func(t *testing.T) { + t.Helper() + + check(t, testcase{ + recurrence: recurrence{ + Start: "2021-05-01T00:00:00+09:00", + End: "2021-05-03T00:00:00+09:00", + Freq: "Monthly", + Until: "2022-05-01T00:00:00+09:00", + }, + + now: "2022-05-01T00:00:01+09:00", + + wantActive: "2022-05-01T00:00:00+09:00-2022-05-03T00:00:00+09:00", + wantUpcoming: "", + }) + }) + + t.Run("monthly override's last reccurence ending", func(t *testing.T) { + t.Helper() + + check(t, testcase{ + recurrence: recurrence{ + Start: "2021-05-01T00:00:00+09:00", + End: "2021-05-03T00:00:00+09:00", + Freq: "Monthly", + Until: "2022-05-01T00:00:00+09:00", + }, + + now: "2022-05-02T23:59:59+09:00", + + wantActive: "2022-05-01T00:00:00+09:00-2022-05-03T00:00:00+09:00", + wantUpcoming: "", + }) + }) + + t.Run("monthly override's last reccurence ended", func(t *testing.T) { + t.Helper() + + check(t, testcase{ + recurrence: recurrence{ + Start: "2021-05-01T00:00:00+09:00", + End: "2021-05-03T00:00:00+09:00", + Freq: "Monthly", + Until: "2022-05-01T00:00:00+09:00", + }, + + now: "2022-05-03T00:00:00+09:00", + + wantActive: "", + wantUpcoming: "", + }) + }) + + t.Run("yearly override started", func(t *testing.T) { + t.Helper() + + check(t, testcase{ + recurrence: recurrence{ + Start: "2021-05-01T00:00:00+09:00", + End: "2021-05-03T00:00:00+09:00", + Freq: "Yearly", + Until: "2022-05-01T00:00:00+09:00", + }, + + now: "2021-05-01T00:00:00+09:00", + + wantActive: "2021-05-01T00:00:00+09:00-2021-05-03T00:00:00+09:00", + wantUpcoming: "2022-05-01T00:00:00+09:00-2022-05-03T00:00:00+09:00", + }) + }) + + t.Run("yearly override reccurrence started", func(t *testing.T) { + t.Helper() + + check(t, testcase{ + recurrence: recurrence{ + Start: "2021-05-01T00:00:00+09:00", + End: "2021-05-03T00:00:00+09:00", + Freq: "Yearly", + Until: "2023-05-01T00:00:00+09:00", + }, + + now: "2022-05-01T00:00:00+09:00", + + wantActive: "2022-05-01T00:00:00+09:00-2022-05-03T00:00:00+09:00", + wantUpcoming: "2023-05-01T00:00:00+09:00-2023-05-03T00:00:00+09:00", + }) + }) + + t.Run("yearly override's last recurrence about to start", func(t *testing.T) { + t.Helper() + + check(t, testcase{ + recurrence: recurrence{ + Start: "2021-05-01T00:00:00+09:00", + End: "2021-05-03T00:00:00+09:00", + Freq: "Yearly", + Until: "2023-05-01T00:00:00+09:00", + }, + + now: "2023-04-30T23:59:59+09:00", + + wantActive: "", + wantUpcoming: "2023-05-01T00:00:00+09:00-2023-05-03T00:00:00+09:00", + }) + }) + + t.Run("yearly override's last recurrence started", func(t *testing.T) { + t.Helper() + + check(t, testcase{ + recurrence: recurrence{ + Start: "2021-05-01T00:00:00+09:00", + End: "2021-05-03T00:00:00+09:00", + Freq: "Yearly", + Until: "2023-05-01T00:00:00+09:00", + }, + + now: "2023-05-01T00:00:00+09:00", + + wantActive: "2023-05-01T00:00:00+09:00-2023-05-03T00:00:00+09:00", + wantUpcoming: "", + }) + }) + + t.Run("yearly override's last recurrence ending", func(t *testing.T) { + t.Helper() + + check(t, testcase{ + recurrence: recurrence{ + Start: "2021-05-01T00:00:00+09:00", + End: "2021-05-03T00:00:00+09:00", + Freq: "Yearly", + Until: "2023-05-01T00:00:00+09:00", + }, + + now: "2023-05-02T23:23:59+09:00", + + wantActive: "2023-05-01T00:00:00+09:00-2023-05-03T00:00:00+09:00", + wantUpcoming: "", + }) + }) + + t.Run("yearly override's last recurrence ended", func(t *testing.T) { + t.Helper() + + check(t, testcase{ + recurrence: recurrence{ + Start: "2021-05-01T00:00:00+09:00", + End: "2021-05-03T00:00:00+09:00", + Freq: "Yearly", + Until: "2023-05-01T00:00:00+09:00", + }, + + now: "2023-05-03T00:00:00+09:00", + + wantActive: "", + wantUpcoming: "", + }) + }) +} + +func parseAndMatchRecurringPeriod(now time.Time, start, end, frequency, until string) (*Period, *Period, error) { + startTime, err := time.Parse(time.RFC3339, start) + if err != nil { + return nil, nil, err + } + + endTime, err := time.Parse(time.RFC3339, end) + if err != nil { + return nil, nil, err + } + + var untilTime time.Time + + if until != "" { + ut, err := time.Parse(time.RFC3339, until) + if err != nil { + return nil, nil, err + } + + untilTime = ut + } + + return MatchSchedule(now, startTime, endTime, RecurrenceRule{Frequency: frequency, UntilTime: untilTime}) +} + +func FuzzMatchSchedule(f *testing.F) { + start := time.Now() + end := time.Now() + now := time.Now() + f.Fuzz(func(t *testing.T, freq string) { + // Verify that it never panics + _, _, _ = MatchSchedule(now, start, end, RecurrenceRule{Frequency: freq}) + }) +}