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  }