Skip to content

Commit cc32a9c

Browse files
authored
feat: Enforce MD replicas within cluster autoscaler bounds (#1169)
When the current MD `.spec.replicas` is outside of the bounds of the cluster autoscaler min/max annotations, cluster autoscaler will not scale the MD up or down. This can occur when either the annotations are edited or `spec.replicas` is updated. In a topology-based cluster, CAPI propagates the cluster autoscaler annotations from `cluster.spec.topology.workers.machineDeployments[].metadata.annotations` to the templated `MachineDeployment` resources. If these annotations are changed so that they do not contain the current number of replicas, then no scaling up or down will happen to the MachineDeployment by the cluster autoscaler. This commit adds a controller that does the following to MachineDeployments: - if `spec.replicas` is nil do nothing - if cluster autoscaler annotations for min or max are missing do nothing - if `spec.replicas` is within cluster autoscaler annotations bounds do nothing - if `spec.replicas` is not with CA annotations bounds then set `spec.replicas` to nil (CAPI MD defaulting webhook will correctly set MD replicas as per https://github.com/kubernetes-sigs/cluster-api/blob/v1.10.3/internal/webhooks/machinedeployment.go\#L353-L376. This ensures that scaling up and down by specifying cluster autoscaler annotations on the machine deployments in `cluster.spec.topology.workers.machineDeployments[].metadata.annotations` will always be respected, even if the current number of replicas is outside the autoscaling specified bounds.
1 parent 42da8b6 commit cc32a9c

File tree

14 files changed

+614
-3
lines changed

14 files changed

+614
-3
lines changed

charts/cluster-api-runtime-extensions-nutanix/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ A Helm chart for cluster-api-runtime-extensions-nutanix
3030
| certificates.issuer.selfSigned | bool | `true` | |
3131
| deployDefaultClusterClasses | bool | `true` | |
3232
| deployment.replicas | int | `1` | |
33+
| enforceClusterAutoscalerLimits.enabled | bool | `true` | |
3334
| env | object | `{}` | |
3435
| helmAddonsConfigMap | string | `"default-helm-addons-config"` | |
3536
| helmRepository.enabled | bool | `true` | |

charts/cluster-api-runtime-extensions-nutanix/templates/deployment.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ spec:
3333
- --namespacesync-enabled={{ .Values.namespaceSync.enabled }}
3434
- --namespacesync-source-namespace={{ default .Release.Namespace .Values.namespaceSync.sourceNamespace }}
3535
- --namespacesync-target-namespace-label-key={{ .Values.namespaceSync.targetNamespaceLabelKey }}
36+
- --enforce-clusterautoscaler-limits-enabled={{ .Values.enforceClusterAutoscalerLimits.enabled }}
3637
- --helm-addons-configmap={{ .Values.helmAddonsConfigMap }}
3738
- --cni.cilium.helm-addon.default-values-template-configmap-name={{ .Values.hooks.cni.cilium.helmAddonStrategy.defaultValueTemplateConfigMap.name }}
3839
- --nfd.helm-addon.default-values-template-configmap-name={{ .Values.hooks.nfd.helmAddonStrategy.defaultValueTemplateConfigMap.name }}

charts/cluster-api-runtime-extensions-nutanix/templates/role.yaml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,16 @@ rules:
7979
- get
8080
- list
8181
- watch
82+
- apiGroups:
83+
- cluster.x-k8s.io
84+
resources:
85+
- machinedeployments
86+
verbs:
87+
- get
88+
- list
89+
- patch
90+
- update
91+
- watch
8292
- apiGroups:
8393
- storage.k8s.io
8494
resources:

charts/cluster-api-runtime-extensions-nutanix/values.schema.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,14 @@
3131
},
3232
"type": "object"
3333
},
34+
"enforceClusterAutoscalerLimits": {
35+
"properties": {
36+
"enabled": {
37+
"type": "boolean"
38+
}
39+
},
40+
"type": "object"
41+
},
3442
"env": {
3543
"properties": {},
3644
"type": "object"

charts/cluster-api-runtime-extensions-nutanix/values.yaml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,16 @@ namespaceSync:
132132
# By default, sourceNamespace is the helm release namespace.
133133
sourceNamespace: ""
134134

135+
# Enable the Cluster Autoscaler limits enforcement controller.
136+
# This controller ensures that the number of replicas in a MachineDeployment
137+
# does not exceed the limits set by the Cluster Autoscaler annotations.
138+
# It will also ensure that the number of replicas is at least the minimum
139+
# number of replicas set by the Cluster Autoscaler annotations.
140+
# The controller will not enforce the limits if the Cluster Autoscaler annotations
141+
# are not present on the MachineDeployment.
142+
enforceClusterAutoscalerLimits:
143+
enabled: true
144+
135145
deployment:
136146
replicas: 1
137147

cmd/main.go

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import (
3131
caaphv1 "github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/api/external/sigs.k8s.io/cluster-api-addon-provider-helm/api/v1alpha1"
3232
"github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/common/pkg/capi/clustertopology/handlers"
3333
"github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/common/pkg/server"
34+
"github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/pkg/controllers/enforceclusterautoscalerlimits"
3435
"github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/pkg/controllers/namespacesync"
3536
"github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/pkg/feature"
3637
"github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/pkg/handlers/aws"
@@ -117,6 +118,7 @@ func main() {
117118
genericMetaHandlers := generic.New()
118119

119120
namespacesyncOptions := namespacesync.Options{}
121+
enforceClusterAutoscalerLimitsOptions := enforceclusterautoscalerlimits.Options{}
120122

121123
// Initialize and parse command line flags.
122124
logs.AddFlags(pflag.CommandLine, logs.SkipLoggingConfigurationFlags())
@@ -128,6 +130,7 @@ func main() {
128130
dockerMetaHandlers.AddFlags(pflag.CommandLine)
129131
nutanixMetaHandlers.AddFlags(pflag.CommandLine)
130132
namespacesyncOptions.AddFlags(pflag.CommandLine)
133+
enforceClusterAutoscalerLimitsOptions.AddFlags(pflag.CommandLine)
131134
pflag.CommandLine.SetNormalizeFunc(cliflag.WordSepNormalizeFunc)
132135
pflag.CommandLine.AddGoFlagSet(flag.CommandLine)
133136

@@ -197,7 +200,6 @@ func main() {
197200
SourceClusterClassNamespace: namespacesyncOptions.SourceNamespace,
198201
IsTargetNamespace: namespacesync.NamespaceHasLabelKey(namespacesyncOptions.TargetNamespaceLabelKey),
199202
}).SetupWithManager(
200-
signalCtx,
201203
mgr,
202204
&controller.Options{MaxConcurrentReconciles: namespacesyncOptions.Concurrency},
203205
); err != nil {
@@ -211,6 +213,23 @@ func main() {
211213
}
212214
}
213215

216+
if enforceClusterAutoscalerLimitsOptions.Enabled {
217+
if err := (&enforceclusterautoscalerlimits.Reconciler{
218+
Client: mgr.GetClient(),
219+
}).SetupWithManager(
220+
mgr,
221+
&controller.Options{MaxConcurrentReconciles: enforceClusterAutoscalerLimitsOptions.Concurrency},
222+
); err != nil {
223+
setupLog.Error(
224+
err,
225+
"unable to create controller",
226+
"controller",
227+
"enforceclusterautoscalerlimits.Reconciler",
228+
)
229+
os.Exit(1)
230+
}
231+
}
232+
214233
mgr.GetWebhookServer().Register("/mutate-v1beta1-cluster", &webhook.Admission{
215234
Handler: cluster.NewDefaulter(mgr.GetClient(), admission.NewDecoder(mgr.GetScheme())),
216235
})

internal/test/builder/builders.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ package builder
1919

2020
import (
2121
"fmt"
22+
"strconv"
2223
"strings"
2324

2425
corev1 "k8s.io/api/core/v1"
@@ -1773,6 +1774,8 @@ type MachineDeploymentBuilder struct {
17731774
labels map[string]string
17741775
status *clusterv1.MachineDeploymentStatus
17751776
minReadySeconds *int32
1777+
minSize *int32
1778+
maxSize *int32
17761779
}
17771780

17781781
// MachineDeployment creates a MachineDeploymentBuilder with the given name and namespace.
@@ -1831,6 +1834,18 @@ func (m *MachineDeploymentBuilder) WithReplicas(replicas int32) *MachineDeployme
18311834
return m
18321835
}
18331836

1837+
// WithMinClusterAutoscalerAnnotation sets the number of CA min annotation for the MachineDeploymentBuilder.
1838+
func (m *MachineDeploymentBuilder) WithMinClusterAutoscalerAnnotation(min int32) *MachineDeploymentBuilder {
1839+
m.minSize = &min
1840+
return m
1841+
}
1842+
1843+
// WithMaxClusterAutoscalerAnnotation sets the number of CA max annotation for the MachineDeploymentBuilder.
1844+
func (m *MachineDeploymentBuilder) WithMaxClusterAutoscalerAnnotation(max int32) *MachineDeploymentBuilder {
1845+
m.maxSize = &max
1846+
return m
1847+
}
1848+
18341849
// WithGeneration sets the passed value on the machine deployments object metadata.
18351850
func (m *MachineDeploymentBuilder) WithGeneration(generation int64) *MachineDeploymentBuilder {
18361851
m.generation = &generation
@@ -1898,6 +1913,20 @@ func (m *MachineDeploymentBuilder) Build() *clusterv1.MachineDeployment {
18981913
}
18991914
obj.Spec.MinReadySeconds = m.minReadySeconds
19001915

1916+
if m.minSize != nil {
1917+
if obj.Annotations == nil {
1918+
obj.Annotations = map[string]string{}
1919+
}
1920+
obj.Annotations[clusterv1.AutoscalerMinSizeAnnotation] = strconv.FormatInt(int64(*m.minSize), 10)
1921+
}
1922+
1923+
if m.maxSize != nil {
1924+
if obj.Annotations == nil {
1925+
obj.Annotations = map[string]string{}
1926+
}
1927+
obj.Annotations[clusterv1.AutoscalerMaxSizeAnnotation] = strconv.FormatInt(int64(*m.maxSize), 10)
1928+
}
1929+
19011930
return obj
19021931
}
19031932

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
// Copyright 2025 Nutanix. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package enforceclusterautoscalerlimits
5+
6+
import (
7+
context "context"
8+
fmt "fmt"
9+
"strconv"
10+
11+
"github.com/pkg/errors"
12+
apierrors "k8s.io/apimachinery/pkg/api/errors"
13+
clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1"
14+
ctrl "sigs.k8s.io/controller-runtime"
15+
"sigs.k8s.io/controller-runtime/pkg/client"
16+
"sigs.k8s.io/controller-runtime/pkg/controller"
17+
)
18+
19+
type Reconciler struct {
20+
client.Client
21+
}
22+
23+
func (r *Reconciler) SetupWithManager(
24+
mgr ctrl.Manager,
25+
options *controller.Options,
26+
) error {
27+
return ctrl.NewControllerManagedBy(mgr).
28+
For(&clusterv1.MachineDeployment{}).
29+
WithOptions(*options).
30+
Complete(r)
31+
}
32+
33+
func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
34+
logger := ctrl.LoggerFrom(ctx).WithValues("machineDeployment", req.NamespacedName)
35+
36+
var md clusterv1.MachineDeployment
37+
if err := r.Get(ctx, req.NamespacedName, &md); err != nil {
38+
if apierrors.IsNotFound(err) {
39+
logger.V(5).Info("MachineDeployment not found, skipping reconciliation")
40+
return ctrl.Result{}, nil
41+
}
42+
43+
return ctrl.Result{}, fmt.Errorf("failed to get MachineDeployment %s: %w", req.NamespacedName, err)
44+
}
45+
46+
// If replicas is not set, we don't need to do anything.
47+
if md.Spec.Replicas == nil {
48+
logger.V(5).Info("MachineDeployment has no replicas set, skipping reconciliation")
49+
return ctrl.Result{}, nil
50+
}
51+
52+
minReplicas, err := minReplicasFromAnnotations(md.Annotations)
53+
if err != nil {
54+
// Do nothing if the minSize annotation is missing.
55+
if errors.Is(err, errMissingMinAnnotation) {
56+
logger.V(5).Info("MachineDeployment has no min size annotation, skipping reconciliation")
57+
return ctrl.Result{}, nil
58+
}
59+
60+
return ctrl.Result{}, fmt.Errorf("failed to get min size: %w", err)
61+
}
62+
63+
maxReplicas, err := maxReplicasFromAnnotations(md.Annotations)
64+
if err != nil {
65+
// Do nothing if the maxSize annotation is missing.
66+
if errors.Is(err, errMissingMaxAnnotation) {
67+
logger.V(5).Info("MachineDeployment has no max size annotation, skipping reconciliation")
68+
return ctrl.Result{}, nil
69+
}
70+
71+
return ctrl.Result{}, fmt.Errorf("failed to get max size: %w", err)
72+
}
73+
74+
if minReplicas > maxReplicas {
75+
logger.WithValues("minReplicas", minReplicas, "maxReplicas", maxReplicas).
76+
Info("Min replicas is greater than max replicas - skipping reconciliation")
77+
return ctrl.Result{}, nil
78+
}
79+
80+
// If the current replicas are within the bounds, do nothing.
81+
if int(*md.Spec.Replicas) >= minReplicas && int(*md.Spec.Replicas) <= maxReplicas {
82+
return ctrl.Result{}, nil
83+
}
84+
85+
// Otherwise set replicas to nil and depend on CAPI MachineDeployment defaulting to handle
86+
// the scaling correctly.
87+
// See https://github.com/kubernetes-sigs/cluster-api/blob/v1.10.3/internal/webhooks/machinedeployment.go#L365
88+
// for more details.
89+
md.Spec.Replicas = nil
90+
91+
if err := r.Update(ctx, &md); err != nil {
92+
return ctrl.Result{}, fmt.Errorf("failed to update MachineDeployment %s: %w", req.NamespacedName, err)
93+
}
94+
95+
return ctrl.Result{}, nil
96+
}
97+
98+
var (
99+
// errMissingMinAnnotation is the error returned when a
100+
// machine set does not have an annotation keyed by
101+
// nodeGroupMinSizeAnnotationKey.
102+
errMissingMinAnnotation = errors.New("missing min annotation")
103+
104+
// errMissingMaxAnnotation is the error returned when a
105+
// machine set does not have an annotation keyed by
106+
// nodeGroupMaxSizeAnnotationKey.
107+
errMissingMaxAnnotation = errors.New("missing max annotation")
108+
109+
// errInvalidMinAnnotationValue is the error returned when a
110+
// machine set has a non-integral min annotation value.
111+
errInvalidMinAnnotation = errors.New("invalid min annotation")
112+
113+
// errInvalidMaxAnnotationValue is the error returned when a
114+
// machine set has a non-integral max annotation value.
115+
errInvalidMaxAnnotation = errors.New("invalid max annotation")
116+
)
117+
118+
// minReplicasFromAnnotations returns the minimum value encoded in the annotations keyed
119+
// by "cluster.x-k8s.io/cluster-api-autoscaler-node-group-min-size".
120+
// Returns errMissingMinAnnotation if the annotation doesn't exist or
121+
// errInvalidMinAnnotation if the value is not of type int.
122+
func minReplicasFromAnnotations(annotations map[string]string) (int, error) {
123+
val, found := annotations[clusterv1.AutoscalerMinSizeAnnotation]
124+
if !found {
125+
return 0, errMissingMinAnnotation
126+
}
127+
i, err := strconv.Atoi(val)
128+
if err != nil {
129+
return 0, fmt.Errorf("%w: %v", errInvalidMinAnnotation, err)
130+
}
131+
return i, nil
132+
}
133+
134+
// maxReplicasFromAnnotations returns the maximum value encoded in the annotations keyed
135+
// by "cluster.x-k8s.io/cluster-api-autoscaler-node-group-max-size".
136+
// Returns errMissingMaxAnnotation if the annotation doesn't exist or
137+
// errInvalidMaxAnnotation if the value is not of type int.
138+
func maxReplicasFromAnnotations(annotations map[string]string) (int, error) {
139+
val, found := annotations[clusterv1.AutoscalerMaxSizeAnnotation]
140+
if !found {
141+
return 0, errMissingMaxAnnotation
142+
}
143+
i, err := strconv.Atoi(val)
144+
if err != nil {
145+
return 0, fmt.Errorf("%w: %v", errInvalidMaxAnnotation, err)
146+
}
147+
return i, nil
148+
}

0 commit comments

Comments
 (0)