sigs.k8s.io/cluster-api@v1.7.1/internal/webhooks/machineset.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/sets" 32 "k8s.io/apimachinery/pkg/util/validation/field" 33 "k8s.io/utils/ptr" 34 ctrl "sigs.k8s.io/controller-runtime" 35 "sigs.k8s.io/controller-runtime/pkg/client" 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/labels/format" 42 "sigs.k8s.io/cluster-api/util/version" 43 ) 44 45 func (webhook *MachineSet) SetupWebhookWithManager(mgr ctrl.Manager) error { 46 if webhook.decoder == nil { 47 webhook.decoder = admission.NewDecoder(mgr.GetScheme()) 48 } 49 50 return ctrl.NewWebhookManagedBy(mgr). 51 For(&clusterv1.MachineSet{}). 52 WithDefaulter(webhook). 53 WithValidator(webhook). 54 Complete() 55 } 56 57 // +kubebuilder:webhook:verbs=create;update,path=/validate-cluster-x-k8s-io-v1beta1-machineset,mutating=false,failurePolicy=fail,matchPolicy=Equivalent,groups=cluster.x-k8s.io,resources=machinesets,versions=v1beta1,name=validation.machineset.cluster.x-k8s.io,sideEffects=None,admissionReviewVersions=v1;v1beta1 58 // +kubebuilder:webhook:verbs=create;update,path=/mutate-cluster-x-k8s-io-v1beta1-machineset,mutating=true,failurePolicy=fail,matchPolicy=Equivalent,groups=cluster.x-k8s.io,resources=machinesets,versions=v1beta1,name=default.machineset.cluster.x-k8s.io,sideEffects=None,admissionReviewVersions=v1;v1beta1 59 60 // MachineSet implements a validation and defaulting webhook for MachineSet. 61 type MachineSet struct { 62 decoder *admission.Decoder 63 } 64 65 var _ webhook.CustomDefaulter = &MachineSet{} 66 var _ webhook.CustomValidator = &MachineSet{} 67 68 // Default sets default MachineSet field values. 69 func (webhook *MachineSet) Default(ctx context.Context, obj runtime.Object) error { 70 m, ok := obj.(*clusterv1.MachineSet) 71 if !ok { 72 return apierrors.NewBadRequest(fmt.Sprintf("expected a MachineSet but got a %T", obj)) 73 } 74 75 req, err := admission.RequestFromContext(ctx) 76 if err != nil { 77 return err 78 } 79 80 dryRun := false 81 if req.DryRun != nil { 82 dryRun = *req.DryRun 83 } 84 85 var oldMS *clusterv1.MachineSet 86 if req.Operation == v1.Update { 87 oldMS = &clusterv1.MachineSet{} 88 if err := webhook.decoder.DecodeRaw(req.OldObject, oldMS); err != nil { 89 return errors.Wrapf(err, "failed to decode oldObject to MachineSet") 90 } 91 } 92 93 if m.Labels == nil { 94 m.Labels = make(map[string]string) 95 } 96 m.Labels[clusterv1.ClusterNameLabel] = m.Spec.ClusterName 97 98 replicas, err := calculateMachineSetReplicas(ctx, oldMS, m, dryRun) 99 if err != nil { 100 return err 101 } 102 m.Spec.Replicas = ptr.To[int32](replicas) 103 104 if m.Spec.DeletePolicy == "" { 105 randomPolicy := string(clusterv1.RandomMachineSetDeletePolicy) 106 m.Spec.DeletePolicy = randomPolicy 107 } 108 109 if m.Spec.Selector.MatchLabels == nil { 110 m.Spec.Selector.MatchLabels = make(map[string]string) 111 } 112 113 if m.Spec.Template.Labels == nil { 114 m.Spec.Template.Labels = make(map[string]string) 115 } 116 117 if len(m.Spec.Selector.MatchLabels) == 0 && len(m.Spec.Selector.MatchExpressions) == 0 { 118 // Note: MustFormatValue is used here as the value of this label will be a hash if the MachineSet name is longer than 63 characters. 119 m.Spec.Selector.MatchLabels[clusterv1.MachineSetNameLabel] = format.MustFormatValue(m.Name) 120 m.Spec.Template.Labels[clusterv1.MachineSetNameLabel] = format.MustFormatValue(m.Name) 121 } 122 123 if m.Spec.Template.Spec.Version != nil && !strings.HasPrefix(*m.Spec.Template.Spec.Version, "v") { 124 normalizedVersion := "v" + *m.Spec.Template.Spec.Version 125 m.Spec.Template.Spec.Version = &normalizedVersion 126 } 127 128 return nil 129 } 130 131 // ValidateCreate implements webhook.Validator so a webhook will be registered for the type. 132 func (webhook *MachineSet) ValidateCreate(_ context.Context, obj runtime.Object) (admission.Warnings, error) { 133 m, ok := obj.(*clusterv1.MachineSet) 134 if !ok { 135 return nil, apierrors.NewBadRequest(fmt.Sprintf("expected a MachineSet but got a %T", obj)) 136 } 137 138 return nil, webhook.validate(nil, m) 139 } 140 141 // ValidateUpdate implements webhook.Validator so a webhook will be registered for the type. 142 func (webhook *MachineSet) ValidateUpdate(_ context.Context, oldObj, newObj runtime.Object) (admission.Warnings, error) { 143 oldMS, ok := oldObj.(*clusterv1.MachineSet) 144 if !ok { 145 return nil, apierrors.NewBadRequest(fmt.Sprintf("expected a MachineSet but got a %T", oldObj)) 146 } 147 newMS, ok := newObj.(*clusterv1.MachineSet) 148 if !ok { 149 return nil, apierrors.NewBadRequest(fmt.Sprintf("expected a MachineSet but got a %T", newObj)) 150 } 151 152 return nil, webhook.validate(oldMS, newMS) 153 } 154 155 // ValidateDelete implements webhook.Validator so a webhook will be registered for the type. 156 func (webhook *MachineSet) ValidateDelete(_ context.Context, _ runtime.Object) (admission.Warnings, error) { 157 return nil, nil 158 } 159 160 func (webhook *MachineSet) validate(oldMS, newMS *clusterv1.MachineSet) error { 161 var allErrs field.ErrorList 162 specPath := field.NewPath("spec") 163 selector, err := metav1.LabelSelectorAsSelector(&newMS.Spec.Selector) 164 if err != nil { 165 allErrs = append( 166 allErrs, 167 field.Invalid( 168 specPath.Child("selector"), 169 newMS.Spec.Selector, 170 err.Error(), 171 ), 172 ) 173 } else if !selector.Matches(labels.Set(newMS.Spec.Template.Labels)) { 174 allErrs = append( 175 allErrs, 176 field.Invalid( 177 specPath.Child("template", "metadata", "labels"), 178 newMS.Spec.Template.ObjectMeta.Labels, 179 fmt.Sprintf("must match spec.selector %q", selector.String()), 180 ), 181 ) 182 } 183 184 if feature.Gates.Enabled(feature.MachineSetPreflightChecks) { 185 if err := validateSkippedMachineSetPreflightChecks(newMS); err != nil { 186 allErrs = append(allErrs, err) 187 } 188 } 189 190 if oldMS != nil && oldMS.Spec.ClusterName != newMS.Spec.ClusterName { 191 allErrs = append( 192 allErrs, 193 field.Forbidden( 194 specPath.Child("clusterName"), 195 "field is immutable", 196 ), 197 ) 198 } 199 200 if newMS.Spec.Template.Spec.Version != nil { 201 if !version.KubeSemver.MatchString(*newMS.Spec.Template.Spec.Version) { 202 allErrs = append( 203 allErrs, 204 field.Invalid( 205 specPath.Child("template", "spec", "version"), 206 *newMS.Spec.Template.Spec.Version, 207 "must be a valid semantic version", 208 ), 209 ) 210 } 211 } 212 213 // Validate the metadata of the template. 214 allErrs = append(allErrs, newMS.Spec.Template.ObjectMeta.Validate(specPath.Child("template", "metadata"))...) 215 216 if len(allErrs) == 0 { 217 return nil 218 } 219 220 return apierrors.NewInvalid(clusterv1.GroupVersion.WithKind("MachineSet").GroupKind(), newMS.Name, allErrs) 221 } 222 223 func validateSkippedMachineSetPreflightChecks(o client.Object) *field.Error { 224 if o == nil { 225 return nil 226 } 227 skip := o.GetAnnotations()[clusterv1.MachineSetSkipPreflightChecksAnnotation] 228 if skip == "" { 229 return nil 230 } 231 232 supported := sets.New[clusterv1.MachineSetPreflightCheck]( 233 clusterv1.MachineSetPreflightCheckAll, 234 clusterv1.MachineSetPreflightCheckKubeadmVersionSkew, 235 clusterv1.MachineSetPreflightCheckKubernetesVersionSkew, 236 clusterv1.MachineSetPreflightCheckControlPlaneIsStable, 237 ) 238 239 skippedList := strings.Split(skip, ",") 240 invalid := []clusterv1.MachineSetPreflightCheck{} 241 for i := range skippedList { 242 skipped := clusterv1.MachineSetPreflightCheck(strings.TrimSpace(skippedList[i])) 243 if !supported.Has(skipped) { 244 invalid = append(invalid, skipped) 245 } 246 } 247 if len(invalid) > 0 { 248 return field.Invalid( 249 field.NewPath("metadata", "annotations", clusterv1.MachineSetSkipPreflightChecksAnnotation), 250 invalid, 251 fmt.Sprintf("skipped preflight check(s) must be among: %v", sets.List(supported)), 252 ) 253 } 254 return nil 255 } 256 257 // calculateMachineSetReplicas calculates the default value of the replicas field. 258 // The value will be calculated based on the following logic: 259 // * if replicas is already set on newMS, keep the current value 260 // * if the autoscaler min size and max size annotations are set: 261 // - if it's a new MachineSet, use min size 262 // - if the replicas field of the old MachineSet is < min size, use min size 263 // - if the replicas field of the old MachineSet is > max size, use max size 264 // - if the replicas field of the old MachineSet is in the (min size, max size) range, keep the value from the oldMS 265 // 266 // * otherwise use 1 267 // 268 // The goal of this logic is to provide a smoother UX for clusters using the Kubernetes autoscaler. 269 // Note: Autoscaler only takes over control of the replicas field if the replicas value is in the (min size, max size) range. 270 // 271 // We are supporting the following use cases: 272 // * A new MS is created and replicas should be managed by the autoscaler 273 // - Either via the default annotation or via the min size and max size annotations the replicas field 274 // is defaulted to a value which is within the (min size, max size) range so the autoscaler can take control. 275 // 276 // * An existing MS which initially wasn't controlled by the autoscaler should be later controlled by the autoscaler 277 // - To adopt an existing MS users can use the default, min size and max size annotations to enable the autoscaler 278 // and to ensure the replicas field is within the (min size, max size) range. Without the annotations handing over 279 // control to the autoscaler by unsetting the replicas field would lead to the field being set to 1. This is very 280 // disruptive for existing Machines and if 1 is outside the (min size, max size) range the autoscaler won't take 281 // control. 282 // 283 // Notes: 284 // - While the min size and max size annotations of the autoscaler provide the best UX, other autoscalers can use the 285 // DefaultReplicasAnnotation if they have similar use cases. 286 func calculateMachineSetReplicas(ctx context.Context, oldMS *clusterv1.MachineSet, newMS *clusterv1.MachineSet, dryRun bool) (int32, error) { 287 // If replicas is already set => Keep the current value. 288 if newMS.Spec.Replicas != nil { 289 return *newMS.Spec.Replicas, nil 290 } 291 292 log := ctrl.LoggerFrom(ctx) 293 294 // If both autoscaler annotations are set, use them to calculate the default value. 295 minSizeString, hasMinSizeAnnotation := newMS.Annotations[clusterv1.AutoscalerMinSizeAnnotation] 296 maxSizeString, hasMaxSizeAnnotation := newMS.Annotations[clusterv1.AutoscalerMaxSizeAnnotation] 297 if hasMinSizeAnnotation && hasMaxSizeAnnotation { 298 minSize, err := strconv.ParseInt(minSizeString, 10, 32) 299 if err != nil { 300 return 0, errors.Wrapf(err, "failed to caculate MachineSet replicas value: could not parse the value of the %q annotation", clusterv1.AutoscalerMinSizeAnnotation) 301 } 302 maxSize, err := strconv.ParseInt(maxSizeString, 10, 32) 303 if err != nil { 304 return 0, errors.Wrapf(err, "failed to caculate MachineSet replicas value: could not parse the value of the %q annotation", clusterv1.AutoscalerMaxSizeAnnotation) 305 } 306 307 // If it's a new MachineSet => Use the min size. 308 // Note: This will result in a scale up to get into the range where autoscaler takes over. 309 if oldMS == nil { 310 if !dryRun { 311 log.V(2).Info(fmt.Sprintf("Replica field has been defaulted to %d based on the %s annotation (MS is a new MS)", minSize, clusterv1.AutoscalerMinSizeAnnotation)) 312 } 313 return int32(minSize), nil 314 } 315 316 // Otherwise we are handing over the control for the replicas field for an existing MachineSet 317 // to the autoscaler. 318 319 switch { 320 // If the old MachineSet doesn't have replicas set => Use the min size. 321 // Note: As defaulting always sets the replica field, this case should not be possible 322 // We only have this handling to be 100% safe against panics. 323 case oldMS.Spec.Replicas == nil: 324 if !dryRun { 325 log.V(2).Info(fmt.Sprintf("Replica field has been defaulted to %d based on the %s annotation (old MS didn't have replicas set)", minSize, clusterv1.AutoscalerMinSizeAnnotation)) 326 } 327 return int32(minSize), nil 328 // If the old MachineSet replicas are lower than min size => Use the min size. 329 // Note: This will result in a scale up to get into the range where autoscaler takes over. 330 case *oldMS.Spec.Replicas < int32(minSize): 331 if !dryRun { 332 log.V(2).Info(fmt.Sprintf("Replica field has been defaulted to %d based on the %s annotation (old MS had replicas below min size)", minSize, clusterv1.AutoscalerMinSizeAnnotation)) 333 } 334 return int32(minSize), nil 335 // If the old MachineSet replicas are higher than max size => Use the max size. 336 // Note: This will result in a scale down to get into the range where autoscaler takes over. 337 case *oldMS.Spec.Replicas > int32(maxSize): 338 if !dryRun { 339 log.V(2).Info(fmt.Sprintf("Replica field has been defaulted to %d based on the %s annotation (old MS had replicas above max size)", maxSize, clusterv1.AutoscalerMaxSizeAnnotation)) 340 } 341 return int32(maxSize), nil 342 // If the old MachineSet replicas are between min and max size => Keep the current value. 343 default: 344 if !dryRun { 345 log.V(2).Info(fmt.Sprintf("Replica field has been defaulted to %d based on replicas of the old MachineSet (old MS had replicas within min size / max size range)", *oldMS.Spec.Replicas)) 346 } 347 return *oldMS.Spec.Replicas, nil 348 } 349 } 350 351 // If neither the default nor the autoscaler annotations are set => Default to 1. 352 if !dryRun { 353 log.V(2).Info("Replica field has been defaulted to 1") 354 } 355 return 1, nil 356 }