This commit is contained in:
Asaf Haim 2025-03-11 10:30:01 +01:00 committed by GitHub
commit c04bb01fe9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 1039 additions and 18 deletions

View File

@ -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 {

View File

@ -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}}

View File

@ -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.

View File

@ -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,22 +308,63 @@ func (r *AutoscalingRunnerSetReconciler) Reconcile(ctx context.Context, req ctrl
return r.createAutoScalingListenerForRunnerSet(ctx, autoscalingRunnerSet, latestRunnerSet, log)
}
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
}
var overridesSummary string
currentReplicasOutOfDate := latestRunnerSet.Status.CurrentReplicas != autoscalingRunnerSet.Status.CurrentRunners
minReplicasOutOfDate := autoscalingRunnerSet.Status.DesiredMinRunners != minRunners
// Update the status of autoscaling runner set.
if latestRunnerSet.Status.CurrentReplicas != autoscalingRunnerSet.Status.CurrentRunners {
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
}); err != nil {
log.Error(err, "Failed to update autoscaling runner set status with current runner count")
return ctrl.Result{}, err
}
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.
// We reach this code path when runner scale set has been patched with a new runner spec but there are still running ephemeral runners.
// The safest approach is to wait for the running ephemeral runners to finish before creating a new runner set.
@ -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

View File

@ -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(

View File

@ -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"

View File

@ -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,

View File

@ -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
}

View File

@ -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})
})
}