diff --git a/pkg/apis/autoscaling/v1/zz_generated.validations.go b/pkg/apis/autoscaling/v1/zz_generated.validations.go index f0e3b8ebdaf..d342b848010 100644 --- a/pkg/apis/autoscaling/v1/zz_generated.validations.go +++ b/pkg/apis/autoscaling/v1/zz_generated.validations.go @@ -102,6 +102,12 @@ func Validate_HorizontalPodAutoscalerSpec(ctx context.Context, op operation.Oper if earlyReturn { return // do not proceed } + errs = append(errs, validate.IfOption(ctx, op, fldPath, obj, oldObj, "HPAScaleToZero", false, func(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj *int32) field.ErrorList { + return validate.Minimum(ctx, op, fldPath, obj, oldObj, 1) + })...) + errs = append(errs, validate.IfOption(ctx, op, fldPath, obj, oldObj, "HPAScaleToZero", true, func(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj *int32) field.ErrorList { + return validate.Minimum(ctx, op, fldPath, obj, oldObj, 0) + })...) return }(fldPath.Child("minReplicas"), obj.MinReplicas, safe.Field(oldObj, func(oldObj *autoscalingv1.HorizontalPodAutoscalerSpec) *int32 { return oldObj.MinReplicas }), oldObj != nil)...) diff --git a/pkg/apis/autoscaling/v2/zz_generated.validations.go b/pkg/apis/autoscaling/v2/zz_generated.validations.go index 207f95b0115..e5bad2c1f9a 100644 --- a/pkg/apis/autoscaling/v2/zz_generated.validations.go +++ b/pkg/apis/autoscaling/v2/zz_generated.validations.go @@ -94,6 +94,12 @@ func Validate_HorizontalPodAutoscalerSpec(ctx context.Context, op operation.Oper if earlyReturn { return // do not proceed } + errs = append(errs, validate.IfOption(ctx, op, fldPath, obj, oldObj, "HPAScaleToZero", false, func(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj *int32) field.ErrorList { + return validate.Minimum(ctx, op, fldPath, obj, oldObj, 1) + })...) + errs = append(errs, validate.IfOption(ctx, op, fldPath, obj, oldObj, "HPAScaleToZero", true, func(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj *int32) field.ErrorList { + return validate.Minimum(ctx, op, fldPath, obj, oldObj, 0) + })...) return }(fldPath.Child("minReplicas"), obj.MinReplicas, safe.Field(oldObj, func(oldObj *autoscalingv2.HorizontalPodAutoscalerSpec) *int32 { return oldObj.MinReplicas }), oldObj != nil)...) diff --git a/pkg/apis/autoscaling/validation/validation.go b/pkg/apis/autoscaling/validation/validation.go index cf0a82bf202..34b7926147a 100644 --- a/pkg/apis/autoscaling/validation/validation.go +++ b/pkg/apis/autoscaling/validation/validation.go @@ -57,7 +57,7 @@ func validateHorizontalPodAutoscalerSpec(autoscaler autoscaling.HorizontalPodAut 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", opts.MinReplicasLowerBound))) + fmt.Sprintf("must be greater than or equal to %d", opts.MinReplicasLowerBound)).WithOrigin("minimum").MarkCoveredByDeclarative()) } if autoscaler.MaxReplicas == 0 { allErrs = append(allErrs, field.Required(fldPath.Child("maxReplicas"), "must be set and greater than 0").MarkCoveredByDeclarative()) diff --git a/pkg/registry/autoscaling/horizontalpodautoscaler/declarative_validation_test.go b/pkg/registry/autoscaling/horizontalpodautoscaler/declarative_validation_test.go index 36212dc348e..ef42c980fd2 100644 --- a/pkg/registry/autoscaling/horizontalpodautoscaler/declarative_validation_test.go +++ b/pkg/registry/autoscaling/horizontalpodautoscaler/declarative_validation_test.go @@ -19,11 +19,16 @@ package horizontalpodautoscaler import ( "testing" + "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/util/validation/field" genericapirequest "k8s.io/apiserver/pkg/endpoints/request" + utilfeature "k8s.io/apiserver/pkg/util/feature" + featuregatetesting "k8s.io/component-base/featuregate/testing" apitesting "k8s.io/kubernetes/pkg/api/testing" api "k8s.io/kubernetes/pkg/apis/autoscaling" + "k8s.io/kubernetes/pkg/features" + "k8s.io/utils/ptr" ) var apiVersions = []string{"v1", "v2"} @@ -41,12 +46,17 @@ func testDeclarativeValidate(t *testing.T, apiVersion string) { Resource: "horizontalpodautoscalers", }) testCases := map[string]struct { - input api.HorizontalPodAutoscaler - expectedErrs field.ErrorList + input api.HorizontalPodAutoscaler + expectedErrs field.ErrorList + enableScaleToZero bool }{ "valid: minReplicas = 5": { input: makeValidHPA(tweakMinReplicas(5)), }, + "valid: minReplicas = 0 (gate enabled)": { + input: makeValidHPA(tweakMinReplicas(0), tweakMetrics(validScaleToZeroMetrics...)), + enableScaleToZero: true, + }, "valid: minReplicas not set (nil)": { input: makeValidHPA(), // Default, no minReplicas set }, @@ -62,9 +72,16 @@ func testDeclarativeValidate(t *testing.T, apiVersion string) { field.Invalid(field.NewPath("spec", "maxReplicas"), int32(-1), "must be greater than or equal to 1").WithOrigin("minimum"), }, }, + "invalid: minReplicas = 0 (gate disabled)": { + input: makeValidHPA(tweakMinReplicas(0), tweakMetrics(validScaleToZeroMetrics...)), + expectedErrs: field.ErrorList{ + field.Invalid(field.NewPath("spec", "minReplicas"), int32(0), "must be greater than or equal to 1").WithOrigin("minimum"), + }, + }, } for name, tc := range testCases { t.Run(name, func(t *testing.T) { + featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.HPAScaleToZero, tc.enableScaleToZero) apitesting.VerifyValidationEquivalence(t, ctx, &tc.input, Strategy.Validate, tc.expectedErrs) }) } @@ -80,9 +97,10 @@ func TestDeclarativeValidateUpdate(t *testing.T) { func testDeclarativeValidateUpdate(t *testing.T, apiVersion string) { testCases := map[string]struct { - oldObj api.HorizontalPodAutoscaler - updateObj api.HorizontalPodAutoscaler - expectedErrs field.ErrorList + oldObj api.HorizontalPodAutoscaler + updateObj api.HorizontalPodAutoscaler + expectedErrs field.ErrorList + enableScaleToZero bool }{ "valid update": { oldObj: makeValidHPA(), @@ -92,6 +110,11 @@ func testDeclarativeValidateUpdate(t *testing.T, apiVersion string) { oldObj: makeValidHPA(tweakMinReplicas(1)), updateObj: makeValidHPA(tweakMinReplicas(5)), }, + "valid update: minReplicas 1 -> 0 (gate enabled)": { + oldObj: makeValidHPA(tweakMinReplicas(1), tweakMetrics(validScaleToZeroMetrics...)), + updateObj: makeValidHPA(tweakMinReplicas(0), tweakMetrics(validScaleToZeroMetrics...)), + enableScaleToZero: true, + }, "invalid update: maxReplicas = 0 (required)": { oldObj: makeValidHPA(), updateObj: makeValidHPA(tweakMaxReplicas(0)), @@ -106,9 +129,22 @@ func testDeclarativeValidateUpdate(t *testing.T, apiVersion string) { field.Invalid(field.NewPath("spec", "maxReplicas"), int32(-1), "must be greater than or equal to 1").WithOrigin("minimum"), }, }, + "invalid update: minReplicas 1 -> 0 (gate disabled)": { + oldObj: makeValidHPA(tweakMinReplicas(1), tweakMetrics(validScaleToZeroMetrics...)), + updateObj: makeValidHPA(tweakMinReplicas(0), tweakMetrics(validScaleToZeroMetrics...)), + expectedErrs: field.ErrorList{ + field.Invalid(field.NewPath("spec", "minReplicas"), int32(0), "must be greater than or equal to 1").WithOrigin("minimum"), + }, + }, + "valid update: ratcheting minReplicas=0 when gate disabled": { + oldObj: makeValidHPA(tweakMinReplicas(0), tweakMetrics(validScaleToZeroMetrics...)), + updateObj: makeValidHPA(tweakMinReplicas(0), tweakMetrics(validScaleToZeroMetrics...), tweakMaxReplicas(20)), + enableScaleToZero: false, + }, } for name, tc := range testCases { t.Run(name, func(t *testing.T) { + featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.HPAScaleToZero, tc.enableScaleToZero) ctx := genericapirequest.WithRequestInfo(genericapirequest.NewDefaultContext(), &genericapirequest.RequestInfo{ APIGroup: "autoscaling", APIVersion: apiVersion, @@ -155,3 +191,29 @@ func tweakMaxReplicas(replicas int32) func(*api.HorizontalPodAutoscaler) { hpa.Spec.MaxReplicas = replicas } } + +func tweakMetrics(metrics ...api.MetricSpec) func(*api.HorizontalPodAutoscaler) { + return func(hpa *api.HorizontalPodAutoscaler) { + hpa.Spec.Metrics = metrics + } +} + +var validScaleToZeroMetrics = []api.MetricSpec{ + { + Type: api.ObjectMetricSourceType, + Object: &api.ObjectMetricSource{ + Metric: api.MetricIdentifier{ + Name: "requests-per-second", + }, + Target: api.MetricTarget{ + Type: api.ValueMetricType, + Value: ptr.To(resource.MustParse("10k")), + }, + DescribedObject: api.CrossVersionObjectReference{ + Kind: "Ingress", + Name: "main-route", + APIVersion: "networking.k8s.io/v1", + }, + }, + }, +} diff --git a/pkg/registry/autoscaling/horizontalpodautoscaler/strategy.go b/pkg/registry/autoscaling/horizontalpodautoscaler/strategy.go index 945a45644b7..a10f3fa49c5 100644 --- a/pkg/registry/autoscaling/horizontalpodautoscaler/strategy.go +++ b/pkg/registry/autoscaling/horizontalpodautoscaler/strategy.go @@ -77,7 +77,11 @@ func (autoscalerStrategy) Validate(ctx context.Context, obj runtime.Object) fiel autoscaler := obj.(*autoscaling.HorizontalPodAutoscaler) opts := validationOptionsForHorizontalPodAutoscaler(autoscaler, nil) allErrs := validation.ValidateHorizontalPodAutoscaler(autoscaler, opts) - return rest.ValidateDeclarativelyWithMigrationChecks(ctx, legacyscheme.Scheme, autoscaler, nil, allErrs, operation.Create) + var options []string + if utilfeature.DefaultFeatureGate.Enabled(features.HPAScaleToZero) { + options = append(options, "HPAScaleToZero") + } + return rest.ValidateDeclarativelyWithMigrationChecks(ctx, legacyscheme.Scheme, autoscaler, nil, allErrs, operation.Create, rest.WithOptions(options)) } // WarningsOnCreate returns warnings for the creation of the given object. @@ -110,7 +114,12 @@ func (autoscalerStrategy) ValidateUpdate(ctx context.Context, obj, old runtime.O oldHPA := old.(*autoscaling.HorizontalPodAutoscaler) opts := validationOptionsForHorizontalPodAutoscaler(newHPA, oldHPA) errs := validation.ValidateHorizontalPodAutoscalerUpdate(newHPA, oldHPA, opts) - return rest.ValidateDeclarativelyWithMigrationChecks(ctx, legacyscheme.Scheme, newHPA, oldHPA, errs, operation.Update) + var options []string + oldHasZeroMinReplicas := oldHPA.Spec.MinReplicas != nil && *oldHPA.Spec.MinReplicas == 0 + if utilfeature.DefaultFeatureGate.Enabled(features.HPAScaleToZero) || oldHasZeroMinReplicas { + options = append(options, "HPAScaleToZero") + } + return rest.ValidateDeclarativelyWithMigrationChecks(ctx, legacyscheme.Scheme, newHPA, oldHPA, errs, operation.Update, rest.WithOptions(options)) } // WarningsOnUpdate returns warnings for the given update. diff --git a/staging/src/k8s.io/api/autoscaling/v1/generated.proto b/staging/src/k8s.io/api/autoscaling/v1/generated.proto index 405c57fcbf9..1b90e8a95b0 100644 --- a/staging/src/k8s.io/api/autoscaling/v1/generated.proto +++ b/staging/src/k8s.io/api/autoscaling/v1/generated.proto @@ -203,6 +203,8 @@ message HorizontalPodAutoscalerSpec { // available. // +optional // +k8s:optional + // +k8s:ifEnabled(HPAScaleToZero)=+k8s:minimum=0 + // +k8s:ifDisabled(HPAScaleToZero)=+k8s:minimum=1 optional int32 minReplicas = 2; // maxReplicas is the upper limit for the number of pods that can be set by the autoscaler; cannot be smaller than MinReplicas. diff --git a/staging/src/k8s.io/api/autoscaling/v1/types.go b/staging/src/k8s.io/api/autoscaling/v1/types.go index 645ed2ac57d..5bc1a5a70f9 100644 --- a/staging/src/k8s.io/api/autoscaling/v1/types.go +++ b/staging/src/k8s.io/api/autoscaling/v1/types.go @@ -48,6 +48,8 @@ type HorizontalPodAutoscalerSpec struct { // available. // +optional // +k8s:optional + // +k8s:ifEnabled(HPAScaleToZero)=+k8s:minimum=0 + // +k8s:ifDisabled(HPAScaleToZero)=+k8s:minimum=1 MinReplicas *int32 `json:"minReplicas,omitempty" protobuf:"varint,2,opt,name=minReplicas"` // maxReplicas is the upper limit for the number of pods that can be set by the autoscaler; cannot be smaller than MinReplicas. diff --git a/staging/src/k8s.io/api/autoscaling/v2/generated.proto b/staging/src/k8s.io/api/autoscaling/v2/generated.proto index 75fc6cefb6f..f074f038fd7 100644 --- a/staging/src/k8s.io/api/autoscaling/v2/generated.proto +++ b/staging/src/k8s.io/api/autoscaling/v2/generated.proto @@ -249,6 +249,8 @@ message HorizontalPodAutoscalerSpec { // available. // +optional // +k8s:optional + // +k8s:ifEnabled(HPAScaleToZero)=+k8s:minimum=0 + // +k8s:ifDisabled(HPAScaleToZero)=+k8s:minimum=1 optional int32 minReplicas = 2; // maxReplicas is the upper limit for the number of replicas to which the autoscaler can scale up. diff --git a/staging/src/k8s.io/api/autoscaling/v2/types.go b/staging/src/k8s.io/api/autoscaling/v2/types.go index 84d1ec203ee..f99ff1c7bdc 100644 --- a/staging/src/k8s.io/api/autoscaling/v2/types.go +++ b/staging/src/k8s.io/api/autoscaling/v2/types.go @@ -60,6 +60,8 @@ type HorizontalPodAutoscalerSpec struct { // available. // +optional // +k8s:optional + // +k8s:ifEnabled(HPAScaleToZero)=+k8s:minimum=0 + // +k8s:ifDisabled(HPAScaleToZero)=+k8s:minimum=1 MinReplicas *int32 `json:"minReplicas,omitempty" protobuf:"varint,2,opt,name=minReplicas"` // maxReplicas is the upper limit for the number of replicas to which the autoscaler can scale up.