sigs.k8s.io/kueue@v0.6.2/pkg/webhooks/clusterqueue_webhook.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  
    22  	corev1 "k8s.io/api/core/v1"
    23  	"k8s.io/apimachinery/pkg/api/resource"
    24  	apivalidation "k8s.io/apimachinery/pkg/api/validation"
    25  	"k8s.io/apimachinery/pkg/apis/meta/v1/validation"
    26  	"k8s.io/apimachinery/pkg/runtime"
    27  	"k8s.io/apimachinery/pkg/util/sets"
    28  	"k8s.io/apimachinery/pkg/util/validation/field"
    29  	"k8s.io/klog/v2"
    30  	ctrl "sigs.k8s.io/controller-runtime"
    31  	"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
    32  	"sigs.k8s.io/controller-runtime/pkg/webhook"
    33  	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
    34  
    35  	kueue "sigs.k8s.io/kueue/apis/kueue/v1beta1"
    36  	"sigs.k8s.io/kueue/pkg/constants"
    37  	"sigs.k8s.io/kueue/pkg/features"
    38  )
    39  
    40  const (
    41  	limitIsEmptyErrorMsg string = `must be nil when cohort is empty`
    42  	lendingLimitErrorMsg string = `must be less than or equal to the nominalQuota`
    43  )
    44  
    45  type ClusterQueueWebhook struct{}
    46  
    47  func setupWebhookForClusterQueue(mgr ctrl.Manager) error {
    48  	return ctrl.NewWebhookManagedBy(mgr).
    49  		For(&kueue.ClusterQueue{}).
    50  		WithDefaulter(&ClusterQueueWebhook{}).
    51  		WithValidator(&ClusterQueueWebhook{}).
    52  		Complete()
    53  }
    54  
    55  // +kubebuilder:webhook:path=/mutate-kueue-x-k8s-io-v1beta1-clusterqueue,mutating=true,failurePolicy=fail,sideEffects=None,groups=kueue.x-k8s.io,resources=clusterqueues,verbs=create,versions=v1beta1,name=mclusterqueue.kb.io,admissionReviewVersions=v1
    56  
    57  var _ webhook.CustomDefaulter = &ClusterQueueWebhook{}
    58  
    59  // Default implements webhook.CustomDefaulter so a webhook will be registered for the type
    60  func (w *ClusterQueueWebhook) Default(ctx context.Context, obj runtime.Object) error {
    61  	cq := obj.(*kueue.ClusterQueue)
    62  	log := ctrl.LoggerFrom(ctx).WithName("clusterqueue-webhook")
    63  	log.V(5).Info("Applying defaults", "clusterQueue", klog.KObj(cq))
    64  	if !controllerutil.ContainsFinalizer(cq, kueue.ResourceInUseFinalizerName) {
    65  		controllerutil.AddFinalizer(cq, kueue.ResourceInUseFinalizerName)
    66  	}
    67  	if cq.Spec.Preemption == nil {
    68  		cq.Spec.Preemption = &kueue.ClusterQueuePreemption{
    69  			WithinClusterQueue:  kueue.PreemptionPolicyNever,
    70  			ReclaimWithinCohort: kueue.PreemptionPolicyNever,
    71  		}
    72  	}
    73  	if cq.Spec.Preemption.BorrowWithinCohort == nil {
    74  		cq.Spec.Preemption.BorrowWithinCohort = &kueue.BorrowWithinCohort{
    75  			Policy: kueue.BorrowWithinCohortPolicyNever,
    76  		}
    77  	}
    78  	if cq.Spec.FlavorFungibility == nil {
    79  		cq.Spec.FlavorFungibility = &kueue.FlavorFungibility{
    80  			WhenCanBorrow:  kueue.Borrow,
    81  			WhenCanPreempt: kueue.TryNextFlavor,
    82  		}
    83  	}
    84  	return nil
    85  }
    86  
    87  // +kubebuilder:webhook:path=/validate-kueue-x-k8s-io-v1beta1-clusterqueue,mutating=false,failurePolicy=fail,sideEffects=None,groups=kueue.x-k8s.io,resources=clusterqueues,verbs=create;update,versions=v1beta1,name=vclusterqueue.kb.io,admissionReviewVersions=v1
    88  
    89  var _ webhook.CustomValidator = &ClusterQueueWebhook{}
    90  
    91  // ValidateCreate implements webhook.CustomValidator so a webhook will be registered for the type
    92  func (w *ClusterQueueWebhook) ValidateCreate(ctx context.Context, obj runtime.Object) (admission.Warnings, error) {
    93  	cq := obj.(*kueue.ClusterQueue)
    94  	log := ctrl.LoggerFrom(ctx).WithName("clusterqueue-webhook")
    95  	log.V(5).Info("Validating create", "clusterQueue", klog.KObj(cq))
    96  	allErrs := ValidateClusterQueue(cq)
    97  	return nil, allErrs.ToAggregate()
    98  }
    99  
   100  // ValidateUpdate implements webhook.CustomValidator so a webhook will be registered for the type
   101  func (w *ClusterQueueWebhook) ValidateUpdate(ctx context.Context, oldObj, newObj runtime.Object) (admission.Warnings, error) {
   102  	newCQ := newObj.(*kueue.ClusterQueue)
   103  	oldCQ := oldObj.(*kueue.ClusterQueue)
   104  
   105  	log := ctrl.LoggerFrom(ctx).WithName("clusterqueue-webhook")
   106  	log.V(5).Info("Validating update", "clusterQueue", klog.KObj(newCQ))
   107  	allErrs := ValidateClusterQueueUpdate(newCQ, oldCQ)
   108  	return nil, allErrs.ToAggregate()
   109  }
   110  
   111  // ValidateDelete implements webhook.CustomValidator so a webhook will be registered for the type
   112  func (w *ClusterQueueWebhook) ValidateDelete(ctx context.Context, obj runtime.Object) (admission.Warnings, error) {
   113  	return nil, nil
   114  }
   115  
   116  func ValidateClusterQueue(cq *kueue.ClusterQueue) field.ErrorList {
   117  	path := field.NewPath("spec")
   118  
   119  	var allErrs field.ErrorList
   120  	if len(cq.Spec.Cohort) != 0 {
   121  		allErrs = append(allErrs, validateNameReference(cq.Spec.Cohort, path.Child("cohort"))...)
   122  	}
   123  	allErrs = append(allErrs, validateResourceGroups(cq.Spec.ResourceGroups, cq.Spec.Cohort, path.Child("resourceGroups"))...)
   124  	allErrs = append(allErrs,
   125  		validation.ValidateLabelSelector(cq.Spec.NamespaceSelector, validation.LabelSelectorValidationOptions{}, path.Child("namespaceSelector"))...)
   126  	if cq.Spec.Preemption != nil {
   127  		allErrs = append(allErrs, validatePreemption(cq.Spec.Preemption, path.Child("preemption"))...)
   128  	}
   129  	return allErrs
   130  }
   131  
   132  // Since Kubernetes 1.25, we can use CEL validation rules to implement
   133  // a few common immutability patterns directly in the manifest for a CRD.
   134  // ref: https://kubernetes.io/blog/2022/09/29/enforce-immutability-using-cel/
   135  // We need to validate the spec.queueingStrategy immutable manually before Kubernetes 1.25.
   136  func ValidateClusterQueueUpdate(newObj, oldObj *kueue.ClusterQueue) field.ErrorList {
   137  	var allErrs field.ErrorList
   138  	allErrs = append(allErrs, ValidateClusterQueue(newObj)...)
   139  	allErrs = append(allErrs, apivalidation.ValidateImmutableField(newObj.Spec.QueueingStrategy, oldObj.Spec.QueueingStrategy, field.NewPath("spec", "queueingStrategy"))...)
   140  	return allErrs
   141  }
   142  
   143  func validatePreemption(preemption *kueue.ClusterQueuePreemption, path *field.Path) field.ErrorList {
   144  	var allErrs field.ErrorList
   145  	if preemption.ReclaimWithinCohort == kueue.PreemptionPolicyNever &&
   146  		preemption.BorrowWithinCohort != nil &&
   147  		preemption.BorrowWithinCohort.Policy != kueue.BorrowWithinCohortPolicyNever {
   148  		allErrs = append(allErrs, field.Invalid(path, preemption, "reclaimWithinCohort=Never and borrowWithinCohort.Policy!=Never"))
   149  	}
   150  	return allErrs
   151  }
   152  
   153  func validateResourceGroups(resourceGroups []kueue.ResourceGroup, cohort string, path *field.Path) field.ErrorList {
   154  	var allErrs field.ErrorList
   155  	seenResources := sets.New[corev1.ResourceName]()
   156  	seenFlavors := sets.New[kueue.ResourceFlavorReference]()
   157  
   158  	for i, rg := range resourceGroups {
   159  		path := path.Index(i)
   160  		for j, name := range rg.CoveredResources {
   161  			path := path.Child("coveredResources").Index(j)
   162  			allErrs = append(allErrs, validateResourceName(name, path)...)
   163  			if seenResources.Has(name) {
   164  				allErrs = append(allErrs, field.Duplicate(path, name))
   165  			} else {
   166  				seenResources.Insert(name)
   167  			}
   168  		}
   169  		for j, fqs := range rg.Flavors {
   170  			path := path.Child("flavors").Index(j)
   171  			allErrs = append(allErrs, validateFlavorQuotas(fqs, rg.CoveredResources, cohort, path)...)
   172  			if seenFlavors.Has(fqs.Name) {
   173  				allErrs = append(allErrs, field.Duplicate(path.Child("name"), fqs.Name))
   174  			} else {
   175  				seenFlavors.Insert(fqs.Name)
   176  			}
   177  		}
   178  	}
   179  	return allErrs
   180  }
   181  
   182  func validateFlavorQuotas(flavorQuotas kueue.FlavorQuotas, coveredResources []corev1.ResourceName, cohort string, path *field.Path) field.ErrorList {
   183  	allErrs := validateNameReference(string(flavorQuotas.Name), path.Child("name"))
   184  	if len(flavorQuotas.Resources) != len(coveredResources) {
   185  		allErrs = append(allErrs, field.Invalid(path.Child("resources"), field.OmitValueType{}, "must have the same number of resources as the coveredResources"))
   186  	}
   187  
   188  	for i, rq := range flavorQuotas.Resources {
   189  		if i >= len(coveredResources) {
   190  			break
   191  		}
   192  		path := path.Child("resources").Index(i)
   193  		if rq.Name != coveredResources[i] {
   194  			allErrs = append(allErrs, field.Invalid(path.Child("name"), rq.Name, "must match the name in coveredResources"))
   195  		}
   196  		allErrs = append(allErrs, validateResourceQuantity(rq.NominalQuota, path.Child("nominalQuota"))...)
   197  		if rq.BorrowingLimit != nil {
   198  			borrowingLimitPath := path.Child("borrowingLimit")
   199  			allErrs = append(allErrs, validateResourceQuantity(*rq.BorrowingLimit, borrowingLimitPath)...)
   200  			allErrs = append(allErrs, validateLimit(*rq.BorrowingLimit, cohort, borrowingLimitPath)...)
   201  		}
   202  		if features.Enabled(features.LendingLimit) && rq.LendingLimit != nil {
   203  			lendingLimitPath := path.Child("lendingLimit")
   204  			allErrs = append(allErrs, validateResourceQuantity(*rq.LendingLimit, lendingLimitPath)...)
   205  			allErrs = append(allErrs, validateLimit(*rq.LendingLimit, cohort, lendingLimitPath)...)
   206  			allErrs = append(allErrs, validateLendingLimit(*rq.LendingLimit, rq.NominalQuota, lendingLimitPath)...)
   207  		}
   208  	}
   209  	return allErrs
   210  }
   211  
   212  // validateResourceQuantity enforces that specified quantity is valid for specified resource
   213  func validateResourceQuantity(value resource.Quantity, fldPath *field.Path) field.ErrorList {
   214  	var allErrs field.ErrorList
   215  	if value.Cmp(resource.Quantity{}) < 0 {
   216  		allErrs = append(allErrs, field.Invalid(fldPath, value.String(), constants.IsNegativeErrorMsg))
   217  	}
   218  	return allErrs
   219  }
   220  
   221  // validateLimit enforces that BorrowingLimit or LendingLimit must be nil when cohort is empty
   222  func validateLimit(limit resource.Quantity, cohort string, fldPath *field.Path) field.ErrorList {
   223  	var allErrs field.ErrorList
   224  	if len(cohort) == 0 {
   225  		allErrs = append(allErrs, field.Invalid(fldPath, limit.String(), limitIsEmptyErrorMsg))
   226  	}
   227  	return allErrs
   228  }
   229  
   230  // validateLendingLimit enforces that LendingLimit is not greater than NominalQuota
   231  func validateLendingLimit(lend, nominal resource.Quantity, fldPath *field.Path) field.ErrorList {
   232  	var allErrs field.ErrorList
   233  	if lend.Cmp(nominal) > 0 {
   234  		allErrs = append(allErrs, field.Invalid(fldPath, lend.String(), lendingLimitErrorMsg))
   235  	}
   236  	return allErrs
   237  }