From 09681e23a2630242fafacb07b932d379021a24c5 Mon Sep 17 00:00:00 2001 From: yongruilin Date: Thu, 12 Feb 2026 21:17:00 +0000 Subject: [PATCH 1/4] feat: migrate HorizontalPodAutoscalerSpec.MinReplicas to declarative validation --- pkg/apis/autoscaling/v1/zz_generated.validations.go | 6 ++++++ pkg/apis/autoscaling/v2/zz_generated.validations.go | 6 ++++++ pkg/apis/autoscaling/validation/validation.go | 2 +- .../autoscaling/horizontalpodautoscaler/strategy.go | 12 ++++++++++-- .../src/k8s.io/api/autoscaling/v1/generated.proto | 2 ++ staging/src/k8s.io/api/autoscaling/v1/types.go | 2 ++ .../src/k8s.io/api/autoscaling/v2/generated.proto | 2 ++ staging/src/k8s.io/api/autoscaling/v2/types.go | 2 ++ 8 files changed, 31 insertions(+), 3 deletions(-) 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/strategy.go b/pkg/registry/autoscaling/horizontalpodautoscaler/strategy.go index 945a45644b7..5b3aa968b57 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,11 @@ 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 + if utilfeature.DefaultFeatureGate.Enabled(features.HPAScaleToZero) { + 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 893acf80548..175a164f0b1 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 55079e980df..9c3124c8583 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 39877eba6a8..53089e4c8b2 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 1544e9a6089..752a8e2bf95 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. From 36fcbaef232f736c62f527e8d9a238f8cb989c49 Mon Sep 17 00:00:00 2001 From: yongruilin Date: Thu, 12 Feb 2026 21:17:24 +0000 Subject: [PATCH 2/4] test: add declarative validation tests for HPA MinReplicas --- .../declarative_validation_test.go | 67 +++++++++++++++++-- 1 file changed, 62 insertions(+), 5 deletions(-) diff --git a/pkg/registry/autoscaling/horizontalpodautoscaler/declarative_validation_test.go b/pkg/registry/autoscaling/horizontalpodautoscaler/declarative_validation_test.go index 36212dc348e..c77363afa83 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,17 @@ 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"), + }, + }, } 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 +186,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", + }, + }, + }, +} From c9c926a96c28a2bc67be61a187529c45b5a0dd5d Mon Sep 17 00:00:00 2001 From: yongruilin Date: Thu, 12 Feb 2026 21:23:18 +0000 Subject: [PATCH 3/4] test: add ratcheting test case for HPA MinReplicas --- .../horizontalpodautoscaler/declarative_validation_test.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pkg/registry/autoscaling/horizontalpodautoscaler/declarative_validation_test.go b/pkg/registry/autoscaling/horizontalpodautoscaler/declarative_validation_test.go index c77363afa83..ef42c980fd2 100644 --- a/pkg/registry/autoscaling/horizontalpodautoscaler/declarative_validation_test.go +++ b/pkg/registry/autoscaling/horizontalpodautoscaler/declarative_validation_test.go @@ -136,6 +136,11 @@ func testDeclarativeValidateUpdate(t *testing.T, apiVersion string) { 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) { From 8dd711ea808290692eb0442ffa045707cd967631 Mon Sep 17 00:00:00 2001 From: yongruilin Date: Fri, 13 Feb 2026 22:50:16 +0000 Subject: [PATCH 4/4] address feedback: pass HPAScaleToZero option when already in use --- pkg/registry/autoscaling/horizontalpodautoscaler/strategy.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/registry/autoscaling/horizontalpodautoscaler/strategy.go b/pkg/registry/autoscaling/horizontalpodautoscaler/strategy.go index 5b3aa968b57..a10f3fa49c5 100644 --- a/pkg/registry/autoscaling/horizontalpodautoscaler/strategy.go +++ b/pkg/registry/autoscaling/horizontalpodautoscaler/strategy.go @@ -115,7 +115,8 @@ func (autoscalerStrategy) ValidateUpdate(ctx context.Context, obj, old runtime.O opts := validationOptionsForHorizontalPodAutoscaler(newHPA, oldHPA) errs := validation.ValidateHorizontalPodAutoscalerUpdate(newHPA, oldHPA, opts) var options []string - if utilfeature.DefaultFeatureGate.Enabled(features.HPAScaleToZero) { + 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))