From ff4c2220669804de7f954ad643b809a08d3fb567 Mon Sep 17 00:00:00 2001 From: Joseph Oladeji Date: Wed, 7 Aug 2024 15:21:13 -0400 Subject: [PATCH] feat: Adds autoscaling logic for new Chain and Schedule policies (#3929) * Add application logic for Schedule and Chain Policy within the autoscaler * Add schedule and chain policy tests and add calculation for cronStart and cronEnd times * Remove unnecessary mustParseDate calls within validation test * Flesh out tests for autoscaling/schedule logic --- pkg/apis/autoscaling/v1/fleetautoscaler.go | 10 +- .../autoscaling/v1/fleetautoscaler_test.go | 4 +- pkg/fleetautoscalers/controller.go | 5 +- pkg/fleetautoscalers/fleetautoscalers.go | 130 +++++- pkg/fleetautoscalers/fleetautoscalers_test.go | 418 +++++++++++++++++- 5 files changed, 546 insertions(+), 21 deletions(-) diff --git a/pkg/apis/autoscaling/v1/fleetautoscaler.go b/pkg/apis/autoscaling/v1/fleetautoscaler.go index a8e1fe2de3..14e96c4447 100644 --- a/pkg/apis/autoscaling/v1/fleetautoscaler.go +++ b/pkg/apis/autoscaling/v1/fleetautoscaler.go @@ -581,13 +581,9 @@ func (c *ChainPolicy) ValidateChainPolicy(fldPath *field.Path) field.ErrorList { seenIDs[entry.ID] = true } // Ensure that chain entry has a policy - hasValidPolicy := entry.Buffer == nil && entry.Webhook == nil && entry.Counter == nil && entry.List == nil && entry.Schedule == nil - if entry.Type == "" || hasValidPolicy { - allErrs = append(allErrs, field.Required(fldPath.Index(i), "policy is missing")) - } - // Ensure the chain entry's policy is not a chain policy (to avoid nested chain policies) - if entry.Chain != nil { - allErrs = append(allErrs, field.Invalid(fldPath.Index(i), entry.FleetAutoscalerPolicy.Type, "chain policy cannot be used in chain policy")) + hasValidPolicy := entry.Buffer != nil || entry.Webhook != nil || entry.Counter != nil || entry.List != nil || entry.Schedule != nil + if entry.Type == "" || !hasValidPolicy { + allErrs = append(allErrs, field.Required(fldPath.Index(i), "valid policy is missing")) } // Validate the chain entry's policy allErrs = append(allErrs, entry.FleetAutoscalerPolicy.ValidatePolicy(fldPath.Index(i).Child("policy"))...) diff --git a/pkg/apis/autoscaling/v1/fleetautoscaler_test.go b/pkg/apis/autoscaling/v1/fleetautoscaler_test.go index 957cd07153..60a73786f9 100644 --- a/pkg/apis/autoscaling/v1/fleetautoscaler_test.go +++ b/pkg/apis/autoscaling/v1/fleetautoscaler_test.go @@ -488,8 +488,6 @@ func TestFleetAutoscalerScheduleValidateUpdate(t *testing.T) { }, "end time before start time": { fas: modifiedFAS(func(fap *FleetAutoscalerPolicy) { - mustParseDate("3999-06-15T15:59:59Z") - mustParseDate("3999-05-15T15:59:59Z") fap.Schedule.Between.Start = mustParseDate("3999-06-15T15:59:59Z") fap.Schedule.Between.End = mustParseDate("3999-05-15T15:59:59Z") }), @@ -588,7 +586,7 @@ func TestFleetAutoscalerChainValidateUpdate(t *testing.T) { } }), featureFlags: string(runtime.FeatureScheduledAutoscaler) + "=true", - wantLength: 2, + wantLength: 1, wantField: "spec.policy.chain[1]", }, "invalid nested policy format": { diff --git a/pkg/fleetautoscalers/controller.go b/pkg/fleetautoscalers/controller.go index bfbfb5e4ed..abc1e61945 100644 --- a/pkg/fleetautoscalers/controller.go +++ b/pkg/fleetautoscalers/controller.go @@ -313,8 +313,9 @@ func (c *Controller) syncFleetAutoscaler(ctx context.Context, key string) error } currentReplicas := fleet.Status.Replicas - desiredReplicas, scalingLimited, err := computeDesiredFleetSize(fas, fleet, c.gameServerLister, c.counter.Counts()) - if err != nil { + desiredReplicas, scalingLimited, err := computeDesiredFleetSize(fas.Spec.Policy, fleet, c.gameServerLister, c.counter.Counts()) + // If there err is nil and not an inactive schedule error (ignorable in this case), then record the event + if err != nil && !errors.Is(err, InactiveScheduleError{}) { c.recorder.Eventf(fas, corev1.EventTypeWarning, "FleetAutoscaler", "Error calculating desired fleet size on FleetAutoscaler %s. Error: %s", fas.ObjectMeta.Name, err.Error()) diff --git a/pkg/fleetautoscalers/fleetautoscalers.go b/pkg/fleetautoscalers/fleetautoscalers.go index 5ef77e0edf..8663be99ad 100644 --- a/pkg/fleetautoscalers/fleetautoscalers.go +++ b/pkg/fleetautoscalers/fleetautoscalers.go @@ -29,6 +29,7 @@ import ( "time" "github.com/pkg/errors" + "github.com/robfig/cron/v3" "k8s.io/apimachinery/pkg/util/intstr" "k8s.io/apimachinery/pkg/util/uuid" @@ -41,6 +42,8 @@ import ( "agones.dev/agones/pkg/util/runtime" ) +const maxDuration = "2540400h" // 290 Years + var tlsConfig = &tls.Config{} var client = http.Client{ Timeout: 15 * time.Second, @@ -49,18 +52,29 @@ var client = http.Client{ }, } +// InactiveScheduleError denotes an error for schedules that are not currently active. +type InactiveScheduleError struct{} + +func (InactiveScheduleError) Error() string { + return "inactive schedule, policy not applicable" +} + // computeDesiredFleetSize computes the new desired size of the given fleet -func computeDesiredFleetSize(fas *autoscalingv1.FleetAutoscaler, f *agonesv1.Fleet, +func computeDesiredFleetSize(pol autoscalingv1.FleetAutoscalerPolicy, f *agonesv1.Fleet, gameServerLister listeragonesv1.GameServerLister, nodeCounts map[string]gameservers.NodeCount) (int32, bool, error) { - switch fas.Spec.Policy.Type { + switch pol.Type { case autoscalingv1.BufferPolicyType: - return applyBufferPolicy(fas.Spec.Policy.Buffer, f) + return applyBufferPolicy(pol.Buffer, f) case autoscalingv1.WebhookPolicyType: - return applyWebhookPolicy(fas.Spec.Policy.Webhook, f) + return applyWebhookPolicy(pol.Webhook, f) case autoscalingv1.CounterPolicyType: - return applyCounterOrListPolicy(fas.Spec.Policy.Counter, nil, f, gameServerLister, nodeCounts) + return applyCounterOrListPolicy(pol.Counter, nil, f, gameServerLister, nodeCounts) case autoscalingv1.ListPolicyType: - return applyCounterOrListPolicy(nil, fas.Spec.Policy.List, f, gameServerLister, nodeCounts) + return applyCounterOrListPolicy(nil, pol.List, f, gameServerLister, nodeCounts) + case autoscalingv1.SchedulePolicyType: + return applySchedulePolicy(pol.Schedule, f, gameServerLister, nodeCounts, time.Now()) + case autoscalingv1.ChainPolicyType: + return applyChainPolicy(pol.Chain, f, gameServerLister, nodeCounts, time.Now()) } return 0, false, errors.New("wrong policy type, should be one of: Buffer, Webhook, Counter, List") @@ -362,6 +376,110 @@ func applyCounterOrListPolicy(c *autoscalingv1.CounterPolicy, l *autoscalingv1.L return 0, false, errors.Errorf("unable to apply ListPolicy %v", l) } +func applySchedulePolicy(s *autoscalingv1.SchedulePolicy, f *agonesv1.Fleet, gameServerLister listeragonesv1.GameServerLister, nodeCounts map[string]gameservers.NodeCount, currentTime time.Time) (int32, bool, error) { + // Ensure the scheduled autoscaler feature gate is enabled + if !runtime.FeatureEnabled(runtime.FeatureScheduledAutoscaler) { + return 0, false, errors.Errorf("cannot apply SchedulePolicy unless feature flag %s is enabled", runtime.FeatureScheduledAutoscaler) + } + + if isScheduleActive(s, currentTime) { + return computeDesiredFleetSize(s.Policy, f, gameServerLister, nodeCounts) + } + + // If the schedule wasn't active then return the current replica amount of the fleet + return f.Status.Replicas, false, &InactiveScheduleError{} +} + +func applyChainPolicy(c autoscalingv1.ChainPolicy, f *agonesv1.Fleet, gameServerLister listeragonesv1.GameServerLister, nodeCounts map[string]gameservers.NodeCount, currentTime time.Time) (int32, bool, error) { + // Ensure the scheduled autoscaler feature gate is enabled + if !runtime.FeatureEnabled(runtime.FeatureScheduledAutoscaler) { + return 0, false, errors.Errorf("cannot apply ChainPolicy unless feature flag %s is enabled", runtime.FeatureScheduledAutoscaler) + } + + replicas := f.Status.Replicas + var limited bool + var err error + + // Loop over all entries in the chain + for _, entry := range c { + switch entry.Type { + case autoscalingv1.SchedulePolicyType: + replicas, limited, err = applySchedulePolicy(entry.Schedule, f, gameServerLister, nodeCounts, currentTime) + // If no error was returned from the schedule policy (schedule is active and/or webhook policy within schedule was successful), then return the values given + if err == nil { + return replicas, limited, nil + } + case autoscalingv1.WebhookPolicyType: + replicas, limited, err = applyWebhookPolicy(entry.Webhook, f) + // If no error was returned from the webhook policy, then return the values given + if err == nil { + return replicas, limited, nil + } + default: + // Every other policy type we just want to compute the desired fleet and return it + return computeDesiredFleetSize(entry.FleetAutoscalerPolicy, f, gameServerLister, nodeCounts) + } + + } + + // Fall off the chain + return replicas, limited, err +} + +// isScheduleActive checks if a chain entry's is active and returns a boolean, true if active, false otherwise +func isScheduleActive(s *autoscalingv1.SchedulePolicy, currentTime time.Time) bool { + // Used for checking ahead of the schedule for daylight savings purposes + cronDelta := (time.Minute * -1) + (time.Second * -30) + + // If the current time is before the start time, the schedule is inactive so return false + startTime := s.Between.Start.Time + if currentTime.Before(startTime) { + return false + } + + // If an end time is present and the current time is after the end time, the schedule is inactive so return false + endTime := s.Between.End.Time + if !endTime.IsZero() && currentTime.After(endTime) { + return false + } + + // If no startCron field is specified, then it's automatically true (duration is no longer relevant since we're always running) + if s.ActivePeriod.StartCron == "" { + return true + } + + // Ignore the error as validation is already done within the validateChainPolicy after being unmarshalled + location, _ := time.LoadLocation(s.ActivePeriod.Timezone) + + // Ignore the error as validation is already done within the validateChainPolicy after being unmarshalled + startCron, _ := cron.ParseStandard(s.ActivePeriod.StartCron) + + // Ignore the error as validation is already done within the validateChainPolicy after being unmarshalled. + // If the duration is empty set it to the largest duration possible (290 years) + duration, _ := time.ParseDuration(s.ActivePeriod.Duration) + if s.ActivePeriod.Duration == "" { + duration, _ = time.ParseDuration(maxDuration) + } + + // Get the current time - duration + currentTimeMinusDuration := currentTime.Add(duration * -1) + // Take (current time - duration) to get the first available start time + cronStartTime := startCron.Next(currentTimeMinusDuration.In(location)) + // Take the (cronStartTime + duration) to get the end time + cronEndTime := cronStartTime.Add(duration) + + // If the current time is after the cronStartTime - 90 seconds (for daylight saving purposes) AND the current time before the cronEndTime + // then return true + // Example: startCron = 0 14 * * * // 2:00 PM Everyday | duration = 1 hr | cronDelta = 90 seconds | currentTime = 2024-08-01T14:30:00Z | currentTimeMinusDuration = 2024-08-01T13:30:00Z + // then cronStartTime = 2024-08-01T14:00:00Z and cronEndTime = 2024-08-01T15:00:00Z + // and since currentTime > cronStartTime + cronDelta AND currentTime < cronEndTime, we return true + if currentTime.After(cronStartTime.Add(cronDelta)) && currentTime.Before(cronEndTime) { + return true + } + + return false +} + // getSortedGameServers returns the list of Game Servers for the Fleet in the order in which the // Game Servers would be deleted. func getSortedGameServers(f *agonesv1.Fleet, gameServerLister listeragonesv1.GameServerLister, diff --git a/pkg/fleetautoscalers/fleetautoscalers_test.go b/pkg/fleetautoscalers/fleetautoscalers_test.go index 0b1f9bee74..2104d2fc27 100644 --- a/pkg/fleetautoscalers/fleetautoscalers_test.go +++ b/pkg/fleetautoscalers/fleetautoscalers_test.go @@ -23,6 +23,7 @@ import ( "net/http" "net/http/httptest" "testing" + "time" "github.com/stretchr/testify/assert" admregv1 "k8s.io/api/admissionregistration/v1" @@ -40,6 +41,7 @@ import ( const ( scaleFactor = 2 + webhookURL = "scale" ) type testServer struct{} @@ -187,7 +189,7 @@ func TestComputeDesiredFleetSize(t *testing.T) { _, cancel := agtesting.StartInformers(m, gameServers.Informer().HasSynced) defer cancel() - replicas, limited, err := computeDesiredFleetSize(fas, f, gameServers.Lister(), nc) + replicas, limited, err := computeDesiredFleetSize(fas.Spec.Policy, f, gameServers.Lister(), nc) if tc.expected.err != "" && assert.NotNil(t, err) { assert.Equal(t, tc.expected.err, err.Error()) @@ -350,7 +352,7 @@ func TestApplyWebhookPolicy(t *testing.T) { defer server.Close() _, f := defaultWebhookFixtures() - url := "scale" + url := webhookURL emptyString := "" invalidURL := ")1golang.org/" wrongServerURL := "http://127.0.0.1:1" @@ -580,7 +582,7 @@ func TestApplyWebhookPolicy(t *testing.T) { func TestApplyWebhookPolicyNilFleet(t *testing.T) { t.Parallel() - url := "scale" + url := webhookURL w := &autoscalingv1.WebhookPolicy{ Service: &admregv1.ServiceReference{ Name: "service1", @@ -2134,3 +2136,413 @@ func TestApplyListPolicy(t *testing.T) { }) } } + +// nolint:dupl // Linter errors on lines are duplicate of TestApplySchedulePolicy +// NOTE: Does not test for the validity of a fleet autoscaler policy (ValidateSchedulePolicy) +func TestApplySchedulePolicy(t *testing.T) { + t.Parallel() + + type expected struct { + replicas int32 + limited bool + wantErr bool + } + + bufferPolicy := autoscalingv1.FleetAutoscalerPolicy{ + Type: autoscalingv1.BufferPolicyType, + Buffer: &autoscalingv1.BufferPolicy{ + BufferSize: intstr.FromInt(1), + MinReplicas: 3, + MaxReplicas: 10, + }, + } + expectedWhenActive := expected{ + replicas: 3, + limited: false, + wantErr: false, + } + expectedWhenInactive := expected{ + replicas: 0, + limited: false, + wantErr: true, + } + + testCases := map[string]struct { + featureFlags string + specReplicas int32 + statusReplicas int32 + statusAllocatedReplicas int32 + statusReadyReplicas int32 + now time.Time + sp *autoscalingv1.SchedulePolicy + gsList []agonesv1.GameServer + want expected + }{ + "scheduled autoscaler feature flag not enabled": { + featureFlags: string(utilruntime.FeatureScheduledAutoscaler) + "=false", + sp: &autoscalingv1.SchedulePolicy{}, + want: expected{ + replicas: 0, + limited: false, + wantErr: true, + }, + }, + "no start time": { + featureFlags: string(utilruntime.FeatureScheduledAutoscaler) + "=true", + now: mustParseTime("2020-12-26T08:30:00Z"), + sp: &autoscalingv1.SchedulePolicy{ + Between: autoscalingv1.Between{ + End: mustParseMetav1Time("2021-01-01T00:00:00Z"), + }, + ActivePeriod: autoscalingv1.ActivePeriod{ + Timezone: "UTC", + StartCron: "* * * * *", + Duration: "48h", + }, + Policy: bufferPolicy, + }, + want: expectedWhenActive, + }, + "no end time": { + featureFlags: string(utilruntime.FeatureScheduledAutoscaler) + "=true", + now: mustParseTime("2021-01-02T00:00:00Z"), + sp: &autoscalingv1.SchedulePolicy{ + Between: autoscalingv1.Between{ + Start: mustParseMetav1Time("2021-01-01T00:00:00Z"), + }, + ActivePeriod: autoscalingv1.ActivePeriod{ + Timezone: "UTC", + StartCron: "* * * * *", + Duration: "1h", + }, + Policy: bufferPolicy, + }, + want: expectedWhenActive, + }, + "no cron time": { + featureFlags: string(utilruntime.FeatureScheduledAutoscaler) + "=true", + now: mustParseTime("2021-01-01T0:30:00Z"), + sp: &autoscalingv1.SchedulePolicy{ + Between: autoscalingv1.Between{ + Start: mustParseMetav1Time("2021-01-01T00:00:00Z"), + End: mustParseMetav1Time("2021-01-01T01:00:00Z"), + }, + ActivePeriod: autoscalingv1.ActivePeriod{ + Timezone: "UTC", + Duration: "1h", + }, + Policy: bufferPolicy, + }, + want: expectedWhenActive, + }, + "no duration": { + featureFlags: string(utilruntime.FeatureScheduledAutoscaler) + "=true", + now: mustParseTime("2021-01-01T0:30:00Z"), + sp: &autoscalingv1.SchedulePolicy{ + Between: autoscalingv1.Between{ + Start: mustParseMetav1Time("2021-01-01T00:00:00Z"), + End: mustParseMetav1Time("2021-01-01T01:00:00Z"), + }, + ActivePeriod: autoscalingv1.ActivePeriod{ + Timezone: "UTC", + StartCron: "* * * * *", + }, + Policy: bufferPolicy, + }, + want: expectedWhenActive, + }, + "no start time, end time, cron time, duration": { + featureFlags: string(utilruntime.FeatureScheduledAutoscaler) + "=true", + now: mustParseTime("2021-01-01T00:00:00Z"), + sp: &autoscalingv1.SchedulePolicy{ + Policy: bufferPolicy, + }, + want: expectedWhenActive, + }, + "daylight saving time start": { + featureFlags: string(utilruntime.FeatureScheduledAutoscaler) + "=true", + now: mustParseTime("2021-03-14T02:00:00Z"), + sp: &autoscalingv1.SchedulePolicy{ + Between: autoscalingv1.Between{ + Start: mustParseMetav1Time("2021-03-13T00:00:00Z"), + End: mustParseMetav1Time("2021-03-15T00:00:00Z"), + }, + ActivePeriod: autoscalingv1.ActivePeriod{ + Timezone: "UTC", + StartCron: "* 2 * * *", + Duration: "1h", + }, + Policy: bufferPolicy, + }, + want: expectedWhenActive, + }, + "daylight saving time end": { + featureFlags: string(utilruntime.FeatureScheduledAutoscaler) + "=true", + now: mustParseTime("2021-11-07T01:59:59Z"), + sp: &autoscalingv1.SchedulePolicy{ + Between: autoscalingv1.Between{ + Start: mustParseMetav1Time("2021-11-07T00:00:00Z"), + End: mustParseMetav1Time("2021-11-08T00:00:00Z"), + }, + ActivePeriod: autoscalingv1.ActivePeriod{ + Timezone: "UTC", + StartCron: "0 2 * * *", + Duration: "1h", + }, + Policy: bufferPolicy, + }, + want: expectedWhenActive, + }, + "new year": { + featureFlags: string(utilruntime.FeatureScheduledAutoscaler) + "=true", + now: mustParseTime("2021-01-01T00:00:00Z"), + sp: &autoscalingv1.SchedulePolicy{ + Between: autoscalingv1.Between{ + Start: mustParseMetav1Time("2020-12-31T24:59:59Z"), + End: mustParseMetav1Time("2021-01-02T00:00:00Z"), + }, + ActivePeriod: autoscalingv1.ActivePeriod{ + Timezone: "UTC", + StartCron: "* 0 * * *", + Duration: "1h", + }, + Policy: bufferPolicy, + }, + want: expectedWhenActive, + }, + "inactive schedule": { + featureFlags: string(utilruntime.FeatureScheduledAutoscaler) + "=true", + now: mustParseTime("2023-12-12T03:49:00Z"), + sp: &autoscalingv1.SchedulePolicy{ + Between: autoscalingv1.Between{ + Start: mustParseMetav1Time("2022-12-31T24:59:59Z"), + End: mustParseMetav1Time("2023-03-02T00:00:00Z"), + }, + ActivePeriod: autoscalingv1.ActivePeriod{ + Timezone: "UTC", + StartCron: "* 0 * 3 *", + Duration: "", + }, + Policy: bufferPolicy, + }, + want: expectedWhenInactive, + }, + } + + utilruntime.FeatureTestMutex.Lock() + defer utilruntime.FeatureTestMutex.Unlock() + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + err := utilruntime.ParseFeatures(tc.featureFlags) + assert.NoError(t, err) + + _, f := defaultFixtures() + replicas, limited, err := applySchedulePolicy(tc.sp, f, nil, nil, tc.now) + + if tc.want.wantErr { + assert.NotNil(t, err) + } else { + assert.Nil(t, err) + assert.Equal(t, tc.want.replicas, replicas) + assert.Equal(t, tc.want.limited, limited) + } + }) + } +} + +// nolint:dupl // Linter errors on lines are duplicate of TestApplyChainPolicy +// NOTE: Does not test for the validity of a fleet autoscaler policy (ValidateChainPolicy) +func TestApplyChainPolicy(t *testing.T) { + t.Parallel() + + // For Webhook Policy + ts := testServer{} + server := httptest.NewServer(ts) + defer server.Close() + url := webhookURL + + type expected struct { + replicas int32 + limited bool + wantErr bool + } + + scheduleOne := autoscalingv1.ChainEntry{ + ID: "schedule-1", + FleetAutoscalerPolicy: autoscalingv1.FleetAutoscalerPolicy{ + Type: autoscalingv1.SchedulePolicyType, + Schedule: &autoscalingv1.SchedulePolicy{ + Between: autoscalingv1.Between{ + Start: mustParseMetav1Time("2024-08-01T10:07:36-06:00"), + }, + ActivePeriod: autoscalingv1.ActivePeriod{ + Timezone: "America/Chicago", + StartCron: "* * * * *", + Duration: "", + }, + Policy: autoscalingv1.FleetAutoscalerPolicy{ + Type: autoscalingv1.BufferPolicyType, + Buffer: &autoscalingv1.BufferPolicy{ + BufferSize: intstr.FromInt(1), + MinReplicas: 10, + MaxReplicas: 10, + }, + }, + }, + }, + } + scheduleTwo := autoscalingv1.ChainEntry{ + ID: "schedule-2", + FleetAutoscalerPolicy: autoscalingv1.FleetAutoscalerPolicy{ + Type: autoscalingv1.SchedulePolicyType, + Schedule: &autoscalingv1.SchedulePolicy{ + Between: autoscalingv1.Between{ + End: mustParseMetav1Time("2021-01-02T4:53:00-05:00"), + }, + ActivePeriod: autoscalingv1.ActivePeriod{ + Timezone: "America/New_York", + StartCron: "0 1 3 * *", + Duration: "", + }, + Policy: autoscalingv1.FleetAutoscalerPolicy{ + Type: autoscalingv1.BufferPolicyType, + Buffer: &autoscalingv1.BufferPolicy{ + BufferSize: intstr.FromInt(1), + MinReplicas: 3, + MaxReplicas: 10, + }, + }, + }, + }, + } + webhookEntry := autoscalingv1.ChainEntry{ + ID: "webhook policy", + FleetAutoscalerPolicy: autoscalingv1.FleetAutoscalerPolicy{ + Type: autoscalingv1.WebhookPolicyType, + Webhook: &autoscalingv1.WebhookPolicy{ + Service: &admregv1.ServiceReference{ + Name: "service1", + Namespace: "default", + Path: &url, + }, + CABundle: []byte("invalid-value"), + }, + }, + } + defaultEntry := autoscalingv1.ChainEntry{ + ID: "default", + FleetAutoscalerPolicy: autoscalingv1.FleetAutoscalerPolicy{ + Type: autoscalingv1.BufferPolicyType, + Buffer: &autoscalingv1.BufferPolicy{ + BufferSize: intstr.FromInt(1), + MinReplicas: 6, + MaxReplicas: 10, + }, + }, + } + + testCases := map[string]struct { + fleet *agonesv1.Fleet + featureFlags string + specReplicas int32 + statusReplicas int32 + statusAllocatedReplicas int32 + statusReadyReplicas int32 + now time.Time + cp *autoscalingv1.ChainPolicy + gsList []agonesv1.GameServer + want expected + }{ + "scheduled autoscaler feature flag not enabled": { + featureFlags: string(utilruntime.FeatureScheduledAutoscaler) + "=false", + cp: &autoscalingv1.ChainPolicy{}, + want: expected{ + replicas: 0, + limited: false, + wantErr: true, + }, + }, + "default policy": { + featureFlags: string(utilruntime.FeatureScheduledAutoscaler) + "=true", + cp: &autoscalingv1.ChainPolicy{defaultEntry}, + want: expected{ + replicas: 6, + limited: true, + wantErr: false, + }, + }, + "one invalid webhook policy, one default (fallthrough)": { + featureFlags: string(utilruntime.FeatureScheduledAutoscaler) + "=true", + cp: &autoscalingv1.ChainPolicy{webhookEntry, defaultEntry}, + want: expected{ + replicas: 6, + limited: true, + wantErr: false, + }, + }, + "two inactive schedule entries, no default (fall off chain)": { + featureFlags: string(utilruntime.FeatureScheduledAutoscaler) + "=true", + now: mustParseTime("2021-01-01T0:30:00Z"), + cp: &autoscalingv1.ChainPolicy{scheduleOne, scheduleOne}, + want: expected{ + replicas: 5, + limited: false, + wantErr: true, + }, + }, + "two inactive schedules entries, one default (fallthrough)": { + featureFlags: string(utilruntime.FeatureScheduledAutoscaler) + "=true", + now: mustParseTime("2021-11-05T5:30:00Z"), + cp: &autoscalingv1.ChainPolicy{scheduleOne, scheduleTwo, defaultEntry}, + want: expected{ + replicas: 6, + limited: true, + wantErr: false, + }, + }, + "two overlapping/active schedule entries, schedule-1 applied": { + featureFlags: string(utilruntime.FeatureScheduledAutoscaler) + "=true", + now: mustParseTime("2024-08-01T10:07:36-06:00"), + cp: &autoscalingv1.ChainPolicy{scheduleOne, scheduleTwo}, + want: expected{ + replicas: 10, + limited: true, + wantErr: false, + }, + }, + } + + utilruntime.FeatureTestMutex.Lock() + defer utilruntime.FeatureTestMutex.Unlock() + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + err := utilruntime.ParseFeatures(tc.featureFlags) + assert.NoError(t, err) + + _, f := defaultFixtures() + replicas, limited, err := applyChainPolicy(*tc.cp, f, nil, nil, tc.now) + + if tc.want.wantErr { + assert.NotNil(t, err) + } else { + assert.Nil(t, err) + assert.Equal(t, tc.want.replicas, replicas) + assert.Equal(t, tc.want.limited, limited) + } + }) + } +} + +// Parse a time string and return a metav1.Time +func mustParseMetav1Time(timeStr string) metav1.Time { + t, _ := time.Parse(time.RFC3339, timeStr) + return metav1.Date(t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), t.Second(), t.Nanosecond(), t.Location()) +} + +// Parse a time string and return a time.Time +func mustParseTime(timeStr string) time.Time { + t, _ := time.Parse(time.RFC3339, timeStr) + return t +}