diff --git a/charts/kueue/templates/webhook/webhook.yaml b/charts/kueue/templates/webhook/webhook.yaml index dcae76f14e..40e088f135 100644 --- a/charts/kueue/templates/webhook/webhook.yaml +++ b/charts/kueue/templates/webhook/webhook.yaml @@ -599,6 +599,26 @@ webhooks: resources: - clusterqueues sideEffects: None + - admissionReviewVersions: + - v1 + clientConfig: + service: + name: '{{ include "kueue.fullname" . }}-webhook-service' + namespace: '{{ .Release.Namespace }}' + path: /validate-kueue-x-k8s-io-v1alpha1-cohort + failurePolicy: Fail + name: vcohort.kb.io + rules: + - apiGroups: + - kueue.x-k8s.io + apiVersions: + - v1alpha1 + operations: + - CREATE + - UPDATE + resources: + - cohorts + sideEffects: None - admissionReviewVersions: - v1 clientConfig: diff --git a/config/components/webhook/manifests.yaml b/config/components/webhook/manifests.yaml index 37ec2afb5e..1b8c0cdd0d 100644 --- a/config/components/webhook/manifests.yaml +++ b/config/components/webhook/manifests.yaml @@ -555,6 +555,26 @@ webhooks: resources: - clusterqueues sideEffects: None +- admissionReviewVersions: + - v1 + clientConfig: + service: + name: webhook-service + namespace: system + path: /validate-kueue-x-k8s-io-v1alpha1-cohort + failurePolicy: Fail + name: vcohort.kb.io + rules: + - apiGroups: + - kueue.x-k8s.io + apiVersions: + - v1alpha1 + operations: + - CREATE + - UPDATE + resources: + - cohorts + sideEffects: None - admissionReviewVersions: - v1 clientConfig: diff --git a/pkg/util/testing/wrappers.go b/pkg/util/testing/wrappers.go index 74c7eb0e67..6213b38976 100644 --- a/pkg/util/testing/wrappers.go +++ b/pkg/util/testing/wrappers.go @@ -600,6 +600,11 @@ func (c *CohortWrapper) Obj() *kueuealpha.Cohort { return &c.Cohort } +func (c *CohortWrapper) Parent(parentName string) *CohortWrapper { + c.Cohort.Spec.Parent = parentName + return c +} + // ResourceGroup adds a ResourceGroup with flavors. func (c *CohortWrapper) ResourceGroup(flavors ...kueue.FlavorQuotas) *CohortWrapper { c.Spec.ResourceGroups = append(c.Spec.ResourceGroups, createResourceGroup(flavors...)) diff --git a/pkg/webhooks/clusterqueue_webhook.go b/pkg/webhooks/clusterqueue_webhook.go index ae6474b995..e3c5f21f69 100644 --- a/pkg/webhooks/clusterqueue_webhook.go +++ b/pkg/webhooks/clusterqueue_webhook.go @@ -82,11 +82,10 @@ func (w *ClusterQueueWebhook) ValidateCreate(ctx context.Context, obj runtime.Ob // ValidateUpdate implements webhook.CustomValidator so a webhook will be registered for the type func (w *ClusterQueueWebhook) ValidateUpdate(ctx context.Context, oldObj, newObj runtime.Object) (admission.Warnings, error) { newCQ := newObj.(*kueue.ClusterQueue) - oldCQ := oldObj.(*kueue.ClusterQueue) log := ctrl.LoggerFrom(ctx).WithName("clusterqueue-webhook") log.V(5).Info("Validating update", "clusterQueue", klog.KObj(newCQ)) - allErrs := ValidateClusterQueueUpdate(newCQ, oldCQ) + allErrs := ValidateClusterQueueUpdate(newCQ) return nil, allErrs.ToAggregate() } @@ -99,7 +98,11 @@ func ValidateClusterQueue(cq *kueue.ClusterQueue) field.ErrorList { path := field.NewPath("spec") var allErrs field.ErrorList - allErrs = append(allErrs, validateResourceGroups(cq.Spec.ResourceGroups, cq.Spec.Cohort, path.Child("resourceGroups"))...) + config := validationConfig{ + hasParent: cq.Spec.Cohort != "", + enforceNominalGreaterThanLending: true, + } + allErrs = append(allErrs, validateResourceGroups(cq.Spec.ResourceGroups, config, path.Child("resourceGroups"))...) allErrs = append(allErrs, validation.ValidateLabelSelector(cq.Spec.NamespaceSelector, validation.LabelSelectorValidationOptions{}, path.Child("namespaceSelector"))...) allErrs = append(allErrs, validateCQAdmissionChecks(&cq.Spec, path)...) @@ -112,10 +115,8 @@ func ValidateClusterQueue(cq *kueue.ClusterQueue) field.ErrorList { return allErrs } -func ValidateClusterQueueUpdate(newObj, _ *kueue.ClusterQueue) field.ErrorList { - var allErrs field.ErrorList - allErrs = append(allErrs, ValidateClusterQueue(newObj)...) - return allErrs +func ValidateClusterQueueUpdate(newObj *kueue.ClusterQueue) field.ErrorList { + return ValidateClusterQueue(newObj) } func validatePreemption(preemption *kueue.ClusterQueuePreemption, path *field.Path) field.ErrorList { @@ -137,7 +138,7 @@ func validateCQAdmissionChecks(spec *kueue.ClusterQueueSpec, path *field.Path) f return allErrs } -func validateResourceGroups(resourceGroups []kueue.ResourceGroup, cohort string, path *field.Path) field.ErrorList { +func validateResourceGroups(resourceGroups []kueue.ResourceGroup, config validationConfig, path *field.Path) field.ErrorList { var allErrs field.ErrorList seenResources := sets.New[corev1.ResourceName]() seenFlavors := sets.New[kueue.ResourceFlavorReference]() @@ -155,7 +156,7 @@ func validateResourceGroups(resourceGroups []kueue.ResourceGroup, cohort string, } for j, fqs := range rg.Flavors { path := path.Child("flavors").Index(j) - allErrs = append(allErrs, validateFlavorQuotas(fqs, rg.CoveredResources, cohort, path)...) + allErrs = append(allErrs, validateFlavorQuotas(fqs, rg.CoveredResources, config, path)...) if seenFlavors.Has(fqs.Name) { allErrs = append(allErrs, field.Duplicate(path.Child("name"), fqs.Name)) } else { @@ -166,7 +167,7 @@ func validateResourceGroups(resourceGroups []kueue.ResourceGroup, cohort string, return allErrs } -func validateFlavorQuotas(flavorQuotas kueue.FlavorQuotas, coveredResources []corev1.ResourceName, cohort string, path *field.Path) field.ErrorList { +func validateFlavorQuotas(flavorQuotas kueue.FlavorQuotas, coveredResources []corev1.ResourceName, config validationConfig, path *field.Path) field.ErrorList { var allErrs field.ErrorList for i, rq := range flavorQuotas.Resources { @@ -180,13 +181,14 @@ func validateFlavorQuotas(flavorQuotas kueue.FlavorQuotas, coveredResources []co allErrs = append(allErrs, validateResourceQuantity(rq.NominalQuota, path.Child("nominalQuota"))...) if rq.BorrowingLimit != nil { borrowingLimitPath := path.Child("borrowingLimit") + allErrs = append(allErrs, validateLimit(*rq.BorrowingLimit, config, borrowingLimitPath)...) allErrs = append(allErrs, validateResourceQuantity(*rq.BorrowingLimit, borrowingLimitPath)...) } if features.Enabled(features.LendingLimit) && rq.LendingLimit != nil { lendingLimitPath := path.Child("lendingLimit") allErrs = append(allErrs, validateResourceQuantity(*rq.LendingLimit, lendingLimitPath)...) - allErrs = append(allErrs, validateLimit(*rq.LendingLimit, cohort, lendingLimitPath)...) - allErrs = append(allErrs, validateLendingLimit(*rq.LendingLimit, rq.NominalQuota, lendingLimitPath)...) + allErrs = append(allErrs, validateLimit(*rq.LendingLimit, config, lendingLimitPath)...) + allErrs = append(allErrs, validateLendingLimit(*rq.LendingLimit, rq.NominalQuota, config, lendingLimitPath)...) } } return allErrs @@ -202,18 +204,18 @@ func validateResourceQuantity(value resource.Quantity, fldPath *field.Path) fiel } // validateLimit enforces that BorrowingLimit or LendingLimit must be nil when cohort is empty -func validateLimit(limit resource.Quantity, cohort string, fldPath *field.Path) field.ErrorList { +func validateLimit(limit resource.Quantity, config validationConfig, fldPath *field.Path) field.ErrorList { var allErrs field.ErrorList - if len(cohort) == 0 { + if !config.hasParent { allErrs = append(allErrs, field.Invalid(fldPath, limit.String(), limitIsEmptyErrorMsg)) } return allErrs } // validateLendingLimit enforces that LendingLimit is not greater than NominalQuota -func validateLendingLimit(lend, nominal resource.Quantity, fldPath *field.Path) field.ErrorList { +func validateLendingLimit(lend, nominal resource.Quantity, config validationConfig, fldPath *field.Path) field.ErrorList { var allErrs field.ErrorList - if lend.Cmp(nominal) > 0 { + if config.enforceNominalGreaterThanLending && lend.Cmp(nominal) > 0 { allErrs = append(allErrs, field.Invalid(fldPath, lend.String(), lendingLimitErrorMsg)) } return allErrs diff --git a/pkg/webhooks/clusterqueue_webhook_test.go b/pkg/webhooks/clusterqueue_webhook_test.go index 19732b08cc..f2cefdfeaf 100644 --- a/pkg/webhooks/clusterqueue_webhook_test.go +++ b/pkg/webhooks/clusterqueue_webhook_test.go @@ -361,7 +361,7 @@ func TestValidateClusterQueueUpdate(t *testing.T) { for _, tc := range testcases { t.Run(tc.name, func(t *testing.T) { - gotErr := ValidateClusterQueueUpdate(tc.newClusterQueue, tc.oldClusterQueue) + gotErr := ValidateClusterQueueUpdate(tc.newClusterQueue) if diff := cmp.Diff(tc.wantErr, gotErr, cmpopts.IgnoreFields(field.Error{}, "Detail", "BadValue")); diff != "" { t.Errorf("ValidateResources() mismatch (-want +got):\n%s", diff) } diff --git a/pkg/webhooks/cohort_webhook.go b/pkg/webhooks/cohort_webhook.go new file mode 100644 index 0000000000..5146eb398f --- /dev/null +++ b/pkg/webhooks/cohort_webhook.go @@ -0,0 +1,77 @@ +/* +Copyright 2024 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package webhooks + +import ( + "context" + + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/validation/field" + "k8s.io/klog/v2" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/webhook" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + kueuealpha "sigs.k8s.io/kueue/apis/kueue/v1alpha1" +) + +type CohortWebhook struct{} + +func setupWebhookForCohort(mgr ctrl.Manager) error { + return ctrl.NewWebhookManagedBy(mgr). + For(&kueuealpha.Cohort{}). + WithValidator(&CohortWebhook{}). + Complete() +} + +func (w *CohortWebhook) Default(ctx context.Context, obj runtime.Object) error { + return nil +} + +//+kubebuilder:webhook:path=/validate-kueue-x-k8s-io-v1alpha1-cohort,mutating=false,failurePolicy=fail,sideEffects=None,groups=kueue.x-k8s.io,resources=cohorts,verbs=create;update,versions=v1alpha1,name=vcohort.kb.io,admissionReviewVersions=v1 + +var _ webhook.CustomValidator = &CohortWebhook{} + +// ValidateCreate implements webhook.CustomValidator so a webhook will be registered for the type +func (w *CohortWebhook) ValidateCreate(ctx context.Context, obj runtime.Object) (admission.Warnings, error) { + cohort := obj.(*kueuealpha.Cohort) + log := ctrl.LoggerFrom(ctx).WithName("cohort-webhook") + log.V(5).Info("Validating Cohort create", "cohort", klog.KObj(cohort)) + return nil, validateCohort(cohort).ToAggregate() +} + +// ValidateUpdate implements webhook.CustomValidator so a webhook will be registered for the type +func (w *CohortWebhook) ValidateUpdate(ctx context.Context, oldObj, newObj runtime.Object) (admission.Warnings, error) { + cohort := newObj.(*kueuealpha.Cohort) + log := ctrl.LoggerFrom(ctx).WithName("cohort-webhook") + log.V(5).Info("Validating Cohort update", "cohort", klog.KObj(cohort)) + return nil, validateCohort(cohort).ToAggregate() +} + +// ValidateDelete implements webhook.CustomValidator so a webhook will be registered for the type +func (w *CohortWebhook) ValidateDelete(_ context.Context, _ runtime.Object) (admission.Warnings, error) { + return nil, nil +} + +func validateCohort(cohort *kueuealpha.Cohort) field.ErrorList { + path := field.NewPath("spec") + config := validationConfig{ + hasParent: cohort.Spec.Parent != "", + enforceNominalGreaterThanLending: false, + } + return validateResourceGroups(cohort.Spec.ResourceGroups, config, path.Child("resourceGroups")) +} diff --git a/pkg/webhooks/common.go b/pkg/webhooks/common.go index 703cb3fe75..dcea7b45f2 100644 --- a/pkg/webhooks/common.go +++ b/pkg/webhooks/common.go @@ -29,3 +29,8 @@ func validateResourceName(name corev1.ResourceName, fldPath *field.Path) field.E } return allErrs } + +type validationConfig struct { + hasParent bool + enforceNominalGreaterThanLending bool +} diff --git a/pkg/webhooks/webhooks.go b/pkg/webhooks/webhooks.go index 5e172c573b..6ac550dd24 100644 --- a/pkg/webhooks/webhooks.go +++ b/pkg/webhooks/webhooks.go @@ -16,7 +16,9 @@ limitations under the License. package webhooks -import ctrl "sigs.k8s.io/controller-runtime" +import ( + ctrl "sigs.k8s.io/controller-runtime" +) // Setup sets up the webhooks for core controllers. It returns the name of the // webhook that failed to create and an error, if any. @@ -33,5 +35,9 @@ func Setup(mgr ctrl.Manager) (string, error) { return "ClusterQueue", err } + if err := setupWebhookForCohort(mgr); err != nil { + return "Cohort", err + } + return "", nil } diff --git a/test/integration/webhook/cohort_test.go b/test/integration/webhook/cohort_test.go new file mode 100644 index 0000000000..de69cbd4fd --- /dev/null +++ b/test/integration/webhook/cohort_test.go @@ -0,0 +1,362 @@ +/* +Copyright 2024 The Kubernetes Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package webhook + +import ( + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + kueuealpha "sigs.k8s.io/kueue/apis/kueue/v1alpha1" + kueue "sigs.k8s.io/kueue/apis/kueue/v1beta1" + "sigs.k8s.io/kueue/pkg/util/testing" + "sigs.k8s.io/kueue/test/util" +) + +var _ = ginkgo.Describe("Cohort Webhook", func() { + ginkgo.When("Creating a Cohort", func() { + ginkgo.DescribeTable("Validate Cohort on creation", func(cohort *kueuealpha.Cohort, errorType int) { + err := k8sClient.Create(ctx, cohort) + if err == nil { + defer func() { + util.ExpectObjectToBeDeleted(ctx, k8sClient, cohort, true) + }() + } + switch errorType { + case isForbidden: + gomega.Expect(err).Should(gomega.HaveOccurred()) + gomega.Expect(errors.IsForbidden(err)).Should(gomega.BeTrue(), "error: %v", err) + case isInvalid: + gomega.Expect(err).Should(gomega.HaveOccurred()) + gomega.Expect(err).Should(testing.BeAPIError(testing.InvalidError), "error: %v", err) + default: + gomega.Expect(err).ShouldNot(gomega.HaveOccurred()) + } + }, + ginkgo.Entry("Should disallow empty name", + testing.MakeCohort("").Obj(), + isInvalid), + ginkgo.Entry("Should allow default Cohort", + testing.MakeCohort("cohort").Obj(), + isValid), + ginkgo.Entry("Should allow valid parent name", + testing.MakeCohort("cohort").Parent("prod").Obj(), + isValid), + ginkgo.Entry("Should reject invalid parent name", + testing.MakeCohort("cohort").Parent("@prod").Obj(), + isInvalid), + ginkgo.Entry("ResourceGroup should have at least one flavor", + testing.MakeCohort("cohort").ResourceGroup().Obj(), + isInvalid), + ginkgo.Entry("FlavorQuota should have at least one resource", + testing.MakeCohort("cohort"). + ResourceGroup(*testing.MakeFlavorQuotas("foo").Obj()). + Obj(), + isInvalid), + ginkgo.Entry("Should reject invalid flavor name", + testing.MakeCohort("cohort"). + ResourceGroup(*testing.MakeFlavorQuotas("@x86").Resource("cpu", "5").Obj()). + Obj(), + isInvalid), + ginkgo.Entry("Should allow valid resource name", + testing.MakeCohort("cohort"). + ResourceGroup(*testing.MakeFlavorQuotas("x86").Resource("@cpu", "5").Obj()). + Obj(), + isForbidden), + ginkgo.Entry("Should reject too many flavors in resource group", + testing.MakeCohort("cohort").ResourceGroup( + testing.MakeFlavorQuotas("f0").Resource("cpu").FlavorQuotas, + testing.MakeFlavorQuotas("f1").Resource("cpu").FlavorQuotas, + testing.MakeFlavorQuotas("f2").Resource("cpu").FlavorQuotas, + testing.MakeFlavorQuotas("f3").Resource("cpu").FlavorQuotas, + testing.MakeFlavorQuotas("f4").Resource("cpu").FlavorQuotas, + testing.MakeFlavorQuotas("f5").Resource("cpu").FlavorQuotas, + testing.MakeFlavorQuotas("f6").Resource("cpu").FlavorQuotas, + testing.MakeFlavorQuotas("f7").Resource("cpu").FlavorQuotas, + testing.MakeFlavorQuotas("f8").Resource("cpu").FlavorQuotas, + testing.MakeFlavorQuotas("f9").Resource("cpu").FlavorQuotas, + testing.MakeFlavorQuotas("f10").Resource("cpu").FlavorQuotas, + testing.MakeFlavorQuotas("f11").Resource("cpu").FlavorQuotas, + testing.MakeFlavorQuotas("f12").Resource("cpu").FlavorQuotas, + testing.MakeFlavorQuotas("f13").Resource("cpu").FlavorQuotas, + testing.MakeFlavorQuotas("f14").Resource("cpu").FlavorQuotas, + testing.MakeFlavorQuotas("f15").Resource("cpu").FlavorQuotas, + testing.MakeFlavorQuotas("f16").Resource("cpu").FlavorQuotas).Obj(), + isInvalid), + ginkgo.Entry("Should reject too many resources in resource group", + testing.MakeCohort("cohort").ResourceGroup( + testing.MakeFlavorQuotas("flavor"). + Resource("cpu0"). + Resource("cpu1"). + Resource("cpu2"). + Resource("cpu3"). + Resource("cpu4"). + Resource("cpu5"). + Resource("cpu6"). + Resource("cpu7"). + Resource("cpu8"). + Resource("cpu9"). + Resource("cpu10"). + Resource("cpu11"). + Resource("cpu12"). + Resource("cpu13"). + Resource("cpu14"). + Resource("cpu15"). + Resource("cpu16").FlavorQuotas).Obj(), + isInvalid), + ginkgo.Entry("Should allow resource with valid name", + testing.MakeCohort("cohort"). + ResourceGroup(*testing.MakeFlavorQuotas("default").Resource("cpu").Obj()). + Obj(), + isValid), + ginkgo.Entry("Should reject resource with invalid name", + testing.MakeCohort("cohort"). + ResourceGroup(*testing.MakeFlavorQuotas("default").Resource("@cpu").Obj()). + Obj(), + isForbidden), + ginkgo.Entry("Should allow extended resources with valid name", + testing.MakeCohort("cohort"). + ResourceGroup(*testing.MakeFlavorQuotas("default").Resource("example.com/gpu").Obj()). + Obj(), + isValid), + ginkgo.Entry("Should allow flavor with valid name", + testing.MakeCohort("cohort"). + ResourceGroup(*testing.MakeFlavorQuotas("x86").Resource("cpu").Obj()). + Obj(), + isValid), + ginkgo.Entry("Should reject flavor with invalid name", + testing.MakeCohort("cohort"). + ResourceGroup(*testing.MakeFlavorQuotas("x_86").Resource("cpu").Obj()). + Obj(), + isInvalid), + ginkgo.Entry("Should reject negative nominal quota", + testing.MakeCohort("cohort"). + ResourceGroup(*testing.MakeFlavorQuotas("x86").Resource("cpu", "-1").Obj()). + Obj(), + isForbidden), + ginkgo.Entry("Should reject negative borrowing limit", + testing.MakeCohort("cohort"). + ResourceGroup(*testing.MakeFlavorQuotas("x86").Resource("cpu", "1", "-1").Obj()). + Obj(), + isForbidden), + ginkgo.Entry("Should reject negative lending limit", + testing.MakeCohort("cohort"). + ResourceGroup(*testing.MakeFlavorQuotas("x86").Resource("cpu", "1", "", "-1").Obj()). + Obj(), + isForbidden), + ginkgo.Entry("Should reject borrowingLimit when no parent", + testing.MakeCohort("cohort"). + ResourceGroup( + *testing.MakeFlavorQuotas("x86").Resource("cpu", "1", "1").Obj()). + Obj(), + isForbidden), + ginkgo.Entry("Should allow borrowingLimit 0 when parent exists", + testing.MakeCohort("cohort"). + ResourceGroup( + *testing.MakeFlavorQuotas("x86").Resource("cpu", "1", "0").Obj()). + Parent("parent"). + Obj(), + isValid), + ginkgo.Entry("Should allow borrowingLimit when parent exists", + testing.MakeCohort("cohort"). + ResourceGroup( + *testing.MakeFlavorQuotas("x86").Resource("cpu", "1", "1").Obj()). + Parent("parent"). + Obj(), + isValid), + ginkgo.Entry("Should reject lendingLimit when no parent", + testing.MakeCohort("cohort"). + ResourceGroup( + testing.MakeFlavorQuotas("x86"). + ResourceQuotaWrapper("cpu").NominalQuota("1").LendingLimit("1").Append(). + FlavorQuotas, + ). + Obj(), + isForbidden), + ginkgo.Entry("Should allow lendingLimit when parent exists", + testing.MakeCohort("cohort"). + ResourceGroup( + testing.MakeFlavorQuotas("x86"). + ResourceQuotaWrapper("cpu").NominalQuota("1").LendingLimit("1").Append(). + FlavorQuotas, + ). + Parent("parent"). + Obj(), + isValid), + ginkgo.Entry("Should allow lendingLimit 0 when parent exists", + testing.MakeCohort("cohort"). + ResourceGroup( + testing.MakeFlavorQuotas("x86"). + ResourceQuotaWrapper("cpu").NominalQuota("0").LendingLimit("0").Append(). + FlavorQuotas, + ). + Parent("parent"). + Obj(), + isValid), + ginkgo.Entry("Should allow lending limit to exceed nominal quota", + testing.MakeCohort("cohort"). + ResourceGroup( + testing.MakeFlavorQuotas("x86"). + ResourceQuotaWrapper("cpu").NominalQuota("3").LendingLimit("5").Append(). + FlavorQuotas, + ). + Parent("parent"). + Obj(), + isValid), + ginkgo.Entry("Should allow multiple resource groups", + testing.MakeCohort("cohort"). + ResourceGroup( + *testing.MakeFlavorQuotas("alpha"). + Resource("cpu", "0"). + Resource("memory", "0"). + Obj(), + *testing.MakeFlavorQuotas("beta"). + Resource("cpu", "0"). + Resource("memory", "0"). + Obj(), + ). + ResourceGroup( + *testing.MakeFlavorQuotas("gamma"). + Resource("example.com/gpu", "0"). + Obj(), + *testing.MakeFlavorQuotas("omega"). + Resource("example.com/gpu", "0"). + Obj(), + ). + Obj(), + isValid), + ginkgo.Entry("Should reject resources in a flavor in different order", + &kueuealpha.Cohort{ + ObjectMeta: metav1.ObjectMeta{ + Name: "cohort", + }, + Spec: kueuealpha.CohortSpec{ + ResourceGroups: []kueue.ResourceGroup{ + { + CoveredResources: []corev1.ResourceName{"cpu", "memory"}, + Flavors: []kueue.FlavorQuotas{ + *testing.MakeFlavorQuotas("alpha"). + Resource("cpu", "0"). + Resource("memory", "0"). + Obj(), + *testing.MakeFlavorQuotas("beta"). + Resource("memory", "0"). + Resource("cpu", "0"). + Obj(), + }, + }, + }, + }, + }, + isForbidden), + ginkgo.Entry("Should reject missing resources in a flavor", + &kueuealpha.Cohort{ + ObjectMeta: metav1.ObjectMeta{ + Name: "cohort", + }, + Spec: kueuealpha.CohortSpec{ + ResourceGroups: []kueue.ResourceGroup{ + { + CoveredResources: []corev1.ResourceName{"cpu", "memory"}, + Flavors: []kueue.FlavorQuotas{ + *testing.MakeFlavorQuotas("alpha"). + Resource("cpu", "0"). + Obj(), + }, + }, + }, + }, + }, + isInvalid), + ginkgo.Entry("Should reject resource not defined in resource group", + &kueuealpha.Cohort{ + ObjectMeta: metav1.ObjectMeta{ + Name: "cohort", + }, + Spec: kueuealpha.CohortSpec{ + ResourceGroups: []kueue.ResourceGroup{ + { + CoveredResources: []corev1.ResourceName{"cpu"}, + Flavors: []kueue.FlavorQuotas{ + *testing.MakeFlavorQuotas("alpha"). + Resource("cpu", "0"). + Resource("memory", "0"). + Obj(), + }, + }, + }, + }, + }, + isInvalid), + ginkgo.Entry("Should reject resource in more than one resource group", + testing.MakeCohort("cohort"). + ResourceGroup( + *testing.MakeFlavorQuotas("alpha"). + Resource("cpu", "0"). + Resource("memory", "0"). + Obj(), + ). + ResourceGroup( + *testing.MakeFlavorQuotas("beta"). + Resource("memory", "0"). + Obj(), + ). + Obj(), + isForbidden), + ginkgo.Entry("Should reject flavor in more than one resource group", + testing.MakeCohort("cohort"). + ResourceGroup( + *testing.MakeFlavorQuotas("alpha").Resource("cpu").Obj(), + *testing.MakeFlavorQuotas("beta").Resource("cpu").Obj(), + ). + ResourceGroup( + *testing.MakeFlavorQuotas("beta").Resource("memory").Obj(), + ). + Obj(), + isForbidden), + ) + }) + + ginkgo.When("Updating a Cohort", func() { + ginkgo.It("Should update parent", func() { + cohort := testing.MakeCohort("cohort").Obj() + gomega.Expect(k8sClient.Create(ctx, cohort)).Should(gomega.Succeed()) + + updated := cohort.DeepCopy() + updated.Spec.Parent = "cohort2" + + gomega.Expect(k8sClient.Update(ctx, updated)).Should(gomega.Succeed()) + util.ExpectObjectToBeDeleted(ctx, k8sClient, cohort, true) + }) + ginkgo.It("Should reject invalid parent", func() { + cohort := testing.MakeCohort("cohort").Obj() + gomega.Expect(k8sClient.Create(ctx, cohort)).Should(gomega.Succeed()) + + updated := cohort.DeepCopy() + updated.Spec.Parent = "@cohort2" + + gomega.Expect(k8sClient.Update(ctx, updated)).ShouldNot(gomega.Succeed()) + util.ExpectObjectToBeDeleted(ctx, k8sClient, cohort, true) + }) + ginkgo.It("Should reject negative borrowing limit", func() { + cohort := testing.MakeCohort("cohort"). + ResourceGroup(testing.MakeFlavorQuotas("x86").Resource("cpu", "-1").FlavorQuotas).Cohort + + gomega.Expect(k8sClient.Create(ctx, &cohort)).ShouldNot(gomega.Succeed()) + util.ExpectObjectToBeDeleted(ctx, k8sClient, &cohort, true) + }) + }) +})