diff --git a/cmd/controller/main.go b/cmd/controller/main.go index 99b4c8c7d3d1..a6a44fc3a851 100644 --- a/cmd/controller/main.go +++ b/cmd/controller/main.go @@ -44,6 +44,7 @@ import ( "knative.dev/serving/pkg/reconciler/route" "knative.dev/serving/pkg/reconciler/serverlessservice" "knative.dev/serving/pkg/reconciler/service" + "knative.dev/serving/pkg/reconciler/servicescaler" versioned "github.com/cert-manager/cert-manager/pkg/client/clientset/versioned" "knative.dev/serving/pkg/client/certmanager/injection/informers/acme/v1/challenge" @@ -63,6 +64,7 @@ var ctors = []injection.ControllerConstructor{ gc.NewController, nscert.NewController, domainmapping.NewController, + servicescaler.NewController, } func main() { diff --git a/docs/controllers/CONTROLLERS.md b/docs/controllers/CONTROLLERS.md index 417f39205e23..627a5421abcb 100644 --- a/docs/controllers/CONTROLLERS.md +++ b/docs/controllers/CONTROLLERS.md @@ -333,7 +333,30 @@ The Labeler controller syncs routing metadata (labels/annotations) from Routes t --- -### 13. Garbage Collection (GC) Controller +### 13. Service Scaler Controller + +**Location:** `pkg/reconciler/servicescaler/` + +**Watched Resource:** `serving.knative.dev/v1.Route` + +**Responsibility:** +The Service Scaler controller service scaling (currently only service minscale) annotations from Routes to their referenced Revisions. This enables +service scaling annotations to be propagated to the pod autoscaler for scaling decisions. + +**Key Functions:** +- Adds/updates annotations on **Revisions** referenced by Route Status traffic: + - `serving.knative.dev/service-min-scale` - denotes minscale across route traffic + - `serving.knative.dev/service-min-scale-route` - denotes which route set the service min scale decision +- Removes scaling annotations from revisions when Routes are deleted (via Finalizer) +- Does NOT modify Route status (SkipStatusUpdates: true) + +**Informers Watched:** +- Route (primary) +- Revision (tracked and annotated) + +--- + +### 14. Garbage Collection (GC) Controller **Location:** `pkg/reconciler/gc/` diff --git a/pkg/apis/autoscaling/v1alpha1/pa_lifecycle.go b/pkg/apis/autoscaling/v1alpha1/pa_lifecycle.go index 9cff0ab9f547..40de136e1e17 100644 --- a/pkg/apis/autoscaling/v1alpha1/pa_lifecycle.go +++ b/pkg/apis/autoscaling/v1alpha1/pa_lifecycle.go @@ -92,6 +92,9 @@ func (pa *PodAutoscaler) ScaleBounds(asConfig *autoscalerconfig.Config) (int32, if paMin, ok := pa.annotationInt32(autoscaling.MinScaleAnnotation); ok { min = paMin } + if serviceMin, ok := pa.annotationInt32(serving.ServiceMinscaleAnnotation); ok { + min = intMax(min, serviceMin) + } } max := asConfig.MaxScale @@ -307,3 +310,10 @@ func (pas *PodAutoscalerStatus) GetActualScale() int32 { } return -1 } + +func intMax(a, b int32) int32 { + if a < b { + return b + } + return a +} diff --git a/pkg/apis/serving/metadata_validation.go b/pkg/apis/serving/metadata_validation.go index 3f01b80cd6ea..a2c6f89127c1 100644 --- a/pkg/apis/serving/metadata_validation.go +++ b/pkg/apis/serving/metadata_validation.go @@ -18,7 +18,10 @@ package serving import ( "context" + "errors" "fmt" + "math" + "strconv" "strings" "time" @@ -26,6 +29,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "knative.dev/pkg/apis" + "knative.dev/pkg/kmap" "knative.dev/serving/pkg/apis/autoscaling" "knative.dev/serving/pkg/apis/config" ) @@ -75,6 +79,11 @@ func ValidateRolloutDurationAnnotation(annos map[string]string) (errs *apis.Fiel return errs } +// ValidateServiceMinscaleAnnotationKey validates the service minscale annotation. +func ValidateServiceMinscaleAnnotationKey(annos map[string]string) *apis.FieldError { + return getIntGE0(annos, ServiceMinscaleAnnotation) +} + // ValidateHasNoAutoscalingAnnotation validates that the respective entity does not have // annotations from the autoscaling group. It's to be used to validate Service and // Configuration. @@ -130,3 +139,23 @@ func SetUserInfo(ctx context.Context, oldSpec, newSpec, resource interface{}) { } } } + +func getIntGE0(m map[string]string, key kmap.KeyPriority) *apis.FieldError { + k, v, ok := key.Get(m) + if !ok { + return nil + } + // Parsing as uint gives a bad format error, rather than invalid range, unfortunately. + i, err := strconv.ParseInt(v, 10, 32) + if err != nil { + if errors.Is(err, strconv.ErrRange) { + return apis.ErrOutOfBoundsValue(v, 0, math.MaxInt32, k) + } + return apis.ErrInvalidValue(v, k) + } + if i < 0 { + return apis.ErrOutOfBoundsValue(v, 0, math.MaxInt32, k) + } + + return nil +} diff --git a/pkg/apis/serving/metadata_validation_test.go b/pkg/apis/serving/metadata_validation_test.go index b13f8630a93b..f5e9b07c4490 100644 --- a/pkg/apis/serving/metadata_validation_test.go +++ b/pkg/apis/serving/metadata_validation_test.go @@ -491,3 +491,32 @@ func TestValidateRolloutDurationAnnotation(t *testing.T) { }) } } + +func TestValidateServiceMinscaleAnnotationKey(t *testing.T) { + tests := []struct { + name string + value string + want string + }{{ + name: "empty", + want: "invalid value: : serving.knative.dev/service-min-scale", + }, { + name: "valid", + value: "3", + }, { + name: "invalid", + value: "-1", + want: "expected 0 <= -1 <= 2147483647: serving.knative.dev/service-min-scale", + }} + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := ValidateServiceMinscaleAnnotationKey(map[string]string{ + ServiceMinscaleAnnotationKey: tc.value, + }) + if got, want := err.Error(), tc.want; got != want { + t.Errorf("APIErr mismatch, diff(-want,+got):\n%s", cmp.Diff(want, got)) + } + }) + } +} diff --git a/pkg/apis/serving/register.go b/pkg/apis/serving/register.go index 74f326b77496..c985af4b0bec 100644 --- a/pkg/apis/serving/register.go +++ b/pkg/apis/serving/register.go @@ -92,6 +92,14 @@ const ( // which Service they are created. ServiceLabelKey = GroupName + "/service" + // ServiceMinscaleAnnotationKey is the annotation key attached to a Route and Service indicating + // the minScale across all revisions + ServiceMinscaleAnnotationKey = GroupName + "/service-min-scale" + + // ServiceMinscaleAnnotationKey is the annotation key attached to a revision indicating + // which route attached the service min scale, used in the case of multiple routes having /service-min-scale + ServiceMinscaleRouteAnnotationKey = GroupName + "/service-min-scale-route" + // DomainMappingUIDLabelKey is the label key attached to Ingress resources to indicate // which DomainMapping triggered their creation. // This uses a uid rather than a name because domain mapping names can exceed @@ -182,6 +190,12 @@ var ( RolloutDurationKey, GroupName + "/rolloutDuration", } + ServiceMinscaleAnnotation = kmap.KeyPriority{ + ServiceMinscaleAnnotationKey, + } + ServiceMinscaleRouteAnnotation = kmap.KeyPriority{ + ServiceMinscaleRouteAnnotationKey, + } QueueSidecarResourcePercentageAnnotation = kmap.KeyPriority{ QueueSidecarResourcePercentageAnnotationKey, "queue.sidecar." + GroupName + "/resourcePercentage", diff --git a/pkg/apis/serving/v1/route_validation.go b/pkg/apis/serving/v1/route_validation.go index a30b047fa88e..5eeac64f7880 100644 --- a/pkg/apis/serving/v1/route_validation.go +++ b/pkg/apis/serving/v1/route_validation.go @@ -32,6 +32,7 @@ func (r *Route) Validate(ctx context.Context) *apis.FieldError { errs = errs.Also(serving.ValidateRolloutDurationAnnotation(r.GetAnnotations()).ViaField("annotations")) errs = errs.ViaField("metadata") errs = errs.Also(r.Spec.Validate(apis.WithinSpec(ctx)).ViaField("spec")) + errs = errs.Also(serving.ValidateServiceMinscaleAnnotationKey(r.GetAnnotations()).ViaField("annotations")) if apis.IsInUpdate(ctx) { original := apis.GetBaseline(ctx).(*Route) diff --git a/pkg/apis/serving/v1/route_validation_test.go b/pkg/apis/serving/v1/route_validation_test.go index 50b3a62cf98a..7c276897f21e 100644 --- a/pkg/apis/serving/v1/route_validation_test.go +++ b/pkg/apis/serving/v1/route_validation_test.go @@ -791,6 +791,19 @@ func TestRouteAnnotationUpdate(t *testing.T) { }, Spec: getRouteSpec("old"), }, + }, { + name: "service min scale validation, fail", + this: &Route{ + ObjectMeta: metav1.ObjectMeta{ + Name: "valid", + Annotations: map[string]string{ + serving.ServiceMinscaleAnnotationKey: "-1", + }, + }, + Spec: getRouteSpec("new"), + }, + wantErr: apis.ErrGeneric("expected 0 <= -1 <= 2147483647", + serving.ServiceMinscaleAnnotationKey).ViaField("annotations"), }} for _, test := range tests { t.Run(test.name, func(t *testing.T) { diff --git a/pkg/apis/serving/v1/service_validation.go b/pkg/apis/serving/v1/service_validation.go index a68421a18105..c7c18e706465 100644 --- a/pkg/apis/serving/v1/service_validation.go +++ b/pkg/apis/serving/v1/service_validation.go @@ -32,6 +32,7 @@ func (s *Service) Validate(ctx context.Context) (errs *apis.FieldError) { if !apis.IsInStatusUpdate(ctx) { errs = errs.Also(serving.ValidateObjectMetadata(ctx, s.GetObjectMeta(), false)) errs = errs.Also(serving.ValidateRolloutDurationAnnotation(s.GetAnnotations()).ViaField("annotations")) + errs = errs.Also(serving.ValidateServiceMinscaleAnnotationKey(s.GetAnnotations()).ViaField("annotations")) errs = errs.ViaField("metadata") ctx = apis.WithinParent(ctx, s.ObjectMeta) diff --git a/pkg/reconciler/autoscaling/kpa/kpa_test.go b/pkg/reconciler/autoscaling/kpa/kpa_test.go index 797af201c9aa..a361b5984c9c 100644 --- a/pkg/reconciler/autoscaling/kpa/kpa_test.go +++ b/pkg/reconciler/autoscaling/kpa/kpa_test.go @@ -1152,6 +1152,22 @@ func TestReconcile(t *testing.T) { WithPAMetricsService(privateSvc), WithObservedGeneration(1), ), }}, + }, { + Name: "kpa does not become ready without service min scale endpoints when reachable", + Key: key, + Objects: []runtime.Object{ + kpa(testNamespace, testRevision, withServiceMinScale(4), withScales(1, defaultScale), + WithReachabilityReachable, WithPAMetricsService(privateSvc)), + defaultSKS, + metric(testNamespace, testRevision), + defaultDeployment, defaultReady, + }, + WantStatusUpdates: []clientgotesting.UpdateActionImpl{{ + Object: kpa(testNamespace, testRevision, WithPASKSReady, + WithBufferedTraffic, withServiceMinScale(4), WithPAMetricsService(privateSvc), + withScales(1, defaultScale), WithPAStatusService(testRevision), WithReachabilityReachable, + WithObservedGeneration(1)), + }}, }} table.Test(t, MakeFactory(func(ctx context.Context, listers *Listers, cmw configmap.Watcher) controller.Reconciler { @@ -1794,6 +1810,15 @@ func withMinScale(minScale int) PodAutoscalerOption { } } +func withServiceMinScale(serviceMinScale int) PodAutoscalerOption { + return func(pa *autoscalingv1alpha1.PodAutoscaler) { + pa.Annotations = kmeta.UnionMaps( + pa.Annotations, + map[string]string{serving.ServiceMinscaleAnnotationKey: strconv.Itoa(serviceMinScale)}, + ) + } +} + func decider(ns, name string, desiredScale, ebc int32) *scaling.Decider { return &scaling.Decider{ ObjectMeta: metav1.ObjectMeta{ diff --git a/pkg/reconciler/autoscaling/kpa/scaler.go b/pkg/reconciler/autoscaling/kpa/scaler.go index d542c73e8d9a..b8850ac15158 100644 --- a/pkg/reconciler/autoscaling/kpa/scaler.go +++ b/pkg/reconciler/autoscaling/kpa/scaler.go @@ -36,6 +36,7 @@ import ( pkgnet "knative.dev/pkg/network" "knative.dev/serving/pkg/activator" autoscalingv1alpha1 "knative.dev/serving/pkg/apis/autoscaling/v1alpha1" + "knative.dev/serving/pkg/apis/serving" "knative.dev/serving/pkg/autoscaler/config/autoscalerconfig" "knative.dev/serving/pkg/reconciler/autoscaling/config" kparesources "knative.dev/serving/pkg/reconciler/autoscaling/kpa/resources" @@ -333,7 +334,7 @@ func (ks *scaler) scale(ctx context.Context, pa *autoscalingv1alpha1.PodAutoscal asConfig := config.FromContext(ctx).Autoscaler logger := logging.FromContext(ctx) - if desiredScale < 0 && !pa.Status.IsActivating() { + if desiredScale < 0 && !hasServiceMinScale(pa) && !pa.Status.IsActivating() { logger.Debug("Metrics are not yet being collected.") return desiredScale, nil } @@ -379,3 +380,8 @@ func (ks *scaler) scale(ctx context.Context, pa *autoscalingv1alpha1.PodAutoscal logger.Infof("Scaling from %d to %d", currentScale, desiredScale) return desiredScale, ks.applyScale(ctx, pa, desiredScale, ps) } + +func hasServiceMinScale(pa *autoscalingv1alpha1.PodAutoscaler) bool { + _, _, ok := serving.ServiceMinscaleAnnotation.Get(pa.Annotations) + return ok +} diff --git a/pkg/reconciler/revision/cruds_test.go b/pkg/reconciler/revision/cruds_test.go index e44543675de6..9180b4d3a79c 100644 --- a/pkg/reconciler/revision/cruds_test.go +++ b/pkg/reconciler/revision/cruds_test.go @@ -59,6 +59,12 @@ func TestMergeMetadata(t *testing.T) { current: map[string]string{"autoscaling.knative.dev/target-burst-capacity": "0", "deployment.kubernetes.io/revision": "2", "kubectl.kubernetes.io/restartedAt": "2025-11-27T12:14:41+01:00"}, want: map[string]string{"autoscaling.knative.dev/min-scale": "1", "app": "my-revision", "deployment.kubernetes.io/revision": "2", "kubectl.kubernetes.io/restartedAt": "2025-11-27T12:14:41+01:00"}, }, + { + name: "mixed knative and external service min scale", + desired: map[string]string{"serving.knative.dev/serving-min-scale": "1", "app": "my-revision"}, + current: map[string]string{"autoscaling.knative.dev/target-burst-capacity": "0", "deployment.kubernetes.io/revision": "2", "kubectl.kubernetes.io/restartedAt": "2025-11-27T12:14:41+01:00"}, + want: map[string]string{"serving.knative.dev/serving-min-scale": "1", "app": "my-revision", "deployment.kubernetes.io/revision": "2", "kubectl.kubernetes.io/restartedAt": "2025-11-27T12:14:41+01:00"}, + }, } for _, tt := range tests { diff --git a/pkg/reconciler/revision/reconcile_resources.go b/pkg/reconciler/revision/reconcile_resources.go index aa62272ac427..343d8d69b117 100644 --- a/pkg/reconciler/revision/reconcile_resources.go +++ b/pkg/reconciler/revision/reconcile_resources.go @@ -41,6 +41,7 @@ import ( "knative.dev/pkg/logging" "knative.dev/pkg/logging/logkey" "knative.dev/serving/pkg/apis/autoscaling" + "knative.dev/serving/pkg/apis/serving" v1 "knative.dev/serving/pkg/apis/serving/v1" "knative.dev/serving/pkg/networking" "knative.dev/serving/pkg/reconciler/revision/config" @@ -233,7 +234,7 @@ func syncAnnotationsForKPA(dst, src map[string]string) { // Exclude defaulted annotation continue } - if strings.HasPrefix(k, autoscaling.GroupName) { + if isScalingAnnotation(k) { delete(dst, k) } } @@ -249,7 +250,7 @@ func syncAnnotationsForKPA(dst, src map[string]string) { func annotationsNeedReconcilingForKPA(dst, src map[string]string) bool { // Check for extra autoscaling annotations that don't exist in src for k := range dst { - if !strings.HasPrefix(k, autoscaling.GroupName) { + if !isScalingAnnotation(k) { continue } // Exclude defaulted annotation @@ -259,7 +260,7 @@ func annotationsNeedReconcilingForKPA(dst, src map[string]string) bool { if _, ok := src[k]; !ok { // Scaling annotation is in dst but not src - // return false to trigger reconciliation + // return true to trigger reconciliation return true } } @@ -282,6 +283,17 @@ func annotationsNeedReconcilingForKPA(dst, src map[string]string) bool { return false } +func isScalingAnnotation(key string) bool { + // On top of autoscaler annotations, we need to propagate service scaling annotations + additionalAnnotations := sets.NewString( + slices.Concat[[]string]( + serving.ServiceMinscaleAnnotation, + serving.ServiceMinscaleRouteAnnotation, + )...) + + return strings.HasPrefix(key, autoscaling.GroupName) || additionalAnnotations.Has(key) +} + func hasDeploymentTimedOut(deployment *appsv1.Deployment) bool { // as per https://kubernetes.io/docs/concepts/workloads/controllers/deployment for _, cond := range deployment.Status.Conditions { diff --git a/pkg/reconciler/servicescaler/controller.go b/pkg/reconciler/servicescaler/controller.go new file mode 100644 index 000000000000..92a3594f4e98 --- /dev/null +++ b/pkg/reconciler/servicescaler/controller.go @@ -0,0 +1,78 @@ +/* +Copyright 2026 The Knative 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 servicescaler + +import ( + "context" + + "k8s.io/client-go/tools/cache" + "knative.dev/pkg/configmap" + "knative.dev/pkg/controller" + "knative.dev/pkg/logging" + v1 "knative.dev/serving/pkg/apis/serving/v1" + servingclient "knative.dev/serving/pkg/client/injection/client" + revisioninformer "knative.dev/serving/pkg/client/injection/informers/serving/v1/revision" + routeinformer "knative.dev/serving/pkg/client/injection/informers/serving/v1/route" + routereconciler "knative.dev/serving/pkg/client/injection/reconciler/serving/v1/route" + "knative.dev/serving/pkg/reconciler/autoscaling/config" +) + +// NewController initializes the controller and is called by the generated code +// Registers eventhandlers to enqueue events +func NewController( + ctx context.Context, + cmw configmap.Watcher, +) *controller.Impl { + logger := logging.FromContext(ctx) + routeInformer := routeinformer.Get(ctx) + revisionInformer := revisioninformer.Get(ctx) + + configStore := config.NewStore(logger.Named("config-store")) + configStore.WatchConfigs(cmw) + + c := &Reconciler{ + client: servingclient.Get(ctx), + revisionLister: revisionInformer.Lister(), + revisionIndexer: revisionInformer.Informer().GetIndexer(), + routeLister: routeInformer.Lister(), + } + impl := routereconciler.NewImpl(ctx, c, func(*controller.Impl) controller.Options { + return controller.Options{ + ConfigStore: configStore, + // This controller should not mutate the route's status. + SkipStatusUpdates: true, + } + }) + + c.tracker = impl.Tracker + + // Make sure trackers are deleted once the observers are removed. + routeInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{ + DeleteFunc: impl.Tracker.OnDeletedObserver, + }) + + routeInformer.Informer().AddEventHandler(controller.HandleAll(impl.Enqueue)) + + revisionInformer.Informer().AddEventHandler(controller.HandleAll( + controller.EnsureTypeMeta( + impl.Tracker.OnChanged, + v1.SchemeGroupVersion.WithKind("Revision"), + ), + )) + + return impl +} diff --git a/pkg/reconciler/servicescaler/servicescaler.go b/pkg/reconciler/servicescaler/servicescaler.go new file mode 100644 index 000000000000..cb964ec4a38c --- /dev/null +++ b/pkg/reconciler/servicescaler/servicescaler.go @@ -0,0 +1,237 @@ +/* +Copyright 2026 The Knative 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 servicescaler + +import ( + "context" + "encoding/json" + "fmt" + "math" + "strconv" + "strings" + + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/tools/cache" + "knative.dev/serving/pkg/client/clientset/versioned" + routereconciler "knative.dev/serving/pkg/client/injection/reconciler/serving/v1/route" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "knative.dev/pkg/kmeta" + "knative.dev/pkg/logging" + pkgreconciler "knative.dev/pkg/reconciler" + "knative.dev/pkg/tracker" + "knative.dev/serving/pkg/apis/serving" + v1 "knative.dev/serving/pkg/apis/serving/v1" + listers "knative.dev/serving/pkg/client/listers/serving/v1" +) + +// Reconciler implements controller.Reconciler for Service resources. +type Reconciler struct { + client versioned.Interface + + // listers index properties about resources + revisionLister listers.RevisionLister + revisionIndexer cache.Indexer + routeLister listers.RouteLister + + tracker tracker.Interface +} + +var ( + _ routereconciler.Finalizer = (*Reconciler)(nil) + _ routereconciler.Interface = (*Reconciler)(nil) +) + +// FinalizeKind removes service minscale from its traffic targets +// This does not modify or observe spec for the Route itself. +func (c *Reconciler) FinalizeKind(ctx context.Context, r *v1.Route) pkgreconciler.Event { + return c.removeServiceScaleAnnotations(ctx, r) +} + +func (c *Reconciler) ReconcileKind(ctx context.Context, r *v1.Route) pkgreconciler.Event { + ctx, cancel := context.WithTimeout(ctx, pkgreconciler.DefaultTimeout) + defer cancel() + + logger := logging.FromContext(ctx) + + for _, tt := range r.Status.Traffic { + revName := tt.RevisionName + if revName != "" { + if err := c.tracker.TrackReference(ref(r.Namespace, revName, "Revision"), r); err != nil { + return err + } + + rev, err := c.revisionLister.Revisions(r.Namespace).Get(revName) + if err != nil { + // The revision might not exist (yet). The informers will notify if it gets created. + continue + } + + serviceMinScale := computeServiceMinScale(tt, r) + revCopy := rev.DeepCopy() + logger.Info("service min scale ", serviceMinScale) + err = c.patchServiceScaleAnnotations(ctx, revCopy, r, serviceMinScale) + if err != nil { + logger.Error(err) + return err + } + } + } + + return nil +} + +func ref(namespace, name, kind string) tracker.Reference { + apiVersion, kind := v1.SchemeGroupVersion.WithKind(kind).ToAPIVersionAndKind() + return tracker.Reference{ + APIVersion: apiVersion, + Kind: kind, + Name: name, + Namespace: namespace, + } +} + +func computeServiceMinScale(tt v1.TrafficTarget, r *v1.Route) int32 { + _, serviceMinScale, ok := serving.ServiceMinscaleAnnotation.Get(r.GetAnnotations()) + if ok && tt.Percent != nil { + val, err := strconv.ParseFloat(serviceMinScale, 32) + if err != nil { + // should not happen, but just incase return 0 + return 0 + } + return int32(math.Ceil((float64(*tt.Percent) / 100.0) * val)) + } + + // If annotation not found, assume 0 + return 0 +} + +func (c *Reconciler) patchServiceScaleAnnotations(ctx context.Context, rev *v1.Revision, route *v1.Route, serviceMinscale int32) error { + routeKey := getRouteKey(route) + _, val, minScaleOk := serving.ServiceMinscaleAnnotation.Get(rev.GetAnnotations()) + if minScaleOk { + // min scale revision exists already, check if we need to update + currentServiceMinScale, err := strconv.ParseInt(val, 10, 32) + if err == nil && currentServiceMinScale == int64(serviceMinscale) { + // if it is the same, no need to update + return nil + } + + _, routeName, routeNameOk := serving.ServiceMinscaleRouteAnnotation.Get(rev.GetAnnotations()) + // if not the same, check if there is already a route that applied service minscale to revision, if it exists + if err == nil && routeNameOk && currentServiceMinScale > int64(serviceMinscale) { + if routeName != "" && routeName != routeKey { + // finally, check if route exists incase it previously failed deletion + parts := strings.Split(routeName, "/") + if len(parts) == 2 { + _, err = c.routeLister.Routes(parts[0]).Get(parts[1]) + if err != nil && !apierrors.IsNotFound(err) { + // if there is an error that is not a not found error, throw an error to reconcile again + return err + } + + if err == nil { + // if no error, it means the route was found and no update is needed since route exists + return nil + } + } + } + } + } + + _, _, routeMinScaleOk := serving.ServiceMinscaleAnnotation.Get(route.GetAnnotations()) + if !minScaleOk && !routeMinScaleOk { + // if minscale is not set on target AND current route, no need to patch + return nil + } + + var serviceMinscalePatchVal *string + var serviceMinscaleRoutePatchVal *string + if serviceMinscale > 0 { + // if greater than 0 apply service minscale + // else apply blank annotation so that it gets removed + formattedServiceMinscale := strconv.FormatInt(int64(serviceMinscale), 10) + serviceMinscalePatchVal = &formattedServiceMinscale + serviceMinscaleRoutePatchVal = &routeKey + } else { + serviceMinscalePatchVal = nil + serviceMinscaleRoutePatchVal = nil + } + + return c.patchRevisionServiceMinScale(ctx, route, rev.GetName(), serviceMinscalePatchVal, serviceMinscaleRoutePatchVal) +} + +func (c *Reconciler) removeServiceScaleAnnotations(ctx context.Context, r *v1.Route) pkgreconciler.Event { + revAccessors, err := c.getRevAccessors(r) + if err != nil { + return err + } + + for _, elt := range revAccessors { + name := elt.GetName() + if err := c.patchRevisionServiceMinScale(ctx, r, name, nil, nil); err != nil { + return err + } + } + + return nil +} + +func (c *Reconciler) patchRevisionServiceMinScale(ctx context.Context, route *v1.Route, revName string, serviceMinscale, serviceMinscaleRouteKey *string) error { + annotationPatchMap := map[string]any{ + "metadata": map[string]any{ + "annotations": map[string]any{ + serving.ServiceMinscaleAnnotation.Key(): serviceMinscale, + serving.ServiceMinscaleRouteAnnotation.Key(): serviceMinscaleRouteKey, + }, + }, + } + + patch, err := json.Marshal(annotationPatchMap) + if err != nil { + return err + } + + _, err = c.client.ServingV1().Revisions(route.GetNamespace()).Patch(ctx, revName, types.MergePatchType, patch, metav1.PatchOptions{}) + return err +} + +func (c *Reconciler) getRevAccessors(route *v1.Route) ([]kmeta.Accessor, error) { + routeKey := getRouteKey(route) + kl := make([]kmeta.Accessor, 0, 1) + filter := func(m any) { + rev := m.(*v1.Revision) + // only target revision with minscale key associated with route + if _, val, ok := serving.ServiceMinscaleRouteAnnotation.Get(rev.GetAnnotations()); ok && val == routeKey { + kl = append(kl, rev) + } + } + + // get all in namespace + selector := labels.SelectorFromSet(nil) + if err := cache.ListAllByNamespace(c.revisionIndexer, route.GetNamespace(), selector, filter); err != nil { + return nil, err + } + + return kl, nil +} + +func getRouteKey(route *v1.Route) string { + return fmt.Sprintf("%s/%s", route.GetNamespace(), route.GetName()) +} diff --git a/pkg/reconciler/servicescaler/servicescaler_test.go b/pkg/reconciler/servicescaler/servicescaler_test.go new file mode 100644 index 000000000000..606c24f7fcd7 --- /dev/null +++ b/pkg/reconciler/servicescaler/servicescaler_test.go @@ -0,0 +1,436 @@ +/* +Copyright 2026 The Knative 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 servicescaler + +import ( + "context" + "fmt" + "testing" + + // Inject the fake informers that this controller needs. + + servingclient "knative.dev/serving/pkg/client/injection/client/fake" + _ "knative.dev/serving/pkg/client/injection/informers/serving/v1/revision/fake" + _ "knative.dev/serving/pkg/client/injection/informers/serving/v1/route/fake" + "knative.dev/serving/pkg/deployment" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + clientgotesting "k8s.io/client-go/testing" + routereconciler "knative.dev/serving/pkg/client/injection/reconciler/serving/v1/route" + + corev1 "k8s.io/api/core/v1" + netcfg "knative.dev/networking/pkg/config" + "knative.dev/pkg/configmap" + "knative.dev/pkg/controller" + "knative.dev/pkg/kmeta" + "knative.dev/pkg/logging" + "knative.dev/pkg/ptr" + "knative.dev/pkg/system" + "knative.dev/serving/pkg/apis/serving" + v1 "knative.dev/serving/pkg/apis/serving/v1" + autoscalercfg "knative.dev/serving/pkg/autoscaler/config" + + . "knative.dev/pkg/reconciler/testing" + . "knative.dev/serving/pkg/reconciler/testing/v1" + . "knative.dev/serving/pkg/testing/v1" +) + +func TestReconcile(t *testing.T) { + now := metav1.Now() + table := TableTest{ + { + Name: "bad workqueue key", + // Make sure Reconcile handles bad keys. + Key: "too/many/parts", + }, { + Name: "key not found", + // Make sure Reconcile handles good keys that don't exist. + Key: "foo/not-found", + }, { + Name: "annotate service minscale with 1 revision", + Objects: []runtime.Object{ + simpleRunLatest("default", "test-route", "revision-test", WithServiceMinScale("3"), WithRouteFinalizer), + rev("default", "revision-test"), + }, + WantPatches: []clientgotesting.PatchActionImpl{ + patchAddServiceMinscaleAnnotationKey("default", rev("default", "revision-test").Name, "3", "default/test-route"), + }, + Key: "default/test-route", + }, { + Name: "add minscale annotation to two revisions split", + Objects: []runtime.Object{ + routeWithTraffic("default", "test-service", + []v1.TrafficTarget{ + { + RevisionName: rev("default", "test-service").Name, + Percent: getInt64Pointer(50), + LatestRevision: getBoolPointer(false), + }, + { + RevisionName: rev("default", "test-service", WithRevName("the-revision")).Name, + Percent: getInt64Pointer(50), + LatestRevision: getBoolPointer(true), + }, + }, WithServiceMinScale("6"), WithRouteFinalizer, + ), + rev("default", "test-service"), + rev("default", "test-service", WithRevName("the-revision")), + }, + WantPatches: []clientgotesting.PatchActionImpl{ + patchAddServiceMinscaleAnnotationKey("default", rev("default", "test-service").Name, "3", "default/test-service"), + patchAddServiceMinscaleAnnotationKey("default", rev("default", "test-service", WithRevName("the-revision")).Name, "3", "default/test-service"), + }, + Key: "default/test-service", + }, { + Name: "steady state", + Objects: []runtime.Object{ + simpleRunLatest("default", "steady-state", "test-service", WithServiceMinScale("3"), WithRouteFinalizer), + rev("default", "test-service", WithRevisionAnn(serving.ServiceMinscaleAnnotationKey, "3"), WithRevisionAnn(serving.ServiceMinscaleRouteAnnotationKey, "default/steady-state")), + }, + Key: "default/steady-state", + }, { + Name: "two routes one with higher min scale", + Objects: []runtime.Object{ + simpleRunLatest("default", "first-route", "first-route", WithRouteFinalizer, + WithSpecTraffic(configTraffic("new")), WithRouteAnnotation(map[string]string{serving.ServiceMinscaleAnnotationKey: "3"})), + simpleRunLatest("default", "second-route", "first-route", WithRouteFinalizer, + WithSpecTraffic(configTraffic("new")), WithRouteAnnotation(map[string]string{serving.ServiceMinscaleAnnotationKey: "5"})), + rev("default", "first-route", WithRevisionAnn(serving.ServiceMinscaleAnnotationKey, "3"), WithRevisionAnn(serving.ServiceMinscaleRouteAnnotationKey, "default/first-route")), + }, + WantPatches: []clientgotesting.PatchActionImpl{ + patchAddServiceMinscaleAnnotationKey("default", rev("default", + "first-route", WithRevisionAnn(serving.ServiceMinscaleAnnotationKey, "3"), + WithRevisionAnn(serving.ServiceMinscaleRouteAnnotationKey, "default/first-route")).Name, "5", "default/second-route"), + }, + Key: "default/second-route", + }, { + Name: "transition after deletion with previous annotations present (error case)", + Objects: []runtime.Object{ + simpleRunLatest("default", "second-route", "test-service", WithRouteFinalizer, + WithSpecTraffic(configTraffic("new")), WithRouteAnnotation(map[string]string{serving.ServiceMinscaleAnnotationKey: "3"})), + rev("default", "test-service", WithRevisionAnn(serving.ServiceMinscaleAnnotationKey, "5"), WithRevisionAnn(serving.ServiceMinscaleRouteAnnotationKey, "default/first-route")), + }, + WantPatches: []clientgotesting.PatchActionImpl{ + patchAddServiceMinscaleAnnotationKey("default", rev("default", + "test-service", WithRevisionAnn(serving.ServiceMinscaleAnnotationKey, "5"), + WithRevisionAnn(serving.ServiceMinscaleRouteAnnotationKey, "default/first-route")).Name, "3", "default/second-route"), + }, + Key: "default/second-route", + }, { + Name: "steady state two routes", + Objects: []runtime.Object{ + simpleRunLatest("default", "steady-state-1", "test-service", WithRouteFinalizer, + WithRouteAnnotation(map[string]string{serving.ServiceMinscaleAnnotationKey: "5"})), + simpleRunLatest("default", "steady-state-2", "test-service", WithRouteFinalizer, + WithRouteAnnotation(map[string]string{serving.ServiceMinscaleAnnotationKey: "3"})), + rev("default", "test-service", + WithRevisionAnn(serving.ServiceMinscaleAnnotationKey, "5"), WithRevisionAnn(serving.ServiceMinscaleRouteAnnotationKey, "default/steady-state-1")), + }, + Key: "default/steady-state-2", + }, { + Name: "add minscale annotation to two revisions split one zero", + Objects: []runtime.Object{ + routeWithTraffic("default", "test-service", + []v1.TrafficTarget{ + { + RevisionName: rev("default", "test-service").Name, + Percent: getInt64Pointer(50), + LatestRevision: getBoolPointer(false), + }, + { + RevisionName: rev("default", "test-service", WithRevName("the-revision")).Name, + Percent: getInt64Pointer(50), + LatestRevision: getBoolPointer(true), + }, + { + RevisionName: rev("default", "test-service", WithRevName("the-revision-2")).Name, + Percent: getInt64Pointer(0), + LatestRevision: getBoolPointer(false), + }, + }, WithServiceMinScale("6"), WithRouteFinalizer, + ), + rev("default", "test-service"), + rev("default", "test-service", WithRevName("the-revision")), + rev("default", "test-service", WithRevName("the-revision-2"), + WithRevisionAnn(serving.ServiceMinscaleAnnotationKey, "6"), WithRevisionAnn(serving.ServiceMinscaleRouteAnnotationKey, "default/test-service")), + }, + WantPatches: []clientgotesting.PatchActionImpl{ + patchAddServiceMinscaleAnnotationKey("default", rev("default", "test-service").Name, "3", "default/test-service"), + patchAddServiceMinscaleAnnotationKey("default", rev("default", "test-service", WithRevName("the-revision")).Name, "3", "default/test-service"), + patchRemoveServiceMinscaleAnnotationKey("default", rev("default", "test-service", WithRevName("the-revision-2")).Name), + }, + Key: "default/test-service", + }, { + Name: "delete route existing ann", + Objects: []runtime.Object{ + simpleRunLatest("default", "delete-route", "test-service", WithRouteFinalizer, WithRouteDeletionTimestamp(&now)), + rev("default", "test-service", + WithRevisionAnn(serving.ServiceMinscaleRouteAnnotationKey, "default/delete-route"), + WithRevisionAnn(serving.ServiceMinscaleAnnotationKey, "3")), + }, + WantPatches: []clientgotesting.PatchActionImpl{ + patchRemoveServiceMinscaleAnnotationKey("default", rev("default", "test-service").Name), + patchRemoveFinalizerAction("default", "delete-route"), + }, + WantEvents: []string{ + Eventf(corev1.EventTypeNormal, "FinalizerUpdate", `Updated "delete-route" finalizers`), + }, + Key: "default/delete-route", + }, { + Name: "first reconcile with with finalizer", + Objects: []runtime.Object{ + simpleRunLatest("default", "first-reconcile", "test-service", WithRouteAnnotation(map[string]string{serving.ServiceMinscaleAnnotationKey: "3"})), + rev("default", "test-service"), + }, + WantPatches: []clientgotesting.PatchActionImpl{ + patchAddFinalizerAction("default", "first-reconcile"), + patchAddServiceMinscaleAnnotationKey("default", rev("default", "test-service").Name, "3", "default/first-reconcile"), + }, + WantEvents: []string{ + Eventf(corev1.EventTypeNormal, "FinalizerUpdate", "Updated %q finalizers", "first-reconcile"), + }, + Key: "default/first-reconcile", + }, { + Name: "route updated lower service minscale", + Objects: []runtime.Object{ + simpleRunLatest("default", "test-route", "test-service", WithRouteFinalizer, + WithRouteAnnotation(map[string]string{serving.ServiceMinscaleAnnotationKey: "3"})), + rev("default", "test-service", + WithRevisionAnn(serving.ServiceMinscaleAnnotationKey, "5"), + WithRevisionAnn(serving.ServiceMinscaleRouteAnnotationKey, "default/test-route")), + }, + WantPatches: []clientgotesting.PatchActionImpl{ + patchAddServiceMinscaleAnnotationKey("default", rev("default", "test-service").Name, "3", "default/test-route"), + }, + Key: "default/test-route", + }, { + Name: "route updated higher service minscale", + Objects: []runtime.Object{ + simpleRunLatest("default", "test-route", "test-service", WithRouteFinalizer, + WithRouteAnnotation(map[string]string{serving.ServiceMinscaleAnnotationKey: "5"})), + rev("default", "test-service", + WithRevisionAnn(serving.ServiceMinscaleAnnotationKey, "3"), + WithRevisionAnn(serving.ServiceMinscaleRouteAnnotationKey, "default/test-route")), + }, + WantPatches: []clientgotesting.PatchActionImpl{ + patchAddServiceMinscaleAnnotationKey("default", rev("default", "test-service").Name, "5", "default/test-route"), + }, + Key: "default/test-route", + }, { + Name: "route deleted with unrelated revision in same namespace", + Objects: []runtime.Object{ + simpleRunLatest("default", "delete-route", "other-service", WithRouteFinalizer, WithRouteDeletionTimestamp(&now)), + rev("default", "test-service", + WithRevisionAnn(serving.ServiceMinscaleRouteAnnotationKey, "default/other-route"), + WithRevisionAnn(serving.ServiceMinscaleAnnotationKey, "3")), + }, + WantPatches: []clientgotesting.PatchActionImpl{ + patchRemoveFinalizerAction("default", "delete-route"), + }, + WantEvents: []string{ + Eventf(corev1.EventTypeNormal, "FinalizerUpdate", `Updated "delete-route" finalizers`), + }, + Key: "default/delete-route", + }, { + Name: "re-reconcile blank route name", + Objects: []runtime.Object{ + simpleRunLatest("default", "test-route", "test-service", WithRouteFinalizer, + WithRouteAnnotation(map[string]string{serving.ServiceMinscaleAnnotationKey: "2"})), + rev("default", "test-service", + WithRevisionAnn(serving.ServiceMinscaleRouteAnnotationKey, ""), + WithRevisionAnn(serving.ServiceMinscaleAnnotationKey, "3")), + }, + WantPatches: []clientgotesting.PatchActionImpl{ + patchAddServiceMinscaleAnnotationKey("default", rev("default", "test-service").Name, "2", "default/test-route"), + }, + Key: "default/test-route", + }, { + Name: "steady-state no annotation", + Objects: []runtime.Object{ + simpleRunLatest("default", "steady-state", "test-service", WithRouteFinalizer), + rev("default", "test-service"), + }, + Key: "default/steady-state", + }, + } + + table.Test(t, MakeFactory(func(ctx context.Context, listers *Listers, cmw configmap.Watcher) controller.Reconciler { + client := servingclient.Get(ctx) + rLister := listers.GetRevisionLister() + rIndexer := listers.IndexerFor(&v1.Revision{}) + routeLister := listers.GetRouteLister() + + r := &Reconciler{ + client: client, + revisionLister: rLister, + revisionIndexer: rIndexer, + routeLister: routeLister, + tracker: &NullTracker{}, + } + + return routereconciler.NewReconciler(ctx, logging.FromContext(ctx), servingclient.Get(ctx), + listers.GetRouteLister(), controller.GetEventRecorder(ctx), r) + })) +} + +func configTraffic(name string) v1.TrafficTarget { + return v1.TrafficTarget{ + ConfigurationName: name, + Percent: ptr.Int64(100), + LatestRevision: ptr.Bool(true), + } +} + +func revTraffic(name string, latest bool) v1.TrafficTarget { + return v1.TrafficTarget{ + RevisionName: name, + Percent: ptr.Int64(100), + LatestRevision: ptr.Bool(latest), + } +} + +func routeWithTraffic(namespace, name string, status []v1.TrafficTarget, opts ...RouteOption) *v1.Route { + return Route(namespace, name, + append([]RouteOption{ + WithStatusTraffic(status...), WithInitRouteConditions, + MarkTrafficAssigned, MarkCertificateReady, MarkIngressReady, WithRouteObservedGeneration, + }, opts...)...) +} + +func simpleRunLatest(namespace, name, serviceName string, opts ...RouteOption) *v1.Route { + return routeWithTraffic(namespace, name, + []v1.TrafficTarget{revTraffic(serviceName+"-dbnfd", true)}, + opts...) +} + +func simpleConfig(namespace, name string, opts ...ConfigOption) *v1.Configuration { + cfg := &v1.Configuration{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: namespace, + Name: name, + ResourceVersion: "v1", + }, + } + cfg.Status.InitializeConditions() + cfg.Status.SetLatestCreatedRevisionName(name + "-dbnfd") + cfg.Status.SetLatestReadyRevisionName(name + "-dbnfd") + + for _, opt := range opts { + opt(cfg) + } + return cfg +} + +func rev(namespace, name string, opts ...RevisionOption) *v1.Revision { + cfg := simpleConfig(namespace, name) + rev := &v1.Revision{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: namespace, + Name: cfg.Status.LatestReadyRevisionName, + ResourceVersion: "v1", + OwnerReferences: []metav1.OwnerReference{*kmeta.NewControllerRef(cfg)}, + }, + } + + for _, opt := range opts { + opt(rev) + } + return rev +} + +func patchRemoveServiceMinscaleAnnotationKey(namespace, name string) clientgotesting.PatchActionImpl { + return patchAddServiceMinscaleAnnotationKey(namespace, name, "null", "null") +} + +func patchAddServiceMinscaleAnnotationKey(namespace, revisionName, serviceMinScale, routeKey string) clientgotesting.PatchActionImpl { + action := clientgotesting.PatchActionImpl{ + Name: revisionName, + ActionImpl: clientgotesting.ActionImpl{Namespace: namespace}, + } + + // Note: the raw json `"key": null` removes a value, whereas an actual value + // called "null" would need quotes to parse as a string `"key":"null"`. + if serviceMinScale != "null" { + serviceMinScale = `"` + serviceMinScale + `"` + } + if routeKey != "null" { + routeKey = `"` + routeKey + `"` + } + + action.Patch = []byte(fmt.Sprintf( + `{"metadata":{"annotations":{"serving.knative.dev/service-min-scale":%s,"serving.knative.dev/service-min-scale-route":%s}}}`, serviceMinScale, routeKey)) + return action +} + +func getInt64Pointer(val int) *int64 { + p := int64(val) + return &p +} + +func getBoolPointer(val bool) *bool { + return &val +} + +func patchRemoveFinalizerAction(namespace, name string) clientgotesting.PatchActionImpl { + return clientgotesting.PatchActionImpl{ + Name: name, + ActionImpl: clientgotesting.ActionImpl{Namespace: namespace}, + Patch: []byte(`{"metadata":{"finalizers":[],"resourceVersion":""}}`), + } +} + +func patchAddFinalizerAction(namespace, name string) clientgotesting.PatchActionImpl { + p := fmt.Sprintf(`{"metadata":{"finalizers":[%q],"resourceVersion":""}}`, v1.Resource("routes").String()) + return clientgotesting.PatchActionImpl{ + Name: name, + ActionImpl: clientgotesting.ActionImpl{Namespace: namespace}, + Patch: []byte(p), + } +} + +func TestNew(t *testing.T) { + ctx, _ := SetupFakeContext(t) + + configMapWatcher := configmap.NewStaticWatcher(&corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: system.Namespace(), + Name: deployment.ConfigName, + }, + Data: map[string]string{ + deployment.QueueSidecarImageKey: "motorbike-sidecar", + }, + }, &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: system.Namespace(), + Name: netcfg.ConfigMapName, + }, + Data: map[string]string{}, + }, &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: autoscalercfg.ConfigName, + Namespace: system.Namespace(), + }, + Data: map[string]string{}, + }) + + c := NewController(ctx, configMapWatcher) + + if c == nil { + t.Fatal("Expected NewController to return a non-nil value") + } +} diff --git a/pkg/testing/v1/route.go b/pkg/testing/v1/route.go index 935cdb7f5100..988e71ba592c 100644 --- a/pkg/testing/v1/route.go +++ b/pkg/testing/v1/route.go @@ -28,6 +28,7 @@ import ( duckv1 "knative.dev/pkg/apis/duck/v1" "knative.dev/pkg/network" "knative.dev/pkg/ptr" + "knative.dev/serving/pkg/apis/serving" v1 "knative.dev/serving/pkg/apis/serving/v1" routenames "knative.dev/serving/pkg/reconciler/route/resources/names" ) @@ -56,6 +57,16 @@ func WithRouteGeneration(generation int64) RouteOption { } } +// WithServiceMinScale sets the route's service minscale +func WithServiceMinScale(serviceMinScale string) RouteOption { + return func(r *v1.Route) { + if r.Annotations == nil { + r.Annotations = map[string]string{} + } + r.Annotations[serving.ServiceMinscaleAnnotation.Key()] = serviceMinScale + } +} + // WithRouteObservedGeneration sets the route's observed generation to it's generation func WithRouteObservedGeneration(r *v1.Route) { r.Status.ObservedGeneration = r.Generation diff --git a/test/ha/ha.go b/test/ha/ha.go index fd199e6d5f73..96a92331ad0e 100644 --- a/test/ha/ha.go +++ b/test/ha/ha.go @@ -42,7 +42,7 @@ import ( const ( // NumControllerReconcilers is the number of controllers run by ./cmd/controller/main.go. // It is exported so the tests from cmd/controller/main.go can ensure we keep it in sync. - NumControllerReconcilers = 9 + NumControllerReconcilers = 10 ) func createPizzaPlanetService(t *testing.T, fopt ...rtesting.ServiceOption) (test.ResourceNames, *v1test.ResourceObjects) {