diff --git a/controllers/horizontalrunnerautoscaler_scheduled_overrides.go b/controllers/horizontalrunnerautoscaler_scheduled_overrides.go new file mode 100644 index 00000000..bc03c9f5 --- /dev/null +++ b/controllers/horizontalrunnerautoscaler_scheduled_overrides.go @@ -0,0 +1,122 @@ +package controllers + +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 sor 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/horizontalrunnerautoscaler_scheduled_overrides_test.go b/controllers/horizontalrunnerautoscaler_scheduled_overrides_test.go new file mode 100644 index 00000000..9190bf67 --- /dev/null +++ b/controllers/horizontalrunnerautoscaler_scheduled_overrides_test.go @@ -0,0 +1,607 @@ +package controllers + +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}) +} diff --git a/go.mod b/go.mod index 3d5c78d6..e958497c 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( github.com/onsi/gomega v1.5.0 github.com/prometheus/client_golang v0.9.2 github.com/stretchr/testify v1.4.0 // indirect + github.com/teambition/rrule-go v1.6.2 golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45 k8s.io/api v0.0.0-20190918155943-95b840bb6a1f k8s.io/apimachinery v0.0.0-20190913080033-27d36303b655 diff --git a/go.sum b/go.sum index 3ce72319..fd4878e5 100644 --- a/go.sum +++ b/go.sum @@ -237,6 +237,8 @@ github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/teambition/rrule-go v1.6.2 h1:keZiiijltBxYUuhQaySAEGyIFR0UOkAd7i+u6FM5/+I= +github.com/teambition/rrule-go v1.6.2/go.mod h1:mBJ1Ht5uboJ6jexKdNUJg2NcwP8uUMNvStWXlJD3MvU= github.com/tmc/grpc-websocket-proxy v0.0.0-20170815181823-89b8d40f7ca8/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= github.com/xiang90/probing v0.0.0-20160813154853-07dd2e8dfe18/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU=