sigs.k8s.io/cluster-api@v1.7.1/internal/webhooks/machinedeployment.go (about) 1 /* 2 Copyright 2021 The Kubernetes Authors. 3 4 Licensed under the Apache License, Version 2.0 (the "License"); 5 you may not use this file except in compliance with the License. 6 You may obtain a copy of the License at 7 8 http://www.apache.org/licenses/LICENSE-2.0 9 10 Unless required by applicable law or agreed to in writing, software 11 distributed under the License is distributed on an "AS IS" BASIS, 12 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 See the License for the specific language governing permissions and 14 limitations under the License. 15 */ 16 17 package webhooks 18 19 import ( 20 "context" 21 "fmt" 22 "strconv" 23 "strings" 24 25 "github.com/pkg/errors" 26 v1 "k8s.io/api/admission/v1" 27 apierrors "k8s.io/apimachinery/pkg/api/errors" 28 metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 29 "k8s.io/apimachinery/pkg/labels" 30 "k8s.io/apimachinery/pkg/runtime" 31 "k8s.io/apimachinery/pkg/util/intstr" 32 "k8s.io/apimachinery/pkg/util/validation" 33 "k8s.io/apimachinery/pkg/util/validation/field" 34 "k8s.io/utils/ptr" 35 ctrl "sigs.k8s.io/controller-runtime" 36 "sigs.k8s.io/controller-runtime/pkg/webhook" 37 "sigs.k8s.io/controller-runtime/pkg/webhook/admission" 38 39 clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" 40 "sigs.k8s.io/cluster-api/feature" 41 "sigs.k8s.io/cluster-api/util/version" 42 ) 43 44 func (webhook *MachineDeployment) SetupWebhookWithManager(mgr ctrl.Manager) error { 45 if webhook.decoder == nil { 46 webhook.decoder = admission.NewDecoder(mgr.GetScheme()) 47 } 48 49 return ctrl.NewWebhookManagedBy(mgr). 50 For(&clusterv1.MachineDeployment{}). 51 WithDefaulter(webhook). 52 WithValidator(webhook). 53 Complete() 54 } 55 56 // +kubebuilder:webhook:verbs=create;update,path=/validate-cluster-x-k8s-io-v1beta1-machinedeployment,mutating=false,failurePolicy=fail,matchPolicy=Equivalent,groups=cluster.x-k8s.io,resources=machinedeployments,versions=v1beta1,name=validation.machinedeployment.cluster.x-k8s.io,sideEffects=None,admissionReviewVersions=v1;v1beta1 57 // +kubebuilder:webhook:verbs=create;update,path=/mutate-cluster-x-k8s-io-v1beta1-machinedeployment,mutating=true,failurePolicy=fail,matchPolicy=Equivalent,groups=cluster.x-k8s.io,resources=machinedeployments,versions=v1beta1,name=default.machinedeployment.cluster.x-k8s.io,sideEffects=None,admissionReviewVersions=v1;v1beta1 58 59 // MachineDeployment implements a validation and defaulting webhook for MachineDeployment. 60 type MachineDeployment struct { 61 decoder *admission.Decoder 62 } 63 64 var _ webhook.CustomDefaulter = &MachineDeployment{} 65 var _ webhook.CustomValidator = &MachineDeployment{} 66 67 // Default implements webhook.CustomDefaulter. 68 func (webhook *MachineDeployment) Default(ctx context.Context, obj runtime.Object) error { 69 m, ok := obj.(*clusterv1.MachineDeployment) 70 if !ok { 71 return apierrors.NewBadRequest(fmt.Sprintf("expected a MachineDeployment but got a %T", obj)) 72 } 73 74 req, err := admission.RequestFromContext(ctx) 75 if err != nil { 76 return err 77 } 78 dryRun := false 79 if req.DryRun != nil { 80 dryRun = *req.DryRun 81 } 82 83 var oldMD *clusterv1.MachineDeployment 84 if req.Operation == v1.Update { 85 oldMD = &clusterv1.MachineDeployment{} 86 if err := webhook.decoder.DecodeRaw(req.OldObject, oldMD); err != nil { 87 return errors.Wrapf(err, "failed to decode oldObject to MachineDeployment") 88 } 89 } 90 91 if m.Labels == nil { 92 m.Labels = make(map[string]string) 93 } 94 m.Labels[clusterv1.ClusterNameLabel] = m.Spec.ClusterName 95 96 replicas, err := calculateMachineDeploymentReplicas(ctx, oldMD, m, dryRun) 97 if err != nil { 98 return err 99 } 100 m.Spec.Replicas = ptr.To[int32](replicas) 101 102 if m.Spec.MinReadySeconds == nil { 103 m.Spec.MinReadySeconds = ptr.To[int32](0) 104 } 105 106 if m.Spec.RevisionHistoryLimit == nil { 107 m.Spec.RevisionHistoryLimit = ptr.To[int32](1) 108 } 109 110 if m.Spec.ProgressDeadlineSeconds == nil { 111 m.Spec.ProgressDeadlineSeconds = ptr.To[int32](600) 112 } 113 114 if m.Spec.Selector.MatchLabels == nil { 115 m.Spec.Selector.MatchLabels = make(map[string]string) 116 } 117 118 if m.Spec.Strategy == nil { 119 m.Spec.Strategy = &clusterv1.MachineDeploymentStrategy{} 120 } 121 122 if m.Spec.Strategy.Type == "" { 123 m.Spec.Strategy.Type = clusterv1.RollingUpdateMachineDeploymentStrategyType 124 } 125 126 if m.Spec.Template.Labels == nil { 127 m.Spec.Template.Labels = make(map[string]string) 128 } 129 130 // Default RollingUpdate strategy only if strategy type is RollingUpdate. 131 if m.Spec.Strategy.Type == clusterv1.RollingUpdateMachineDeploymentStrategyType { 132 if m.Spec.Strategy.RollingUpdate == nil { 133 m.Spec.Strategy.RollingUpdate = &clusterv1.MachineRollingUpdateDeployment{} 134 } 135 if m.Spec.Strategy.RollingUpdate.MaxSurge == nil { 136 ios1 := intstr.FromInt(1) 137 m.Spec.Strategy.RollingUpdate.MaxSurge = &ios1 138 } 139 if m.Spec.Strategy.RollingUpdate.MaxUnavailable == nil { 140 ios0 := intstr.FromInt(0) 141 m.Spec.Strategy.RollingUpdate.MaxUnavailable = &ios0 142 } 143 } 144 145 // If no selector has been provided, add label and selector for the 146 // MachineDeployment's name as a default way of providing uniqueness. 147 if len(m.Spec.Selector.MatchLabels) == 0 && len(m.Spec.Selector.MatchExpressions) == 0 { 148 m.Spec.Selector.MatchLabels[clusterv1.MachineDeploymentNameLabel] = m.Name 149 m.Spec.Template.Labels[clusterv1.MachineDeploymentNameLabel] = m.Name 150 } 151 // Make sure selector and template to be in the same cluster. 152 m.Spec.Selector.MatchLabels[clusterv1.ClusterNameLabel] = m.Spec.ClusterName 153 m.Spec.Template.Labels[clusterv1.ClusterNameLabel] = m.Spec.ClusterName 154 155 // tolerate version strings without a "v" prefix: prepend it if it's not there 156 if m.Spec.Template.Spec.Version != nil && !strings.HasPrefix(*m.Spec.Template.Spec.Version, "v") { 157 normalizedVersion := "v" + *m.Spec.Template.Spec.Version 158 m.Spec.Template.Spec.Version = &normalizedVersion 159 } 160 161 return nil 162 } 163 164 // ValidateCreate implements webhook.CustomValidator so a webhook will be registered for the type. 165 func (webhook *MachineDeployment) ValidateCreate(_ context.Context, obj runtime.Object) (admission.Warnings, error) { 166 m, ok := obj.(*clusterv1.MachineDeployment) 167 if !ok { 168 return nil, apierrors.NewBadRequest(fmt.Sprintf("expected a MachineDeployment but got a %T", obj)) 169 } 170 171 return nil, webhook.validate(nil, m) 172 } 173 174 // ValidateUpdate implements webhook.Validator so a webhook will be registered for the type. 175 func (webhook *MachineDeployment) ValidateUpdate(_ context.Context, oldObj, newObj runtime.Object) (admission.Warnings, error) { 176 oldMD, ok := oldObj.(*clusterv1.MachineDeployment) 177 if !ok { 178 return nil, apierrors.NewBadRequest(fmt.Sprintf("expected a MachineDeployment but got a %T", oldObj)) 179 } 180 181 newMD, ok := newObj.(*clusterv1.MachineDeployment) 182 if !ok { 183 return nil, apierrors.NewBadRequest(fmt.Sprintf("expected a MachineDeployment but got a %T", newObj)) 184 } 185 186 return nil, webhook.validate(oldMD, newMD) 187 } 188 189 // ValidateDelete implements webhook.Validator so a webhook will be registered for the type. 190 func (webhook *MachineDeployment) ValidateDelete(_ context.Context, _ runtime.Object) (admission.Warnings, error) { 191 return nil, nil 192 } 193 194 func (webhook *MachineDeployment) validate(oldMD, newMD *clusterv1.MachineDeployment) error { 195 var allErrs field.ErrorList 196 // The MachineDeployment name is used as a label value. This check ensures names which are not be valid label values are rejected. 197 if errs := validation.IsValidLabelValue(newMD.Name); len(errs) != 0 { 198 for _, err := range errs { 199 allErrs = append( 200 allErrs, 201 field.Invalid( 202 field.NewPath("metadata", "name"), 203 newMD.Name, 204 fmt.Sprintf("must be a valid label value: %s", err), 205 ), 206 ) 207 } 208 } 209 specPath := field.NewPath("spec") 210 selector, err := metav1.LabelSelectorAsSelector(&newMD.Spec.Selector) 211 if err != nil { 212 allErrs = append( 213 allErrs, 214 field.Invalid(specPath.Child("selector"), newMD.Spec.Selector, err.Error()), 215 ) 216 } else if !selector.Matches(labels.Set(newMD.Spec.Template.Labels)) { 217 allErrs = append( 218 allErrs, 219 field.Forbidden( 220 specPath.Child("template", "metadata", "labels"), 221 fmt.Sprintf("must match spec.selector %q", selector.String()), 222 ), 223 ) 224 } 225 226 // MachineSet preflight checks that should be skipped could also be set as annotation on the MachineDeployment 227 // since MachineDeployment annotations are synced to the MachineSet. 228 if feature.Gates.Enabled(feature.MachineSetPreflightChecks) { 229 if err := validateSkippedMachineSetPreflightChecks(newMD); err != nil { 230 allErrs = append(allErrs, err) 231 } 232 } 233 234 if oldMD != nil && oldMD.Spec.ClusterName != newMD.Spec.ClusterName { 235 allErrs = append( 236 allErrs, 237 field.Forbidden( 238 specPath.Child("clusterName"), 239 "field is immutable", 240 ), 241 ) 242 } 243 244 if newMD.Spec.Strategy != nil && newMD.Spec.Strategy.RollingUpdate != nil { 245 total := 1 246 if newMD.Spec.Replicas != nil { 247 total = int(*newMD.Spec.Replicas) 248 } 249 250 if newMD.Spec.Strategy.RollingUpdate.MaxSurge != nil { 251 if _, err := intstr.GetScaledValueFromIntOrPercent(newMD.Spec.Strategy.RollingUpdate.MaxSurge, total, true); err != nil { 252 allErrs = append( 253 allErrs, 254 field.Invalid(specPath.Child("strategy", "rollingUpdate", "maxSurge"), 255 newMD.Spec.Strategy.RollingUpdate.MaxSurge, fmt.Sprintf("must be either an int or a percentage: %v", err.Error())), 256 ) 257 } 258 } 259 260 if newMD.Spec.Strategy.RollingUpdate.MaxUnavailable != nil { 261 if _, err := intstr.GetScaledValueFromIntOrPercent(newMD.Spec.Strategy.RollingUpdate.MaxUnavailable, total, true); err != nil { 262 allErrs = append( 263 allErrs, 264 field.Invalid(specPath.Child("strategy", "rollingUpdate", "maxUnavailable"), 265 newMD.Spec.Strategy.RollingUpdate.MaxUnavailable, fmt.Sprintf("must be either an int or a percentage: %v", err.Error())), 266 ) 267 } 268 } 269 } 270 271 if newMD.Spec.Template.Spec.Version != nil { 272 if !version.KubeSemver.MatchString(*newMD.Spec.Template.Spec.Version) { 273 allErrs = append(allErrs, field.Invalid(specPath.Child("template", "spec", "version"), *newMD.Spec.Template.Spec.Version, "must be a valid semantic version")) 274 } 275 } 276 277 // Validate the metadata of the template. 278 allErrs = append(allErrs, newMD.Spec.Template.ObjectMeta.Validate(specPath.Child("template", "metadata"))...) 279 280 if len(allErrs) == 0 { 281 return nil 282 } 283 284 return apierrors.NewInvalid(clusterv1.GroupVersion.WithKind("MachineDeployment").GroupKind(), newMD.Name, allErrs) 285 } 286 287 // calculateMachineDeploymentReplicas calculates the default value of the replicas field. 288 // The value will be calculated based on the following logic: 289 // * if replicas is already set on newMD, keep the current value 290 // * if the autoscaler min size and max size annotations are set: 291 // - if it's a new MachineDeployment, use min size 292 // - if the replicas field of the old MachineDeployment is < min size, use min size 293 // - if the replicas field of the old MachineDeployment is > max size, use max size 294 // - if the replicas field of the old MachineDeployment is in the (min size, max size) range, keep the value from the oldMD 295 // 296 // * otherwise use 1 297 // 298 // The goal of this logic is to provide a smoother UX for clusters using the Kubernetes autoscaler. 299 // Note: Autoscaler only takes over control of the replicas field if the replicas value is in the (min size, max size) range. 300 // 301 // We are supporting the following use cases: 302 // * A new MD is created and replicas should be managed by the autoscaler 303 // - Either via the default annotation or via the min size and max size annotations the replicas field 304 // is defaulted to a value which is within the (min size, max size) range so the autoscaler can take control. 305 // 306 // * An existing MD which initially wasn't controlled by the autoscaler should be later controlled by the autoscaler 307 // - To adopt an existing MD users can use the default, min size and max size annotations to enable the autoscaler 308 // and to ensure the replicas field is within the (min size, max size) range. Without the annotations handing over 309 // control to the autoscaler by unsetting the replicas field would lead to the field being set to 1. This is very 310 // disruptive for existing Machines and if 1 is outside the (min size, max size) range the autoscaler won't take 311 // control. 312 // 313 // Notes: 314 // - While the min size and max size annotations of the autoscaler provide the best UX, other autoscalers can use the 315 // DefaultReplicasAnnotation if they have similar use cases. 316 func calculateMachineDeploymentReplicas(ctx context.Context, oldMD *clusterv1.MachineDeployment, newMD *clusterv1.MachineDeployment, dryRun bool) (int32, error) { 317 // If replicas is already set => Keep the current value. 318 if newMD.Spec.Replicas != nil { 319 return *newMD.Spec.Replicas, nil 320 } 321 322 log := ctrl.LoggerFrom(ctx) 323 324 // If both autoscaler annotations are set, use them to calculate the default value. 325 minSizeString, hasMinSizeAnnotation := newMD.Annotations[clusterv1.AutoscalerMinSizeAnnotation] 326 maxSizeString, hasMaxSizeAnnotation := newMD.Annotations[clusterv1.AutoscalerMaxSizeAnnotation] 327 if hasMinSizeAnnotation && hasMaxSizeAnnotation { 328 minSize, err := strconv.ParseInt(minSizeString, 10, 32) 329 if err != nil { 330 return 0, errors.Wrapf(err, "failed to caculate MachineDeployment replicas value: could not parse the value of the %q annotation", clusterv1.AutoscalerMinSizeAnnotation) 331 } 332 maxSize, err := strconv.ParseInt(maxSizeString, 10, 32) 333 if err != nil { 334 return 0, errors.Wrapf(err, "failed to caculate MachineDeployment replicas value: could not parse the value of the %q annotation", clusterv1.AutoscalerMaxSizeAnnotation) 335 } 336 337 // If it's a new MachineDeployment => Use the min size. 338 // Note: This will result in a scale up to get into the range where autoscaler takes over. 339 if oldMD == nil { 340 if !dryRun { 341 log.V(2).Info(fmt.Sprintf("Replica field has been defaulted to %d based on the %s annotation (MD is a new MD)", minSize, clusterv1.AutoscalerMinSizeAnnotation)) 342 } 343 return int32(minSize), nil 344 } 345 346 // Otherwise we are handing over the control for the replicas field for an existing MachineDeployment 347 // to the autoscaler. 348 349 switch { 350 // If the old MachineDeployment doesn't have replicas set => Use the min size. 351 // Note: As defaulting always sets the replica field, this case should not be possible 352 // We only have this handling to be 100% safe against panics. 353 case oldMD.Spec.Replicas == nil: 354 if !dryRun { 355 log.V(2).Info(fmt.Sprintf("Replica field has been defaulted to %d based on the %s annotation (old MD didn't have replicas set)", minSize, clusterv1.AutoscalerMinSizeAnnotation)) 356 } 357 return int32(minSize), nil 358 // If the old MachineDeployment replicas are lower than min size => Use the min size. 359 // Note: This will result in a scale up to get into the range where autoscaler takes over. 360 case *oldMD.Spec.Replicas < int32(minSize): 361 if !dryRun { 362 log.V(2).Info(fmt.Sprintf("Replica field has been defaulted to %d based on the %s annotation (old MD had replicas below min size)", minSize, clusterv1.AutoscalerMinSizeAnnotation)) 363 } 364 return int32(minSize), nil 365 // If the old MachineDeployment replicas are higher than max size => Use the max size. 366 // Note: This will result in a scale down to get into the range where autoscaler takes over. 367 case *oldMD.Spec.Replicas > int32(maxSize): 368 if !dryRun { 369 log.V(2).Info(fmt.Sprintf("Replica field has been defaulted to %d based on the %s annotation (old MD had replicas above max size)", maxSize, clusterv1.AutoscalerMaxSizeAnnotation)) 370 } 371 return int32(maxSize), nil 372 // If the old MachineDeployment replicas are between min and max size => Keep the current value. 373 default: 374 if !dryRun { 375 log.V(2).Info(fmt.Sprintf("Replica field has been defaulted to %d based on replicas of the old MachineDeployment (old MD had replicas within min size / max size range)", *oldMD.Spec.Replicas)) 376 } 377 return *oldMD.Spec.Replicas, nil 378 } 379 } 380 381 // If neither the default nor the autoscaler annotations are set => Default to 1. 382 if !dryRun { 383 log.V(2).Info("Replica field has been defaulted to 1") 384 } 385 return 1, nil 386 }