diff --git a/pkg/apis/autoscaling/annotations.go b/pkg/apis/autoscaling/annotations.go index e49aaab2eca..d793cd4aa38 100644 --- a/pkg/apis/autoscaling/annotations.go +++ b/pkg/apis/autoscaling/annotations.go @@ -36,3 +36,11 @@ const DefaultCPUUtilization = 80 // BehaviorSpecsAnnotation is the annotation which holds the HPA constraints specs // when converting the `Behavior` field from autoscaling/v2beta2 const BehaviorSpecsAnnotation = "autoscaling.alpha.kubernetes.io/behavior" + +// ToleranceScaleDownAnnotation is the annotation which holds the HPA tolerance specs +// when converting the `ScaleDown.Tolerance` field from autoscaling/v2 +const ToleranceScaleDownAnnotation = "autoscaling.alpha.kubernetes.io/scale-down-tolerance" + +// ToleranceScaleUpAnnotation is the annotation which holds the HPA tolerance specs +// when converting the `ScaleUp.Tolerance` field from autoscaling/v2 +const ToleranceScaleUpAnnotation = "autoscaling.alpha.kubernetes.io/scale-up-tolerance" diff --git a/pkg/apis/autoscaling/helpers.go b/pkg/apis/autoscaling/helpers.go index f66a12f4def..67853dc29a0 100644 --- a/pkg/apis/autoscaling/helpers.go +++ b/pkg/apis/autoscaling/helpers.go @@ -16,8 +16,10 @@ limitations under the License. package autoscaling -// DropRoundTripHorizontalPodAutoscalerAnnotations removes any annotations used to serialize round-tripped fields from later API versions, +// DropRoundTripHorizontalPodAutoscalerAnnotations removes any annotations used to +// serialize round-tripped fields from HorizontalPodAutoscaler later API versions, // and returns false if no changes were made and the original input object was returned. +// // It should always be called when converting internal -> external versions, prior // to setting any of the custom annotations: // @@ -34,12 +36,16 @@ package autoscaling func DropRoundTripHorizontalPodAutoscalerAnnotations(in map[string]string) (out map[string]string, copied bool) { _, hasMetricsSpecs := in[MetricSpecsAnnotation] _, hasBehaviorSpecs := in[BehaviorSpecsAnnotation] + _, hasToleranceScaleDown := in[ToleranceScaleDownAnnotation] + _, hasToleranceScaleUp := in[ToleranceScaleUpAnnotation] _, hasMetricsStatuses := in[MetricStatusesAnnotation] _, hasConditions := in[HorizontalPodAutoscalerConditionsAnnotation] - if hasMetricsSpecs || hasBehaviorSpecs || hasMetricsStatuses || hasConditions { + if hasMetricsSpecs || hasBehaviorSpecs || hasToleranceScaleDown || hasToleranceScaleUp || hasMetricsStatuses || hasConditions { out = DeepCopyStringMap(in) delete(out, MetricSpecsAnnotation) delete(out, BehaviorSpecsAnnotation) + delete(out, ToleranceScaleDownAnnotation) + delete(out, ToleranceScaleUpAnnotation) delete(out, MetricStatusesAnnotation) delete(out, HorizontalPodAutoscalerConditionsAnnotation) return out, true diff --git a/pkg/apis/autoscaling/types.go b/pkg/apis/autoscaling/types.go index 7f254442b13..aa7e0ca0159 100644 --- a/pkg/apis/autoscaling/types.go +++ b/pkg/apis/autoscaling/types.go @@ -138,12 +138,18 @@ const ( DisabledPolicySelect ScalingPolicySelect = "Disabled" ) -// HPAScalingRules configures the scaling behavior for one direction. -// These Rules are applied after calculating DesiredReplicas from metrics for the HPA. +// HPAScalingRules configures the scaling behavior for one direction via +// scaling Policy Rules and a configurable metric tolerance. +// +// Scaling Policy Rules are applied after calculating DesiredReplicas from metrics for the HPA. // They can limit the scaling velocity by specifying scaling policies. // They can prevent flapping by specifying the stabilization window, so that the // number of replicas is not set instantly, instead, the safest value from the stabilization // window is chosen. +// +// The tolerance is applied to the metric values and prevents scaling too +// eagerly for small metric variations. (Note that setting a tolerance requires +// enabling the alpha HPAConfigurableTolerance feature gate.) type HPAScalingRules struct { // StabilizationWindowSeconds is the number of seconds for which past recommendations should be // considered while scaling up or scaling down. @@ -157,10 +163,23 @@ type HPAScalingRules struct { // If not set, the default value MaxPolicySelect is used. // +optional SelectPolicy *ScalingPolicySelect - // policies is a list of potential scaling polices which can used during scaling. - // At least one policy must be specified, otherwise the HPAScalingRules will be discarded as invalid + // policies is a list of potential scaling polices which can be used during scaling. + // If not set, use the default values: + // - For scale up: allow doubling the number of pods, or an absolute change of 4 pods in a 15s window. + // - For scale down: allow all pods to be removed in a 15s window. // +optional Policies []HPAScalingPolicy + // tolerance is the tolerance on the ratio between the current and desired + // metric value under which no updates are made to the desired number of + // replicas (e.g. 0.01 for 1%). Must be greater than or equal to zero. If not + // set, the default cluster-wide tolerance is applied (by default 10%). + // + // This is an alpha field and requires enabling the HPAConfigurableTolerance + // feature gate. + // + // +featureGate=HPAConfigurableTolerance + // +optional + Tolerance *resource.Quantity } // HPAScalingPolicyType is the type of the policy which could be used while making scaling decisions. diff --git a/pkg/apis/autoscaling/v2/defaults.go b/pkg/apis/autoscaling/v2/defaults.go index 2ae50ae287d..2917dd5ff05 100644 --- a/pkg/apis/autoscaling/v2/defaults.go +++ b/pkg/apis/autoscaling/v2/defaults.go @@ -91,9 +91,12 @@ func SetDefaults_HorizontalPodAutoscaler(obj *autoscalingv2.HorizontalPodAutosca SetDefaults_HorizontalPodAutoscalerBehavior(obj) } -// SetDefaults_HorizontalPodAutoscalerBehavior fills the behavior if it is not null +// SetDefaults_HorizontalPodAutoscalerBehavior fills the behavior if it contains +// at least one scaling rule policy (for scale-up or scale-down) func SetDefaults_HorizontalPodAutoscalerBehavior(obj *autoscalingv2.HorizontalPodAutoscaler) { - // if behavior is specified, we should fill all the 'nil' values with the default ones + // If behavior contains a scaling rule policy (either for scale-up, scale-down, or both), we + // should fill all the unset scaling policy fields (i.e. StabilizationWindowSeconds, + // SelectPolicy, Policies) with default values if obj.Spec.Behavior != nil { obj.Spec.Behavior.ScaleUp = GenerateHPAScaleUpRules(obj.Spec.Behavior.ScaleUp) obj.Spec.Behavior.ScaleDown = GenerateHPAScaleDownRules(obj.Spec.Behavior.ScaleDown) @@ -129,5 +132,8 @@ func copyHPAScalingRules(from, to *autoscalingv2.HPAScalingRules) *autoscalingv2 if from.Policies != nil { to.Policies = from.Policies } + if from.Tolerance != nil { + to.Tolerance = from.Tolerance + } return to } diff --git a/pkg/apis/autoscaling/v2/defaults_test.go b/pkg/apis/autoscaling/v2/defaults_test.go index ad95076d7e5..cd8cfa0bc42 100644 --- a/pkg/apis/autoscaling/v2/defaults_test.go +++ b/pkg/apis/autoscaling/v2/defaults_test.go @@ -20,8 +20,12 @@ import ( "reflect" "testing" + "k8s.io/apimachinery/pkg/api/resource" "k8s.io/apimachinery/pkg/runtime" + utilfeature "k8s.io/apiserver/pkg/util/feature" + featuregatetesting "k8s.io/component-base/featuregate/testing" "k8s.io/kubernetes/pkg/api/legacyscheme" + "k8s.io/kubernetes/pkg/features" "github.com/google/go-cmp/cmp" "github.com/stretchr/testify/assert" @@ -132,14 +136,17 @@ func TestGenerateScaleUpRules(t *testing.T) { rateUpPercentPeriodSeconds int32 stabilizationSeconds *int32 selectPolicy *autoscalingv2.ScalingPolicySelect + tolerance *resource.Quantity expectedPolicies []autoscalingv2.HPAScalingPolicy expectedStabilization *int32 expectedSelectPolicy string + expectedTolerance *resource.Quantity annotation string } maxPolicy := autoscalingv2.MaxChangePolicySelect minPolicy := autoscalingv2.MinChangePolicySelect + sampleTolerance := resource.MustParse("0.5") tests := []TestCase{ { annotation: "Default values", @@ -208,12 +215,25 @@ func TestGenerateScaleUpRules(t *testing.T) { expectedStabilization: utilpointer.Int32(25), expectedSelectPolicy: string(autoscalingv2.MaxChangePolicySelect), }, + { + annotation: "Percent policy and tolerance is specified", + rateUpPercent: 7, + rateUpPercentPeriodSeconds: 10, + tolerance: &sampleTolerance, + expectedPolicies: []autoscalingv2.HPAScalingPolicy{ + {Type: autoscalingv2.PercentScalingPolicy, Value: 7, PeriodSeconds: 10}, + }, + expectedStabilization: utilpointer.Int32(0), + expectedSelectPolicy: string(autoscalingv2.MaxChangePolicySelect), + expectedTolerance: &sampleTolerance, + }, } for _, tc := range tests { t.Run(tc.annotation, func(t *testing.T) { scaleUpRules := &autoscalingv2.HPAScalingRules{ StabilizationWindowSeconds: tc.stabilizationSeconds, SelectPolicy: tc.selectPolicy, + Tolerance: tc.tolerance, } if tc.rateUpPods != 0 || tc.rateUpPodsPeriodSeconds != 0 { scaleUpRules.Policies = append(scaleUpRules.Policies, autoscalingv2.HPAScalingPolicy{ @@ -234,10 +254,138 @@ func TestGenerateScaleUpRules(t *testing.T) { } assert.Equal(t, autoscalingv2.ScalingPolicySelect(tc.expectedSelectPolicy), *up.SelectPolicy) + assert.Equal(t, tc.expectedTolerance, up.Tolerance) }) } } +func TestSetBehaviorDefaults(t *testing.T) { + sampleTolerance := resource.MustParse("0.5") + maxPolicy := autoscalingv2.MaxChangePolicySelect + policies := []autoscalingv2.HPAScalingPolicy{ + {Type: autoscalingv2.PercentScalingPolicy, Value: 7, PeriodSeconds: 10}, + } + type TestCase struct { + behavior *autoscalingv2.HorizontalPodAutoscalerBehavior + expectedBehavior *autoscalingv2.HorizontalPodAutoscalerBehavior + annotation string + } + + tests := []TestCase{ + { + annotation: "Nil behavior", + behavior: nil, + expectedBehavior: nil, + }, + { + annotation: "Behavior with stabilizationWindowSeconds and tolerance", + behavior: &autoscalingv2.HorizontalPodAutoscalerBehavior{ + ScaleUp: &autoscalingv2.HPAScalingRules{ + StabilizationWindowSeconds: utilpointer.Int32(100), + Tolerance: &sampleTolerance, + }, + }, + expectedBehavior: &autoscalingv2.HorizontalPodAutoscalerBehavior{ + ScaleDown: &autoscalingv2.HPAScalingRules{ + SelectPolicy: &maxPolicy, + Policies: []autoscalingv2.HPAScalingPolicy{ + {Type: autoscalingv2.PercentScalingPolicy, Value: 100, PeriodSeconds: 15}, + }, + }, + ScaleUp: &autoscalingv2.HPAScalingRules{ + StabilizationWindowSeconds: utilpointer.Int32(100), + SelectPolicy: &maxPolicy, + Policies: []autoscalingv2.HPAScalingPolicy{ + {Type: autoscalingv2.PodsScalingPolicy, Value: 4, PeriodSeconds: 15}, + {Type: autoscalingv2.PercentScalingPolicy, Value: 100, PeriodSeconds: 15}, + }, + Tolerance: &sampleTolerance, + }, + }, + }, + { + annotation: "Behavior with policy, without tolerance", + behavior: &autoscalingv2.HorizontalPodAutoscalerBehavior{ + ScaleDown: &autoscalingv2.HPAScalingRules{ + Policies: policies, + }, + }, + expectedBehavior: &autoscalingv2.HorizontalPodAutoscalerBehavior{ + ScaleDown: &autoscalingv2.HPAScalingRules{ + SelectPolicy: &maxPolicy, + Policies: policies, + }, + ScaleUp: &autoscalingv2.HPAScalingRules{ + StabilizationWindowSeconds: utilpointer.Int32(0), + SelectPolicy: &maxPolicy, + Policies: []autoscalingv2.HPAScalingPolicy{ + {Type: autoscalingv2.PodsScalingPolicy, Value: 4, PeriodSeconds: 15}, + {Type: autoscalingv2.PercentScalingPolicy, Value: 100, PeriodSeconds: 15}, + }, + }, + }, + }, + } + for _, tc := range tests { + t.Run(tc.annotation, func(t *testing.T) { + hpa := autoscalingv2.HorizontalPodAutoscaler{ + Spec: autoscalingv2.HorizontalPodAutoscalerSpec{ + Behavior: tc.behavior, + }, + } + expectedHPA := autoscalingv2.HorizontalPodAutoscaler{ + Spec: autoscalingv2.HorizontalPodAutoscalerSpec{ + Behavior: tc.expectedBehavior, + }, + } + SetDefaults_HorizontalPodAutoscalerBehavior(&hpa) + assert.Equal(t, expectedHPA, hpa) + }) + } +} + +func TestSetBehaviorDefaultsConfigurableToleranceEnabled(t *testing.T) { + // Enable HPAConfigurableTolerance feature gate. + featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.HPAConfigurableTolerance, true) + + // Verify that the tolerance field is left unset. + maxPolicy := autoscalingv2.MaxChangePolicySelect + + hpa := autoscalingv2.HorizontalPodAutoscaler{ + Spec: autoscalingv2.HorizontalPodAutoscalerSpec{ + Behavior: &autoscalingv2.HorizontalPodAutoscalerBehavior{ + ScaleUp: &autoscalingv2.HPAScalingRules{ + StabilizationWindowSeconds: utilpointer.Int32(100), + }, + }, + }, + } + + expectedHPA := autoscalingv2.HorizontalPodAutoscaler{ + Spec: autoscalingv2.HorizontalPodAutoscalerSpec{ + Behavior: &autoscalingv2.HorizontalPodAutoscalerBehavior{ + ScaleDown: &autoscalingv2.HPAScalingRules{ + SelectPolicy: &maxPolicy, + Policies: []autoscalingv2.HPAScalingPolicy{ + {Type: autoscalingv2.PercentScalingPolicy, Value: 100, PeriodSeconds: 15}, + }, + }, + ScaleUp: &autoscalingv2.HPAScalingRules{ + StabilizationWindowSeconds: utilpointer.Int32(100), + SelectPolicy: &maxPolicy, + Policies: []autoscalingv2.HPAScalingPolicy{ + {Type: autoscalingv2.PodsScalingPolicy, Value: 4, PeriodSeconds: 15}, + {Type: autoscalingv2.PercentScalingPolicy, Value: 100, PeriodSeconds: 15}, + }, + }, + }, + }, + } + + SetDefaults_HorizontalPodAutoscalerBehavior(&hpa) + assert.Equal(t, expectedHPA, hpa) +} + func TestHorizontalPodAutoscalerAnnotations(t *testing.T) { tests := []struct { hpa autoscalingv2.HorizontalPodAutoscaler diff --git a/pkg/apis/autoscaling/v2beta2/conversion.go b/pkg/apis/autoscaling/v2beta2/conversion.go index 3a555e8c872..a152442605a 100644 --- a/pkg/apis/autoscaling/v2beta2/conversion.go +++ b/pkg/apis/autoscaling/v2beta2/conversion.go @@ -17,8 +17,11 @@ limitations under the License. package v2beta2 import ( + "fmt" + autoscalingv2beta2 "k8s.io/api/autoscaling/v2beta2" + "k8s.io/apimachinery/pkg/api/resource" "k8s.io/apimachinery/pkg/conversion" "k8s.io/kubernetes/pkg/apis/autoscaling" ) @@ -27,8 +30,29 @@ func Convert_autoscaling_HorizontalPodAutoscaler_To_v2beta2_HorizontalPodAutosca if err := autoConvert_autoscaling_HorizontalPodAutoscaler_To_v2beta2_HorizontalPodAutoscaler(in, out, s); err != nil { return err } - // v2beta2 round-trips to internal without any serialized annotations, make sure any from other versions don't get serialized - out.Annotations, _ = autoscaling.DropRoundTripHorizontalPodAutoscalerAnnotations(out.Annotations) + // Ensure old round-trips annotations are discarded + annotations, copiedAnnotations := autoscaling.DropRoundTripHorizontalPodAutoscalerAnnotations(out.Annotations) + out.Annotations = annotations + + behavior := in.Spec.Behavior + if behavior == nil { + return nil + } + // Save the tolerance fields in annotations for round-trip + if behavior.ScaleDown != nil && behavior.ScaleDown.Tolerance != nil { + if !copiedAnnotations { + copiedAnnotations = true + out.Annotations = autoscaling.DeepCopyStringMap(out.Annotations) + } + out.Annotations[autoscaling.ToleranceScaleDownAnnotation] = behavior.ScaleDown.Tolerance.String() + } + if behavior.ScaleUp != nil && behavior.ScaleUp.Tolerance != nil { + if !copiedAnnotations { + copiedAnnotations = true + out.Annotations = autoscaling.DeepCopyStringMap(out.Annotations) + } + out.Annotations[autoscaling.ToleranceScaleUpAnnotation] = behavior.ScaleUp.Tolerance.String() + } return nil } @@ -36,7 +60,44 @@ func Convert_v2beta2_HorizontalPodAutoscaler_To_autoscaling_HorizontalPodAutosca if err := autoConvert_v2beta2_HorizontalPodAutoscaler_To_autoscaling_HorizontalPodAutoscaler(in, out, s); err != nil { return err } - // v2beta2 round-trips to internal without any serialized annotations, make sure any from other versions don't get serialized + // Restore the tolerance fields from annotations for round-trip + if tolerance, ok := out.Annotations[autoscaling.ToleranceScaleDownAnnotation]; ok { + if out.Spec.Behavior == nil { + out.Spec.Behavior = &autoscaling.HorizontalPodAutoscalerBehavior{} + } + if out.Spec.Behavior.ScaleDown == nil { + out.Spec.Behavior.ScaleDown = &autoscaling.HPAScalingRules{} + } + q, err := resource.ParseQuantity(tolerance) + if err != nil { + return fmt.Errorf("failed to parse annotation %q: %w", autoscaling.ToleranceScaleDownAnnotation, err) + } + out.Spec.Behavior.ScaleDown.Tolerance = &q + } + if tolerance, ok := out.Annotations[autoscaling.ToleranceScaleUpAnnotation]; ok { + if out.Spec.Behavior == nil { + out.Spec.Behavior = &autoscaling.HorizontalPodAutoscalerBehavior{} + } + if out.Spec.Behavior.ScaleUp == nil { + out.Spec.Behavior.ScaleUp = &autoscaling.HPAScalingRules{} + } + q, err := resource.ParseQuantity(tolerance) + if err != nil { + return fmt.Errorf("failed to parse annotation %q: %w", autoscaling.ToleranceScaleUpAnnotation, err) + } + out.Spec.Behavior.ScaleUp.Tolerance = &q + } + // Do not save round-trip annotations in internal resource out.Annotations, _ = autoscaling.DropRoundTripHorizontalPodAutoscalerAnnotations(out.Annotations) return nil } + +func Convert_v2beta2_HPAScalingRules_To_autoscaling_HPAScalingRules(in *autoscalingv2beta2.HPAScalingRules, out *autoscaling.HPAScalingRules, s conversion.Scope) error { + // Tolerance field is handled in the HorizontalPodAutoscaler conversion function. + return autoConvert_v2beta2_HPAScalingRules_To_autoscaling_HPAScalingRules(in, out, s) +} + +func Convert_autoscaling_HPAScalingRules_To_v2beta2_HPAScalingRules(in *autoscaling.HPAScalingRules, out *autoscalingv2beta2.HPAScalingRules, s conversion.Scope) error { + // Tolerance field is handled in the HorizontalPodAutoscaler conversion function. + return autoConvert_autoscaling_HPAScalingRules_To_v2beta2_HPAScalingRules(in, out, s) +} diff --git a/pkg/apis/autoscaling/v2beta2/conversion_test.go b/pkg/apis/autoscaling/v2beta2/conversion_test.go new file mode 100644 index 00000000000..faacf410fdd --- /dev/null +++ b/pkg/apis/autoscaling/v2beta2/conversion_test.go @@ -0,0 +1,124 @@ +/* +Copyright 2025 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v2beta2 + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + autoscalingv2beta2 "k8s.io/api/autoscaling/v2beta2" + apiequality "k8s.io/apimachinery/pkg/api/equality" + "k8s.io/apimachinery/pkg/api/resource" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/kubernetes/pkg/apis/autoscaling" + api "k8s.io/kubernetes/pkg/apis/core" + "k8s.io/utils/ptr" +) + +func TestConvertRoundTrip(t *testing.T) { + tolerance1 := resource.MustParse("0.1") + tolerance2 := resource.MustParse("0.2") + tests := []struct { + name string + internalHPA *autoscaling.HorizontalPodAutoscaler + }{ + { + "Complete HPA with scale-up tolerance", + &autoscaling.HorizontalPodAutoscaler{ + ObjectMeta: v1.ObjectMeta{ + Name: "hpa", + Namespace: "hpa-ns", + Annotations: map[string]string{"key": "value"}, + }, + Spec: autoscaling.HorizontalPodAutoscalerSpec{ + MinReplicas: ptr.To(int32(1)), + MaxReplicas: 3, + Metrics: []autoscaling.MetricSpec{ + { + Type: autoscaling.ResourceMetricSourceType, + Resource: &autoscaling.ResourceMetricSource{ + Name: api.ResourceCPU, + Target: autoscaling.MetricTarget{ + Type: autoscaling.AverageValueMetricType, + AverageValue: resource.NewMilliQuantity(300, resource.DecimalSI), + }, + }, + }, + }, + Behavior: &autoscaling.HorizontalPodAutoscalerBehavior{ + ScaleUp: &autoscaling.HPAScalingRules{ + Policies: []autoscaling.HPAScalingPolicy{ + { + Type: autoscaling.PodsScalingPolicy, + Value: 1, + PeriodSeconds: 2, + }, + }, + Tolerance: &tolerance1, + }, + }, + }, + }, + }, + { + "Scale-down and scale-up tolerances", + &autoscaling.HorizontalPodAutoscaler{ + Spec: autoscaling.HorizontalPodAutoscalerSpec{ + MinReplicas: ptr.To(int32(1)), + MaxReplicas: 3, + Behavior: &autoscaling.HorizontalPodAutoscalerBehavior{ + ScaleUp: &autoscaling.HPAScalingRules{ + Tolerance: &tolerance1, + }, + ScaleDown: &autoscaling.HPAScalingRules{ + Tolerance: &tolerance2, + }, + }, + }, + }, + }, + { + "Scale-down tolerance only", + &autoscaling.HorizontalPodAutoscaler{ + Spec: autoscaling.HorizontalPodAutoscalerSpec{ + MinReplicas: ptr.To(int32(1)), + MaxReplicas: 3, + Behavior: &autoscaling.HorizontalPodAutoscalerBehavior{ + ScaleDown: &autoscaling.HPAScalingRules{ + Tolerance: &tolerance2, + }, + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + v2beta2HPA := &autoscalingv2beta2.HorizontalPodAutoscaler{} + if err := Convert_autoscaling_HorizontalPodAutoscaler_To_v2beta2_HorizontalPodAutoscaler(tt.internalHPA, v2beta2HPA, nil); err != nil { + t.Errorf("Convert_autoscaling_HorizontalPodAutoscaler_To_v2beta2_HorizontalPodAutoscaler() error = %v", err) + } + roundtripHPA := &autoscaling.HorizontalPodAutoscaler{} + if err := Convert_v2beta2_HorizontalPodAutoscaler_To_autoscaling_HorizontalPodAutoscaler(v2beta2HPA, roundtripHPA, nil); err != nil { + t.Errorf("Convert_v2beta2_HorizontalPodAutoscaler_To_autoscaling_HorizontalPodAutoscaler() error = %v", err) + } + if !apiequality.Semantic.DeepEqual(tt.internalHPA, roundtripHPA) { + t.Errorf("HPA is not equivalent after round-trip: mismatch (-want +got):\n%s", cmp.Diff(tt.internalHPA, roundtripHPA)) + } + }) + } +} diff --git a/pkg/apis/autoscaling/v2beta2/zz_generated.conversion.go b/pkg/apis/autoscaling/v2beta2/zz_generated.conversion.go index e567f74cc14..7f544a0864f 100644 --- a/pkg/apis/autoscaling/v2beta2/zz_generated.conversion.go +++ b/pkg/apis/autoscaling/v2beta2/zz_generated.conversion.go @@ -101,16 +101,6 @@ func RegisterConversions(s *runtime.Scheme) error { }); err != nil { return err } - if err := s.AddGeneratedConversionFunc((*autoscalingv2beta2.HPAScalingRules)(nil), (*autoscaling.HPAScalingRules)(nil), func(a, b interface{}, scope conversion.Scope) error { - return Convert_v2beta2_HPAScalingRules_To_autoscaling_HPAScalingRules(a.(*autoscalingv2beta2.HPAScalingRules), b.(*autoscaling.HPAScalingRules), scope) - }); err != nil { - return err - } - if err := s.AddGeneratedConversionFunc((*autoscaling.HPAScalingRules)(nil), (*autoscalingv2beta2.HPAScalingRules)(nil), func(a, b interface{}, scope conversion.Scope) error { - return Convert_autoscaling_HPAScalingRules_To_v2beta2_HPAScalingRules(a.(*autoscaling.HPAScalingRules), b.(*autoscalingv2beta2.HPAScalingRules), scope) - }); err != nil { - return err - } if err := s.AddGeneratedConversionFunc((*autoscalingv2beta2.HorizontalPodAutoscalerBehavior)(nil), (*autoscaling.HorizontalPodAutoscalerBehavior)(nil), func(a, b interface{}, scope conversion.Scope) error { return Convert_v2beta2_HorizontalPodAutoscalerBehavior_To_autoscaling_HorizontalPodAutoscalerBehavior(a.(*autoscalingv2beta2.HorizontalPodAutoscalerBehavior), b.(*autoscaling.HorizontalPodAutoscalerBehavior), scope) }); err != nil { @@ -271,11 +261,21 @@ func RegisterConversions(s *runtime.Scheme) error { }); err != nil { return err } + if err := s.AddConversionFunc((*autoscaling.HPAScalingRules)(nil), (*autoscalingv2beta2.HPAScalingRules)(nil), func(a, b interface{}, scope conversion.Scope) error { + return Convert_autoscaling_HPAScalingRules_To_v2beta2_HPAScalingRules(a.(*autoscaling.HPAScalingRules), b.(*autoscalingv2beta2.HPAScalingRules), scope) + }); err != nil { + return err + } if err := s.AddConversionFunc((*autoscaling.HorizontalPodAutoscaler)(nil), (*autoscalingv2beta2.HorizontalPodAutoscaler)(nil), func(a, b interface{}, scope conversion.Scope) error { return Convert_autoscaling_HorizontalPodAutoscaler_To_v2beta2_HorizontalPodAutoscaler(a.(*autoscaling.HorizontalPodAutoscaler), b.(*autoscalingv2beta2.HorizontalPodAutoscaler), scope) }); err != nil { return err } + if err := s.AddConversionFunc((*autoscalingv2beta2.HPAScalingRules)(nil), (*autoscaling.HPAScalingRules)(nil), func(a, b interface{}, scope conversion.Scope) error { + return Convert_v2beta2_HPAScalingRules_To_autoscaling_HPAScalingRules(a.(*autoscalingv2beta2.HPAScalingRules), b.(*autoscaling.HPAScalingRules), scope) + }); err != nil { + return err + } if err := s.AddConversionFunc((*autoscalingv2beta2.HorizontalPodAutoscaler)(nil), (*autoscaling.HorizontalPodAutoscaler)(nil), func(a, b interface{}, scope conversion.Scope) error { return Convert_v2beta2_HorizontalPodAutoscaler_To_autoscaling_HorizontalPodAutoscaler(a.(*autoscalingv2beta2.HorizontalPodAutoscaler), b.(*autoscaling.HorizontalPodAutoscaler), scope) }); err != nil { @@ -455,23 +455,14 @@ func autoConvert_v2beta2_HPAScalingRules_To_autoscaling_HPAScalingRules(in *auto return nil } -// Convert_v2beta2_HPAScalingRules_To_autoscaling_HPAScalingRules is an autogenerated conversion function. -func Convert_v2beta2_HPAScalingRules_To_autoscaling_HPAScalingRules(in *autoscalingv2beta2.HPAScalingRules, out *autoscaling.HPAScalingRules, s conversion.Scope) error { - return autoConvert_v2beta2_HPAScalingRules_To_autoscaling_HPAScalingRules(in, out, s) -} - func autoConvert_autoscaling_HPAScalingRules_To_v2beta2_HPAScalingRules(in *autoscaling.HPAScalingRules, out *autoscalingv2beta2.HPAScalingRules, s conversion.Scope) error { out.StabilizationWindowSeconds = (*int32)(unsafe.Pointer(in.StabilizationWindowSeconds)) out.SelectPolicy = (*autoscalingv2beta2.ScalingPolicySelect)(unsafe.Pointer(in.SelectPolicy)) out.Policies = *(*[]autoscalingv2beta2.HPAScalingPolicy)(unsafe.Pointer(&in.Policies)) + // WARNING: in.Tolerance requires manual conversion: does not exist in peer-type return nil } -// Convert_autoscaling_HPAScalingRules_To_v2beta2_HPAScalingRules is an autogenerated conversion function. -func Convert_autoscaling_HPAScalingRules_To_v2beta2_HPAScalingRules(in *autoscaling.HPAScalingRules, out *autoscalingv2beta2.HPAScalingRules, s conversion.Scope) error { - return autoConvert_autoscaling_HPAScalingRules_To_v2beta2_HPAScalingRules(in, out, s) -} - func autoConvert_v2beta2_HorizontalPodAutoscaler_To_autoscaling_HorizontalPodAutoscaler(in *autoscalingv2beta2.HorizontalPodAutoscaler, out *autoscaling.HorizontalPodAutoscaler, s conversion.Scope) error { out.ObjectMeta = in.ObjectMeta if err := Convert_v2beta2_HorizontalPodAutoscalerSpec_To_autoscaling_HorizontalPodAutoscalerSpec(&in.Spec, &out.Spec, s); err != nil { @@ -495,8 +486,24 @@ func autoConvert_autoscaling_HorizontalPodAutoscaler_To_v2beta2_HorizontalPodAut } func autoConvert_v2beta2_HorizontalPodAutoscalerBehavior_To_autoscaling_HorizontalPodAutoscalerBehavior(in *autoscalingv2beta2.HorizontalPodAutoscalerBehavior, out *autoscaling.HorizontalPodAutoscalerBehavior, s conversion.Scope) error { - out.ScaleUp = (*autoscaling.HPAScalingRules)(unsafe.Pointer(in.ScaleUp)) - out.ScaleDown = (*autoscaling.HPAScalingRules)(unsafe.Pointer(in.ScaleDown)) + if in.ScaleUp != nil { + in, out := &in.ScaleUp, &out.ScaleUp + *out = new(autoscaling.HPAScalingRules) + if err := Convert_v2beta2_HPAScalingRules_To_autoscaling_HPAScalingRules(*in, *out, s); err != nil { + return err + } + } else { + out.ScaleUp = nil + } + if in.ScaleDown != nil { + in, out := &in.ScaleDown, &out.ScaleDown + *out = new(autoscaling.HPAScalingRules) + if err := Convert_v2beta2_HPAScalingRules_To_autoscaling_HPAScalingRules(*in, *out, s); err != nil { + return err + } + } else { + out.ScaleDown = nil + } return nil } @@ -506,8 +513,24 @@ func Convert_v2beta2_HorizontalPodAutoscalerBehavior_To_autoscaling_HorizontalPo } func autoConvert_autoscaling_HorizontalPodAutoscalerBehavior_To_v2beta2_HorizontalPodAutoscalerBehavior(in *autoscaling.HorizontalPodAutoscalerBehavior, out *autoscalingv2beta2.HorizontalPodAutoscalerBehavior, s conversion.Scope) error { - out.ScaleUp = (*autoscalingv2beta2.HPAScalingRules)(unsafe.Pointer(in.ScaleUp)) - out.ScaleDown = (*autoscalingv2beta2.HPAScalingRules)(unsafe.Pointer(in.ScaleDown)) + if in.ScaleUp != nil { + in, out := &in.ScaleUp, &out.ScaleUp + *out = new(autoscalingv2beta2.HPAScalingRules) + if err := Convert_autoscaling_HPAScalingRules_To_v2beta2_HPAScalingRules(*in, *out, s); err != nil { + return err + } + } else { + out.ScaleUp = nil + } + if in.ScaleDown != nil { + in, out := &in.ScaleDown, &out.ScaleDown + *out = new(autoscalingv2beta2.HPAScalingRules) + if err := Convert_autoscaling_HPAScalingRules_To_v2beta2_HPAScalingRules(*in, *out, s); err != nil { + return err + } + } else { + out.ScaleDown = nil + } return nil } @@ -603,7 +626,15 @@ func autoConvert_v2beta2_HorizontalPodAutoscalerSpec_To_autoscaling_HorizontalPo } else { out.Metrics = nil } - out.Behavior = (*autoscaling.HorizontalPodAutoscalerBehavior)(unsafe.Pointer(in.Behavior)) + if in.Behavior != nil { + in, out := &in.Behavior, &out.Behavior + *out = new(autoscaling.HorizontalPodAutoscalerBehavior) + if err := Convert_v2beta2_HorizontalPodAutoscalerBehavior_To_autoscaling_HorizontalPodAutoscalerBehavior(*in, *out, s); err != nil { + return err + } + } else { + out.Behavior = nil + } return nil } @@ -629,7 +660,15 @@ func autoConvert_autoscaling_HorizontalPodAutoscalerSpec_To_v2beta2_HorizontalPo } else { out.Metrics = nil } - out.Behavior = (*autoscalingv2beta2.HorizontalPodAutoscalerBehavior)(unsafe.Pointer(in.Behavior)) + if in.Behavior != nil { + in, out := &in.Behavior, &out.Behavior + *out = new(autoscalingv2beta2.HorizontalPodAutoscalerBehavior) + if err := Convert_autoscaling_HorizontalPodAutoscalerBehavior_To_v2beta2_HorizontalPodAutoscalerBehavior(*in, *out, s); err != nil { + return err + } + } else { + out.Behavior = nil + } return nil } diff --git a/pkg/apis/autoscaling/validation/validation.go b/pkg/apis/autoscaling/validation/validation.go index bd986816cfd..8b5ac06c442 100644 --- a/pkg/apis/autoscaling/validation/validation.go +++ b/pkg/apis/autoscaling/validation/validation.go @@ -53,12 +53,12 @@ func ValidateScale(scale *autoscaling.Scale) field.ErrorList { // Prefix indicates this name will be used as part of generation, in which case trailing dashes are allowed. var ValidateHorizontalPodAutoscalerName = apivalidation.ValidateReplicationControllerName -func validateHorizontalPodAutoscalerSpec(autoscaler autoscaling.HorizontalPodAutoscalerSpec, fldPath *field.Path, minReplicasLowerBound int32) field.ErrorList { +func validateHorizontalPodAutoscalerSpec(autoscaler autoscaling.HorizontalPodAutoscalerSpec, fldPath *field.Path, opts HorizontalPodAutoscalerSpecValidationOptions) field.ErrorList { allErrs := field.ErrorList{} - if autoscaler.MinReplicas != nil && *autoscaler.MinReplicas < minReplicasLowerBound { + if autoscaler.MinReplicas != nil && *autoscaler.MinReplicas < opts.MinReplicasLowerBound { allErrs = append(allErrs, field.Invalid(fldPath.Child("minReplicas"), *autoscaler.MinReplicas, - fmt.Sprintf("must be greater than or equal to %d", minReplicasLowerBound))) + fmt.Sprintf("must be greater than or equal to %d", opts.MinReplicasLowerBound))) } if autoscaler.MaxReplicas < 1 { allErrs = append(allErrs, field.Invalid(fldPath.Child("maxReplicas"), autoscaler.MaxReplicas, "must be greater than 0")) @@ -72,7 +72,7 @@ func validateHorizontalPodAutoscalerSpec(autoscaler autoscaling.HorizontalPodAut if refErrs := validateMetrics(autoscaler.Metrics, fldPath.Child("metrics"), autoscaler.MinReplicas); len(refErrs) > 0 { allErrs = append(allErrs, refErrs...) } - if refErrs := validateBehavior(autoscaler.Behavior, fldPath.Child("behavior")); len(refErrs) > 0 { + if refErrs := validateBehavior(autoscaler.Behavior, fldPath.Child("behavior"), opts); len(refErrs) > 0 { allErrs = append(allErrs, refErrs...) } return allErrs @@ -115,7 +115,13 @@ func ValidateHorizontalPodAutoscaler(autoscaler *autoscaling.HorizontalPodAutosc } else { minReplicasLowerBound = 1 } - allErrs = append(allErrs, validateHorizontalPodAutoscalerSpec(autoscaler.Spec, field.NewPath("spec"), minReplicasLowerBound)...) + + opts := HorizontalPodAutoscalerSpecValidationOptions{ + AllowTolerance: utilfeature.DefaultMutableFeatureGate.Enabled(features.HPAConfigurableTolerance), + MinReplicasLowerBound: minReplicasLowerBound, + } + + allErrs = append(allErrs, validateHorizontalPodAutoscalerSpec(autoscaler.Spec, field.NewPath("spec"), opts)...) return allErrs } @@ -134,7 +140,12 @@ func ValidateHorizontalPodAutoscalerUpdate(newAutoscaler, oldAutoscaler *autosca minReplicasLowerBound = 1 } - allErrs = append(allErrs, validateHorizontalPodAutoscalerSpec(newAutoscaler.Spec, field.NewPath("spec"), minReplicasLowerBound)...) + opts := HorizontalPodAutoscalerSpecValidationOptions{ + AllowTolerance: utilfeature.DefaultMutableFeatureGate.Enabled(features.HPAConfigurableTolerance), + MinReplicasLowerBound: minReplicasLowerBound, + } + + allErrs = append(allErrs, validateHorizontalPodAutoscalerSpec(newAutoscaler.Spec, field.NewPath("spec"), opts)...) return allErrs } @@ -148,6 +159,15 @@ func ValidateHorizontalPodAutoscalerStatusUpdate(newAutoscaler, oldAutoscaler *a return allErrs } +// HorizontalPodAutoscalerSpecValidationOptions contains the different settings for +// HorizontalPodAutoscaler spec validation. +type HorizontalPodAutoscalerSpecValidationOptions struct { + // Allow setting a tolerance on HPAScalingRules. + AllowTolerance bool + // The minimum value for minReplicas. + MinReplicasLowerBound int32 +} + func validateMetrics(metrics []autoscaling.MetricSpec, fldPath *field.Path, minReplicas *int32) field.ErrorList { allErrs := field.ErrorList{} hasObjectMetrics := false @@ -175,13 +195,13 @@ func validateMetrics(metrics []autoscaling.MetricSpec, fldPath *field.Path, minR return allErrs } -func validateBehavior(behavior *autoscaling.HorizontalPodAutoscalerBehavior, fldPath *field.Path) field.ErrorList { +func validateBehavior(behavior *autoscaling.HorizontalPodAutoscalerBehavior, fldPath *field.Path, opts HorizontalPodAutoscalerSpecValidationOptions) field.ErrorList { allErrs := field.ErrorList{} if behavior != nil { - if scaleUpErrs := validateScalingRules(behavior.ScaleUp, fldPath.Child("scaleUp")); len(scaleUpErrs) > 0 { + if scaleUpErrs := validateScalingRules(behavior.ScaleUp, fldPath.Child("scaleUp"), opts); len(scaleUpErrs) > 0 { allErrs = append(allErrs, scaleUpErrs...) } - if scaleDownErrs := validateScalingRules(behavior.ScaleDown, fldPath.Child("scaleDown")); len(scaleDownErrs) > 0 { + if scaleDownErrs := validateScalingRules(behavior.ScaleDown, fldPath.Child("scaleDown"), opts); len(scaleDownErrs) > 0 { allErrs = append(allErrs, scaleDownErrs...) } } @@ -191,7 +211,7 @@ func validateBehavior(behavior *autoscaling.HorizontalPodAutoscalerBehavior, fld var validSelectPolicyTypes = sets.NewString(string(autoscaling.MaxPolicySelect), string(autoscaling.MinPolicySelect), string(autoscaling.DisabledPolicySelect)) var validSelectPolicyTypesList = validSelectPolicyTypes.List() -func validateScalingRules(rules *autoscaling.HPAScalingRules, fldPath *field.Path) field.ErrorList { +func validateScalingRules(rules *autoscaling.HPAScalingRules, fldPath *field.Path, opts HorizontalPodAutoscalerSpecValidationOptions) field.ErrorList { allErrs := field.ErrorList{} if rules != nil { if rules.StabilizationWindowSeconds != nil && *rules.StabilizationWindowSeconds < 0 { @@ -214,6 +234,13 @@ func validateScalingRules(rules *autoscaling.HPAScalingRules, fldPath *field.Pat allErrs = append(allErrs, policyErrs...) } } + + if rules.Tolerance != nil && !opts.AllowTolerance { + allErrs = append(allErrs, field.Forbidden(fldPath.Child("tolerance"), "tolerance is not supported when the HPAConfigurableTolerance feature gate is not enabled")) + } + if rules.Tolerance != nil && rules.Tolerance.Sign() < 0 { + allErrs = append(allErrs, field.Invalid(fldPath.Child("tolerance"), rules.Tolerance, "must be greater or equal to zero")) + } } return allErrs } diff --git a/pkg/apis/autoscaling/validation/validation_test.go b/pkg/apis/autoscaling/validation/validation_test.go index 443ee39199c..003e97c1edc 100644 --- a/pkg/apis/autoscaling/validation/validation_test.go +++ b/pkg/apis/autoscaling/validation/validation_test.go @@ -28,6 +28,7 @@ import ( api "k8s.io/kubernetes/pkg/apis/core" "k8s.io/kubernetes/pkg/features" utilpointer "k8s.io/utils/pointer" + "k8s.io/utils/ptr" ) func TestValidateScale(t *testing.T) { @@ -367,8 +368,9 @@ func TestValidateBehavior(t *testing.T) { func prepareHPAWithBehavior(b autoscaling.HorizontalPodAutoscalerBehavior) autoscaling.HorizontalPodAutoscaler { return autoscaling.HorizontalPodAutoscaler{ ObjectMeta: metav1.ObjectMeta{ - Name: "myautoscaler", - Namespace: metav1.NamespaceDefault, + Name: "myautoscaler", + Namespace: metav1.NamespaceDefault, + ResourceVersion: "1", }, Spec: autoscaling.HorizontalPodAutoscalerSpec{ ScaleTargetRef: autoscaling.CrossVersionObjectReference{ @@ -1652,3 +1654,236 @@ func TestValidateHorizontalPodAutoscalerScaleToZeroUpdateDisabled(t *testing.T) } } } + +func TestValidateHorizontalPodAutoscalerConfigurableToleranceEnabled(t *testing.T) { + // Enable HPAConfigurableTolerance feature gate. + featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.HPAConfigurableTolerance, true) + + policiesList := []autoscaling.HPAScalingPolicy{{ + Type: autoscaling.PodsScalingPolicy, + Value: 1, + PeriodSeconds: 1800, + }} + + successCases := []autoscaling.HPAScalingRules{ + { + Policies: policiesList, + Tolerance: ptr.To(resource.MustParse("0.1")), + }, + { + Policies: policiesList, + Tolerance: ptr.To(resource.MustParse("10")), + }, + { + Policies: policiesList, + Tolerance: ptr.To(resource.MustParse("0")), + }, + { + Policies: policiesList, + Tolerance: resource.NewMilliQuantity(100, resource.DecimalSI), + }, + { + Policies: policiesList, + Tolerance: resource.NewScaledQuantity(1, resource.Milli), + }, + { + Policies: policiesList, + }, + } + for _, c := range successCases { + b := autoscaling.HorizontalPodAutoscalerBehavior{ + ScaleDown: &c, + } + hpa := prepareHPAWithBehavior(b) + if errs := ValidateHorizontalPodAutoscaler(&hpa); len(errs) != 0 { + t.Errorf("expected success: %v", errs) + } + } + + failureCases := []struct { + rule autoscaling.HPAScalingRules + msg string + }{ + { + rule: autoscaling.HPAScalingRules{}, + msg: "at least one Policy", + }, + { + rule: autoscaling.HPAScalingRules{ + Policies: policiesList, + Tolerance: ptr.To(resource.MustParse("-0.001")), + }, + msg: "greater or equal to zero", + }, + { + rule: autoscaling.HPAScalingRules{ + Policies: policiesList, + Tolerance: resource.NewMilliQuantity(-10, resource.DecimalSI), + }, + msg: "greater or equal to zero", + }, + { + rule: autoscaling.HPAScalingRules{ + StabilizationWindowSeconds: utilpointer.Int32(60), + }, + msg: "at least one Policy", + }, + { + rule: autoscaling.HPAScalingRules{ + Tolerance: resource.NewMilliQuantity(1, resource.DecimalSI), + StabilizationWindowSeconds: utilpointer.Int32(60), + }, + msg: "at least one Policy", + }, + } + for _, c := range failureCases { + b := autoscaling.HorizontalPodAutoscalerBehavior{ + ScaleUp: &c.rule, + } + hpa := prepareHPAWithBehavior(b) + errs := ValidateHorizontalPodAutoscaler(&hpa) + if len(errs) != 1 { + t.Fatalf("expected exactly one error, got: %v", errs) + } + if !strings.Contains(errs[0].Error(), c.msg) { + t.Errorf("unexpected error: %q, expected: %q", errs[0], c.msg) + } + } +} + +func TestValidateHorizontalPodAutoscalerConfigurableToleranceDisabled(t *testing.T) { + // Disable HPAConfigurableTolerance feature gate. + featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.HPAConfigurableTolerance, false) + + maxPolicy := autoscaling.MaxPolicySelect + policiesList := []autoscaling.HPAScalingPolicy{{ + Type: autoscaling.PodsScalingPolicy, + Value: 1, + PeriodSeconds: 1800, + }} + + successCases := []autoscaling.HPAScalingRules{ + { + Policies: policiesList, + }, + { + SelectPolicy: &maxPolicy, + Policies: policiesList, + }, + { + StabilizationWindowSeconds: utilpointer.Int32(60), + Policies: policiesList, + }, + } + for _, c := range successCases { + b := autoscaling.HorizontalPodAutoscalerBehavior{ + ScaleDown: &c, + } + hpa := prepareHPAWithBehavior(b) + if errs := ValidateHorizontalPodAutoscaler(&hpa); len(errs) != 0 { + t.Errorf("expected success: %v", errs) + } + } + + failureCases := []struct { + rule autoscaling.HPAScalingRules + msg string + }{ + { + rule: autoscaling.HPAScalingRules{ + Policies: policiesList, + Tolerance: resource.NewMilliQuantity(1, resource.DecimalSI), + }, + msg: "not supported", + }, + { + rule: autoscaling.HPAScalingRules{}, + msg: "at least one Policy", + }, + { + rule: autoscaling.HPAScalingRules{ + StabilizationWindowSeconds: utilpointer.Int32(60), + }, + msg: "at least one Policy", + }, + } + for _, c := range failureCases { + b := autoscaling.HorizontalPodAutoscalerBehavior{ + ScaleUp: &c.rule, + } + hpa := prepareHPAWithBehavior(b) + errs := ValidateHorizontalPodAutoscaler(&hpa) + if len(errs) != 1 { + t.Fatalf("expected exactly one error, got: %v", errs) + } + if !strings.Contains(errs[0].Error(), c.msg) { + t.Errorf("unexpected error: %q, expected: %q", errs[0], c.msg) + } + } +} + +func TestValidateHorizontalPodAutoscalerUpdateConfigurableToleranceEnabled(t *testing.T) { + // Enable HPAConfigurableTolerance feature gate. + featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.HPAConfigurableTolerance, true) + + policiesList := []autoscaling.HPAScalingPolicy{{ + Type: autoscaling.PodsScalingPolicy, + Value: 1, + PeriodSeconds: 1800, + }} + + withToleranceHPA := prepareHPAWithBehavior(autoscaling.HorizontalPodAutoscalerBehavior{ + ScaleUp: &autoscaling.HPAScalingRules{ + Policies: policiesList, + Tolerance: resource.NewMilliQuantity(10, resource.DecimalSI), + }}) + withoutToleranceHPA := prepareHPAWithBehavior(autoscaling.HorizontalPodAutoscalerBehavior{ + ScaleUp: &autoscaling.HPAScalingRules{ + Policies: policiesList, + }}) + + if errs := ValidateHorizontalPodAutoscalerUpdate(&withToleranceHPA, &withoutToleranceHPA); len(errs) != 0 { + t.Errorf("expected success: %v", errs) + } + + if errs := ValidateHorizontalPodAutoscalerUpdate(&withoutToleranceHPA, &withToleranceHPA); len(errs) != 0 { + t.Errorf("expected success: %v", errs) + } +} + +func TestValidateHorizontalPodAutoscalerConfigurableToleranceUpdateDisabled(t *testing.T) { + // Disable HPAConfigurableTolerance feature gate. + featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.HPAConfigurableTolerance, false) + + policiesList := []autoscaling.HPAScalingPolicy{{ + Type: autoscaling.PodsScalingPolicy, + Value: 1, + PeriodSeconds: 1800, + }} + + withToleranceHPA := prepareHPAWithBehavior(autoscaling.HorizontalPodAutoscalerBehavior{ + ScaleUp: &autoscaling.HPAScalingRules{ + Policies: policiesList, + Tolerance: resource.NewMilliQuantity(10, resource.DecimalSI), + }}) + withoutToleranceHPA := prepareHPAWithBehavior(autoscaling.HorizontalPodAutoscalerBehavior{ + ScaleUp: &autoscaling.HPAScalingRules{ + Policies: policiesList, + }}) + notSupportedErrorMsg := "not supported" + + errs := ValidateHorizontalPodAutoscalerUpdate(&withToleranceHPA, &withoutToleranceHPA) + if len(errs) == 0 { + t.Errorf("expected failure for %q", notSupportedErrorMsg) + } else if !strings.Contains(errs[0].Error(), notSupportedErrorMsg) { + t.Errorf("unexpected error: %q, expected: %q", errs[0], notSupportedErrorMsg) + } + + if errs := ValidateHorizontalPodAutoscalerUpdate(&withoutToleranceHPA, &withoutToleranceHPA); len(errs) != 0 { + t.Errorf("expected success: %v", errs) + } + + if errs := ValidateHorizontalPodAutoscalerUpdate(&withoutToleranceHPA, &withToleranceHPA); len(errs) != 0 { + t.Errorf("expected success: %v", errs) + } +} diff --git a/staging/src/k8s.io/api/autoscaling/v2/types.go b/staging/src/k8s.io/api/autoscaling/v2/types.go index 99e8db09dc8..e6b9a5fc205 100644 --- a/staging/src/k8s.io/api/autoscaling/v2/types.go +++ b/staging/src/k8s.io/api/autoscaling/v2/types.go @@ -171,12 +171,18 @@ const ( DisabledPolicySelect ScalingPolicySelect = "Disabled" ) -// HPAScalingRules configures the scaling behavior for one direction. -// These Rules are applied after calculating DesiredReplicas from metrics for the HPA. +// HPAScalingRules configures the scaling behavior for one direction via +// scaling Policy Rules and a configurable metric tolerance. +// +// Scaling Policy Rules are applied after calculating DesiredReplicas from metrics for the HPA. // They can limit the scaling velocity by specifying scaling policies. // They can prevent flapping by specifying the stabilization window, so that the // number of replicas is not set instantly, instead, the safest value from the stabilization // window is chosen. +// +// The tolerance is applied to the metric values and prevents scaling too +// eagerly for small metric variations. (Note that setting a tolerance requires +// enabling the alpha HPAConfigurableTolerance feature gate.) type HPAScalingRules struct { // stabilizationWindowSeconds is the number of seconds for which past recommendations should be // considered while scaling up or scaling down. @@ -193,10 +199,24 @@ type HPAScalingRules struct { SelectPolicy *ScalingPolicySelect `json:"selectPolicy,omitempty" protobuf:"bytes,1,opt,name=selectPolicy"` // policies is a list of potential scaling polices which can be used during scaling. - // At least one policy must be specified, otherwise the HPAScalingRules will be discarded as invalid + // If not set, use the default values: + // - For scale up: allow doubling the number of pods, or an absolute change of 4 pods in a 15s window. + // - For scale down: allow all pods to be removed in a 15s window. // +listType=atomic // +optional Policies []HPAScalingPolicy `json:"policies,omitempty" listType:"atomic" protobuf:"bytes,2,rep,name=policies"` + + // tolerance is the tolerance on the ratio between the current and desired + // metric value under which no updates are made to the desired number of + // replicas (e.g. 0.01 for 1%). Must be greater than or equal to zero. If not + // set, the default cluster-wide tolerance is applied (by default 10%). + // + // This is an alpha field and requires enabling the HPAConfigurableTolerance + // feature gate. + // + // +featureGate=HPAConfigurableTolerance + // +optional + Tolerance *resource.Quantity `json:"tolerance,omitempty" protobuf:"bytes,4,opt,name=tolerance"` } // HPAScalingPolicyType is the type of the policy which could be used while making scaling decisions.