From c17f8f313e792dc426e560e76108dbb2098cf250 Mon Sep 17 00:00:00 2001 From: seanlaii Date: Sat, 25 Oct 2025 20:25:27 -0400 Subject: [PATCH] [Feature] Add resourcequota plugin for EstimateComponents Signed-off-by: seanlaii --- .../plugins/resourcequota/resourcequota.go | 377 ++++++++-- .../resourcequota/resourcequota_test.go | 708 +++++++++++++++++- 2 files changed, 1008 insertions(+), 77 deletions(-) diff --git a/pkg/estimator/server/framework/plugins/resourcequota/resourcequota.go b/pkg/estimator/server/framework/plugins/resourcequota/resourcequota.go index 0be3f9560651..7a764ac3233f 100644 --- a/pkg/estimator/server/framework/plugins/resourcequota/resourcequota.go +++ b/pkg/estimator/server/framework/plugins/resourcequota/resourcequota.go @@ -24,6 +24,7 @@ import ( "strings" corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/selection" corelisters "k8s.io/client-go/listers/core/v1" @@ -42,31 +43,36 @@ const ( Name = "ResourceQuotaEstimator" resourceRequestsPrefix = "requests." resourceLimitsPrefix = "limits." + + // noQuotaConstraint represents the value when there is no quota constraint. + noQuotaConstraint = math.MaxInt32 ) -// resourceQuotaEstimator is to estimate how many replica allowed by the ResourceQuota constrain for a given pb.ReplicaRequirements -// Kubernetes ResourceQuota object provides constraints that limit aggregate resource consumption per namespace +// resourceQuotaEstimator estimates how many replicas are allowed by the ResourceQuota constraint for a given pb.ReplicaRequirements. +// Kubernetes ResourceQuota object provides constraints that limit aggregate resource consumption per namespace. // It is categorized into 3 types: // 1) compute resource including cpu, memory, hugepages- and extended resource like gpu // 2) storage including pvc, ephemeral-storage etc. // 3) object count including count/services, count/secrets etc. -// ResourceQuota supports 6 scope +// ResourceQuota supports 6 scopes: // 1) Terminating // 2) NotTerminating // 3) BestEffort // 4) NotBestEffort // 5) PriorityClass // 6) CrossNamespacePodAffinity -// For now, we only support ComputeResource (cpu, memory and extended resource) and PriorityClass scope, the reasons are -// 1) pb.ReplicaRequirements only contains requested resources currently, we cannot determine if its QoS (BestEffort or not) -// 2) storage and object count do not support scope selector -// ToDo (@wengyao04): we can extend the resourceQuotaEstimator support if further requirements are needed +// For now, we only support ComputeResource (cpu, memory and extended resource) and PriorityClass scope. The reasons are: +// 1) pb.ReplicaRequirements only contains requested resources currently, we cannot determine its QoS (BestEffort or not) +// 2) Storage and object count quotas do not support scope selector +// 3) Pod quota is not included in this iteration to keep the initial implementation focused on compute resources +// TODO (@wengyao04): we can extend the resourceQuotaEstimator support if further requirements are needed type resourceQuotaEstimator struct { enabled bool rqLister corelisters.ResourceQuotaLister } var _ framework.EstimateReplicasPlugin = &resourceQuotaEstimator{} +var _ framework.EstimateComponentsPlugin = &resourceQuotaEstimator{} // New initializes a new plugin and returns it. func New(fh framework.Handle) (framework.Plugin, error) { @@ -86,11 +92,11 @@ func (pl *resourceQuotaEstimator) Name() string { return Name } -// Estimate the replica allowed by the ResourceQuota +// Estimate estimates the replicas allowed by the ResourceQuota constraints. func (pl *resourceQuotaEstimator) Estimate(_ context.Context, _ *schedcache.Snapshot, replicaRequirements *pb.ReplicaRequirements) (int32, *framework.Result) { - var replica int32 = math.MaxInt32 + var replica int32 = noQuotaConstraint if !pl.enabled { klog.V(5).Info("Estimator Plugin", "name", Name, "enabled", pl.enabled) return replica, framework.NewResult(framework.Noopperation, fmt.Sprintf("%s is disabled", pl.Name())) @@ -100,8 +106,9 @@ func (pl *resourceQuotaEstimator) Estimate(_ context.Context, rqList, err := pl.rqLister.ResourceQuotas(namespace).List(labels.Everything()) if err != nil { - klog.Error(err, "fail to list resource quota", "namespace", namespace) - return replica, framework.AsResult(err) + klog.Error(err, "failed to list resource quota", "namespace", namespace) + // Conservative approach: return 0 replicas on error + return 0, framework.AsResult(err) } for _, rq := range rqList { rqEvaluator := newResourceQuotaEvaluator(rq, priorityClassName) @@ -113,8 +120,9 @@ func (pl *resourceQuotaEstimator) Estimate(_ context.Context, var result *framework.Result switch replica { - case math.MaxInt32: - result = framework.NewResult(framework.Noopperation, fmt.Sprintf("%s has no operation on input replicaRequirements", pl.Name())) + case noQuotaConstraint: + // No quota constraints found - resources are schedulable without restrictions + result = framework.NewResult(framework.Success, fmt.Sprintf("%s found no quota constraints", pl.Name())) case 0: result = framework.NewResult(framework.Unschedulable, fmt.Sprintf("zero replica is estimated by %s", pl.Name())) default: @@ -123,77 +131,327 @@ func (pl *resourceQuotaEstimator) Estimate(_ context.Context, return replica, result } -type resourceQuotaEvaluator struct { - //key (string) is the name of the resource quota - //value (ResourceList) is the free resources calculated by (hard - used) from the resource quota status - resourceRequest map[string]corev1.ResourceList +// EstimateComponents estimates the maximum component sets allowed by ResourceQuota constraints. +// For each ResourceQuota in the namespace, it filters the components that match the quota's scope +// selectors (e.g., priorityClassName), aggregates their resource requirements, and calculates how +// many complete component sets can fit within the quota. The function returns the minimum allowed +// sets across all ResourceQuotas to ensure all quota constraints are satisfied. +func (pl *resourceQuotaEstimator) EstimateComponents(_ context.Context, + _ *schedcache.Snapshot, + components []pb.Component) (int32, *framework.Result) { + if !pl.enabled { + klog.V(5).Info("Estimator Plugin", "name", Name, "enabled", pl.enabled) + return noQuotaConstraint, framework.NewResult(framework.Noopperation, fmt.Sprintf("%s is disabled", pl.Name())) + } + + if len(components) == 0 { + klog.V(5).Infof("%s: received empty components list", pl.Name()) + return noQuotaConstraint, framework.NewResult(framework.Noopperation, fmt.Sprintf("%s received empty components list", pl.Name())) + } + + namespace := components[0].ReplicaRequirements.Namespace + + rqList, err := pl.rqLister.ResourceQuotas(namespace).List(labels.Everything()) + if err != nil { + klog.Error(err, "failed to list resource quota", "namespace", namespace) + // Conservative approach: return 0 component sets on error + return 0, framework.AsResult(err) + } + + if len(rqList) == 0 { + klog.V(5).Infof("%s: no ResourceQuota found in namespace %s", pl.Name(), namespace) + // No quotas exist - resources are schedulable without restrictions + return noQuotaConstraint, framework.NewResult(framework.Success, fmt.Sprintf("%s found no quota constraints", pl.Name())) + } + + // Evaluate all components together against each ResourceQuota. + // Each ResourceQuota will filter components based on its scope selectors. + var maxSets int32 = noQuotaConstraint + for _, rq := range rqList { + setsFromRq := pl.evaluateComponentsAgainstQuota(rq, components) + + klog.V(5).Infof("%s: ResourceQuota %s/%s allows %d component sets", + pl.Name(), rq.Namespace, rq.Name, setsFromRq) + + if setsFromRq < maxSets { + maxSets = setsFromRq + } + + // Early exit if any quota allows zero sets. + if maxSets == 0 { + break + } + } + + // Determine the result based on the final maxSets value. + var result *framework.Result + switch maxSets { + case noQuotaConstraint: + // No quota constraints found - resources are schedulable without restrictions + result = framework.NewResult(framework.Success, fmt.Sprintf("%s found no quota constraints", pl.Name())) + case 0: + result = framework.NewResult(framework.Unschedulable, fmt.Sprintf("zero component sets estimated by %s", pl.Name())) + default: + result = framework.NewResult(framework.Success) + } + + klog.V(5).Infof("%s: final estimation result: %d component sets, status: %s", + pl.Name(), maxSets, result.Code()) + + return maxSets, result } -func newResourceQuotaEvaluator(rq *corev1.ResourceQuota, priorityClassName string) *resourceQuotaEvaluator { +// evaluateComponentsAgainstQuota evaluates all components against a single ResourceQuota. +// +// Steps: +// 1. Filter components that match this quota's scope selectors +// 2. If no components match → quota doesn't constrain workload → return noQuotaConstraint +// 3. Aggregate resource requirements for one complete component set +// 4. Calculate: sets = quota.available / aggregated_requirements +func (pl *resourceQuotaEstimator) evaluateComponentsAgainstQuota(rq *corev1.ResourceQuota, + components []pb.Component) int32 { selectors := getScopeSelectorsFromQuota(rq) - resources := make(map[string]corev1.ResourceList) + var matchedComponents []pb.Component + + if len(selectors) == 0 { + matchedComponents = components + } else { + for _, component := range components { + if quotaAppliesToPriority(selectors, component.ReplicaRequirements.PriorityClassName) { + matchedComponents = append(matchedComponents, component) + } + } + } + + if len(matchedComponents) == 0 { + return noQuotaConstraint + } + availableResources := calculateFreeResources(rq, matchingResources(resourceNames(rq.Status.Hard))) + if len(availableResources) == 0 { + return noQuotaConstraint + } + + perSetRequirements := pl.aggregateComponentRequirements(matchedComponents) + return pl.evaluateResourcesAgainstQuota(availableResources, perSetRequirements) +} + +func quotaAppliesToPriority(selectors []corev1.ScopedResourceSelectorRequirement, priorityClassName string) bool { for _, selector := range selectors { matchScope, err := matchesScope(selector, priorityClassName) if err != nil { klog.Error(err, "matchesScope failed") continue } - if !matchScope { + if matchScope { + return true + } + } + return false +} + +// aggregateComponentRequirements computes the total resource requirements for one complete +// component set by summing up each component's per-replica requirements multiplied by its replica count. +func (pl *resourceQuotaEstimator) aggregateComponentRequirements(components []pb.Component) corev1.ResourceList { + resourceRequirements := map[corev1.ResourceName]int64{} + + for _, component := range components { + if component.ReplicaRequirements.ResourceRequest == nil { continue } + + replicas := int64(component.Replicas) + for resourceName, perReplicaQuantity := range component.ReplicaRequirements.ResourceRequest { + // Only process supported compute resources (CPU, Memory) and extended resources (GPU, etc.) + if !isSupportedResource(resourceName) { + continue + } + + // CPU uses MilliValue, all others use Value + var perReplicaAmount int64 + if resourceName == corev1.ResourceCPU { + perReplicaAmount = perReplicaQuantity.MilliValue() + } else { + perReplicaAmount = perReplicaQuantity.Value() + } + resourceRequirements[resourceName] += perReplicaAmount * replicas + } + } + + return convertToResourceList(resourceRequirements) +} + +// evaluateResourcesAgainstQuota calculates how many complete sets of the required resources +// can fit within the available quota resources using resource-agnostic division. +func (pl *resourceQuotaEstimator) evaluateResourcesAgainstQuota( + availableResources corev1.ResourceList, + perSetRequirements corev1.ResourceList) int32 { + // Filter to only include resources constrained by this quota + filtered := filterConstrainedResources(availableResources, perSetRequirements) + + if len(filtered) == 0 { + return noQuotaConstraint + } + + // Create a Resource object with available quota + availableResource := util.NewResource(availableResources) + availableResource.AllowedPodNumber = math.MaxInt64 // Pod quota not supported yet + + // Calculate how many sets can fit using resource-agnostic division + allowed := availableResource.MaxDivided(filtered) + + // Handle integer overflow: treat very large numbers as no constraint + if allowed > math.MaxInt32 { + return noQuotaConstraint + } + + return int32(allowed) // #nosec G115: integer overflow conversion int64 -> int32 +} + +type resourceQuotaEvaluator struct { + // key (string) is the name of the resource quota + // value (ResourceList) is the free resources calculated by (hard - used) from the resource quota status + resourceRequest map[string]corev1.ResourceList +} + +func newResourceQuotaEvaluator(rq *corev1.ResourceQuota, priorityClassName string) *resourceQuotaEvaluator { + selectors := getScopeSelectorsFromQuota(rq) + resources := make(map[string]corev1.ResourceList) + + // Determine if this quota applies based on scope selectors + quotaApplies := false + if len(selectors) == 0 { + // If there are no scope selectors, the quota applies to all pods + quotaApplies = true + } else { + quotaApplies = quotaAppliesToPriority(selectors, priorityClassName) + } + + // If the quota applies, extract the free resources + if quotaApplies { matchResource := matchingResources(resourceNames(rq.Status.Hard)) if len(matchResource) != 0 { - resource := calculateFreeResources(rq, matchResource) - resources[rq.Name] = resource - break + freeResource := calculateFreeResources(rq, matchResource) + resources[rq.Name] = freeResource } } + return &resourceQuotaEvaluator{ resourceRequest: resources, } } +// evaluate evaluates resource requirements against all applicable ResourceQuotas +// and returns the most restrictive constraint (minimum allowed replicas). func (e *resourceQuotaEvaluator) evaluate(replicaRequirements *pb.ReplicaRequirements) int32 { - var result int32 = math.MaxInt32 - for _, resourceList := range e.resourceRequest { - filteredRequiredResourceList := corev1.ResourceList{} - // If the resource in pb.ReplicaRequirements is not the ResourceQuota ResourceList, we skip it. - for resourceName, request := range replicaRequirements.ResourceRequest { - if _, ok := resourceList[resourceName]; ok { - filteredRequiredResourceList[resourceName] = request - } + var result int32 = noQuotaConstraint + + for _, availableResources := range e.resourceRequest { + // Filter to only include resources that are constrained by this ResourceQuota + filteredRequirements := filterConstrainedResources(availableResources, replicaRequirements.ResourceRequest) + + // If no resources are constrained by this quota, skip it + if len(filteredRequirements) == 0 { + continue } - resource := util.NewResource(resourceList) - resource.AllowedPodNumber = math.MaxInt64 - allowed := resource.MaxDivided(filteredRequiredResourceList) - // continue the loop to avoid integer overflow + + // Create a Resource object with available quota + availableResource := util.NewResource(availableResources) + + // Pod quota is not supported in the current implementation. + // To add pod quota support in the future: + // 1. Include pod count in aggregateComponentRequirements() + // 2. Remove this line to let AllowedPodNumber be calculated from availableResources + // 3. Add test cases for pod quota constraints + availableResource.AllowedPodNumber = math.MaxInt64 + + // Calculate how many replicas/sets can fit within the quota + allowed := availableResource.MaxDivided(filteredRequirements) + + // Handle integer overflow: treat very large numbers as no constraint for this quota if allowed > math.MaxInt32 { continue } - replica := int32(allowed) // #nosec G115: integer overflow conversion int64 -> int32 - if replica < result { - result = replica + // Take the minimum across all ResourceQuotas (most restrictive) + count := int32(allowed) // #nosec G115: integer overflow conversion int64 -> int32 + if count < result { + result = count + } + } + + return result +} + +// filterConstrainedResources returns only the resources from requirements that are actually +// constrained by the given ResourceQuota (i.e., present in availableResources). +func filterConstrainedResources( + availableResources corev1.ResourceList, + requirements corev1.ResourceList) corev1.ResourceList { + filtered := corev1.ResourceList{} + for resourceName, requirement := range requirements { + if _, ok := availableResources[resourceName]; ok { + filtered[resourceName] = requirement + } + } + return filtered +} + +// isSupportedResource checks if a resource is supported for quota evaluation. +// This function is called on resource names from component.ReplicaRequirements.ResourceRequest, +// which are expected to be unprefixed (e.g., "cpu", "memory", not "requests.cpu"). +// Supported resources include: +// - CPU and Memory (unprefixed only) +// - Extended resources (GPU, custom devices, etc.) +// Unsupported resources (storage, object counts) are filtered out. +func isSupportedResource(resourceName corev1.ResourceName) bool { + // Supported standard compute resources (unprefixed only). + if resourceName == corev1.ResourceCPU || resourceName == corev1.ResourceMemory { + return true + } + + // Check if it's an extended resource (e.g., nvidia.com/gpu). + if corev1helper.IsExtendedResourceName(resourceName) { + return true + } + + return false +} + +// convertToResourceList converts a map of int64 values to a ResourceList with appropriate formats. +// Only handles supported compute resources: CPU (milli-units), Memory (bytes), and Extended Resources (GPU, etc.). +func convertToResourceList(resourceRequirements map[corev1.ResourceName]int64) corev1.ResourceList { + result := corev1.ResourceList{} + for resourceName, totalAmount := range resourceRequirements { + switch resourceName { + case corev1.ResourceCPU: + // CPU is stored in millicores, use NewMilliQuantity + result[resourceName] = *resource.NewMilliQuantity(totalAmount, resource.DecimalSI) + case corev1.ResourceMemory: + // Memory uses binary units (Ki, Mi, Gi) + result[resourceName] = *resource.NewQuantity(totalAmount, resource.BinarySI) + default: + // All other supported resources are extended resources (GPU, etc.) using decimal format + result[resourceName] = *resource.NewQuantity(totalAmount, resource.DecimalSI) } } return result } -// calculateFreeResources calculates the free resources from input resource quota -// it only calculates the free resources that in resourceNames +// calculateFreeResources calculates the free resources from the input ResourceQuota. +// It only calculates the free resources that are present in resourceNames. func calculateFreeResources(rq *corev1.ResourceQuota, resourceNames []corev1.ResourceName) corev1.ResourceList { hardResourceList := corev1.ResourceList{} usedResourceList := corev1.ResourceList{} for _, resourceName := range resourceNames { rNameStr := string(resourceName) - //skip limits because pb.ReplicaRequirements only support requested resource + // skip limits because pb.ReplicaRequirements only supports requested resources if strings.HasPrefix(rNameStr, resourceLimitsPrefix) { continue } - //requests.cpu is same as cpu - //requests.memory is same as memory - //we merge them together + // requests.cpu is same as cpu + // requests.memory is same as memory + // we merge them together trimmedResourceName := corev1.ResourceName(strings.TrimPrefix(rNameStr, resourceRequestsPrefix)) hardResource, hardResourceOk := rq.Status.Hard[resourceName] usedResource, usedResourceOk := rq.Status.Used[resourceName] @@ -236,8 +494,8 @@ func getScopeSelectorsFromQuota(quota *corev1.ResourceQuota) []corev1.ScopedReso return selectors } -// matchesScope is a function that knows how to evaluate if a pod matches a scope -// we only support PriorityClass scope now. +// matchesScope evaluates whether the replica requirements match a ResourceQuota scope. +// Currently, only PriorityClass scope is supported. func matchesScope(selector corev1.ScopedResourceSelectorRequirement, priorityClassName string) (bool, error) { switch selector.ScopeName { case corev1.ResourceQuotaScopeTerminating: @@ -250,7 +508,7 @@ func matchesScope(selector corev1.ScopedResourceSelectorRequirement, priorityCla return false, nil case corev1.ResourceQuotaScopePriorityClass: if selector.Operator == corev1.ScopeSelectorOpExists { - // This is just checking for existence of a priorityClass on the pod, + // This is just checking for existence of a priorityClass, // no need to take the overhead of selector parsing/evaluation. return len(priorityClassName) != 0, nil } @@ -258,7 +516,7 @@ func matchesScope(selector corev1.ScopedResourceSelectorRequirement, priorityCla case corev1.ResourceQuotaScopeCrossNamespacePodAffinity: return false, nil default: - // hit here means Kubernetes introduced a new resource quota scope + // Unrecognized scope - this may indicate a new Kubernetes ResourceQuota scope was introduced klog.Warning("Unrecognized scope name of resource quota", "scope name", selector.ScopeName) return false, nil } @@ -304,7 +562,10 @@ func scopedResourceSelectorRequirementsAsSelector(ssr corev1.ScopedResourceSelec return selector, nil } -// computeResources are the set of resources managed by quota associated with pods. +// computeResources are the set of standard compute resources managed by quota associated with pods. +// This includes CPU and Memory in their base and prefixed (requests./limits.) forms. +// Extended resources (e.g., nvidia.com/gpu) are handled separately via IsExtendedResourceName check. +// Storage resources (ephemeral-storage, PVC) and object count quotas are not supported. var computeResources = []corev1.ResourceName{ corev1.ResourceCPU, corev1.ResourceMemory, @@ -318,19 +579,19 @@ var computeResources = []corev1.ResourceName{ func matchingResources(input []corev1.ResourceName) []corev1.ResourceName { result := intersection(input, computeResources) - for _, resource := range input { + for _, resourceName := range input { // add extended resources - if corev1helper.IsExtendedResourceName(resource) { - result = append(result, resource) - } else if strings.HasPrefix(string(resource), resourceRequestsPrefix) { - trimmedResourceName := corev1.ResourceName(strings.TrimPrefix(string(resource), resourceRequestsPrefix)) + if corev1helper.IsExtendedResourceName(resourceName) { + result = append(result, resourceName) + } else if strings.HasPrefix(string(resourceName), resourceRequestsPrefix) { + trimmedResourceName := corev1.ResourceName(strings.TrimPrefix(string(resourceName), resourceRequestsPrefix)) if corev1helper.IsExtendedResourceName(trimmedResourceName) { - result = append(result, resource) + result = append(result, resourceName) } - } else if strings.HasPrefix(string(resource), resourceLimitsPrefix) { - trimmedResourceName := corev1.ResourceName(strings.TrimPrefix(string(resource), resourceLimitsPrefix)) + } else if strings.HasPrefix(string(resourceName), resourceLimitsPrefix) { + trimmedResourceName := corev1.ResourceName(strings.TrimPrefix(string(resourceName), resourceLimitsPrefix)) if corev1helper.IsExtendedResourceName(trimmedResourceName) { - result = append(result, resource) + result = append(result, resourceName) } } } diff --git a/pkg/estimator/server/framework/plugins/resourcequota/resourcequota_test.go b/pkg/estimator/server/framework/plugins/resourcequota/resourcequota_test.go index a64ab9756218..c6909d330a76 100644 --- a/pkg/estimator/server/framework/plugins/resourcequota/resourcequota_test.go +++ b/pkg/estimator/server/framework/plugins/resourcequota/resourcequota_test.go @@ -133,6 +133,20 @@ var ( Used: usedResourceList, }, } + noScopeSelectorResourceQuota = &corev1.ResourceQuota{ + ObjectMeta: metav1.ObjectMeta{ + Name: "no-scope", + Namespace: fooNamespace, + }, + Spec: corev1.ResourceQuotaSpec{ + Hard: hardResourceList, + // No ScopeSelector - applies to all pods + }, + Status: corev1.ResourceQuotaStatus{ + Hard: hardResourceList, + Used: usedResourceList, + }, + } ) type testContext struct { @@ -224,7 +238,7 @@ func TestResourceQuotaEstimatorPlugin(t *testing.T) { enabled: true, expect: expect{ replica: math.MaxInt32, - ret: framework.NewResult(framework.Noopperation, "ResourceQuotaEstimator has no operation on input replicaRequirements"), + ret: framework.NewResult(framework.Success, "ResourceQuotaEstimator found no quota constraints"), }, }, "resource-quota-evaluate-cpu-only": { @@ -292,16 +306,15 @@ func TestResourceQuotaEstimatorPlugin(t *testing.T) { enabled: true, expect: expect{ replica: math.MaxInt32, - ret: framework.NewResult(framework.Noopperation, "ResourceQuotaEstimator has no operation on input replicaRequirements"), + ret: framework.NewResult(framework.Success, "ResourceQuotaEstimator found no quota constraints"), }, }, "resource-quota-evaluate-all-unschedulable": { replicaRequirements: pb.ReplicaRequirements{ ResourceRequest: map[corev1.ResourceName]resource.Quantity{ - "cpu": *resource.NewQuantity(2, resource.DecimalSI), - "memory": *resource.NewQuantity(2*(1024*1024), resource.DecimalSI), - "nvidia.com/gpu": *resource.NewQuantity(1, resource.DecimalSI), - "ephemeral-storage": *resource.NewQuantity(1024*1024, resource.DecimalSI), + "cpu": *resource.NewQuantity(2, resource.DecimalSI), + "memory": *resource.NewQuantity(2*(1024*1024), resource.DecimalSI), + "nvidia.com/gpu": *resource.NewQuantity(1, resource.DecimalSI), }, Namespace: fooNamespace, PriorityClassName: fooPriorityClassName, @@ -318,10 +331,9 @@ func TestResourceQuotaEstimatorPlugin(t *testing.T) { "resource-quota-evaluate-all-with-multiple-selector-scopes": { replicaRequirements: pb.ReplicaRequirements{ ResourceRequest: map[corev1.ResourceName]resource.Quantity{ - "cpu": *resource.NewQuantity(2, resource.DecimalSI), - "memory": *resource.NewQuantity(2*(1024*1024), resource.DecimalSI), - "nvidia.com/gpu": *resource.NewQuantity(1, resource.DecimalSI), - "ephemeral-storage": *resource.NewQuantity(1024*1024, resource.DecimalSI), + "cpu": *resource.NewQuantity(2, resource.DecimalSI), + "memory": *resource.NewQuantity(2*(1024*1024), resource.DecimalSI), + "nvidia.com/gpu": *resource.NewQuantity(1, resource.DecimalSI), }, Namespace: fooNamespace, PriorityClassName: fooPriorityClassName, @@ -338,10 +350,9 @@ func TestResourceQuotaEstimatorPlugin(t *testing.T) { "request-resource-quota-evaluate-all": { replicaRequirements: pb.ReplicaRequirements{ ResourceRequest: map[corev1.ResourceName]resource.Quantity{ - "cpu": *resource.NewMilliQuantity(200, resource.DecimalSI), - "memory": *resource.NewQuantity(2*(1024*1024), resource.DecimalSI), - "nvidia.com/gpu": *resource.NewQuantity(1, resource.DecimalSI), - "ephemeral-storage": *resource.NewQuantity(1024*1024, resource.DecimalSI), + "cpu": *resource.NewMilliQuantity(200, resource.DecimalSI), + "memory": *resource.NewQuantity(2*(1024*1024), resource.DecimalSI), + "nvidia.com/gpu": *resource.NewQuantity(1, resource.DecimalSI), }, Namespace: barNamespace, PriorityClassName: barPriorityClassName, @@ -358,10 +369,9 @@ func TestResourceQuotaEstimatorPlugin(t *testing.T) { "resource-quota-evaluate-all": { replicaRequirements: pb.ReplicaRequirements{ ResourceRequest: map[corev1.ResourceName]resource.Quantity{ - "cpu": *resource.NewMilliQuantity(200, resource.DecimalSI), - "memory": *resource.NewQuantity(2*(1024*1024), resource.DecimalSI), - "nvidia.com/gpu": *resource.NewQuantity(1, resource.DecimalSI), - "ephemeral-storage": *resource.NewQuantity(1024*1024, resource.DecimalSI), + "cpu": *resource.NewMilliQuantity(200, resource.DecimalSI), + "memory": *resource.NewQuantity(2*(1024*1024), resource.DecimalSI), + "nvidia.com/gpu": *resource.NewQuantity(1, resource.DecimalSI), }, Namespace: fooNamespace, PriorityClassName: fooPriorityClassName, @@ -405,7 +415,7 @@ func TestResourceQuotaEstimatorPlugin(t *testing.T) { enabled: true, expect: expect{ replica: math.MaxInt32, - ret: framework.NewResult(framework.Noopperation, "ResourceQuotaEstimator has no operation on input replicaRequirements"), + ret: framework.NewResult(framework.Success, "ResourceQuotaEstimator found no quota constraints"), }, }, "feature-gate-disabled": { @@ -428,3 +438,663 @@ func TestResourceQuotaEstimatorPlugin(t *testing.T) { }) } } + +func TestResourceQuotaEstimator_EstimateComponents(t *testing.T) { + tests := map[string]struct { + resourceQuotaList []*corev1.ResourceQuota + components []pb.Component + enabled bool + expect expect + }{ + // ============================================ + // Plugin-level edge cases + // ============================================ + "feature-gate-disabled": { + resourceQuotaList: []*corev1.ResourceQuota{fooResourceQuota}, + components: []pb.Component{ + { + Name: "app", + ReplicaRequirements: pb.ReplicaRequirements{ + Namespace: fooNamespace, + PriorityClassName: fooPriorityClassName, + ResourceRequest: corev1.ResourceList{ + corev1.ResourceCPU: *resource.NewMilliQuantity(100, resource.DecimalSI), + }, + }, + Replicas: 1, + }, + }, + enabled: false, + expect: expect{ + replica: math.MaxInt32, + ret: framework.NewResult(framework.Noopperation, "ResourceQuotaEstimator is disabled"), + }, + }, + "empty-components-list": { + resourceQuotaList: []*corev1.ResourceQuota{fooResourceQuota}, + components: []pb.Component{}, + enabled: true, + expect: expect{ + replica: math.MaxInt32, + ret: framework.NewResult(framework.Noopperation, "ResourceQuotaEstimator received empty components list"), + }, + }, + "no-resource-quota-in-namespace": { + resourceQuotaList: []*corev1.ResourceQuota{}, // Empty list + components: []pb.Component{ + { + Name: "app", + ReplicaRequirements: pb.ReplicaRequirements{ + Namespace: fooNamespace, + PriorityClassName: fooPriorityClassName, + ResourceRequest: corev1.ResourceList{ + corev1.ResourceCPU: *resource.NewMilliQuantity(100, resource.DecimalSI), + }, + }, + Replicas: 1, + }, + }, + enabled: true, + expect: expect{ + replica: math.MaxInt32, + ret: framework.NewResult(framework.Success, "ResourceQuotaEstimator found no quota constraints"), + }, + }, + "priority-class-scope-mismatch": { + resourceQuotaList: []*corev1.ResourceQuota{fooResourceQuota}, + components: []pb.Component{ + { + Name: "app", + ReplicaRequirements: pb.ReplicaRequirements{ + Namespace: fooNamespace, + PriorityClassName: barPriorityClassName, // Mismatch + ResourceRequest: corev1.ResourceList{ + corev1.ResourceCPU: *resource.NewMilliQuantity(100, resource.DecimalSI), + }, + }, + Replicas: 1, + }, + }, + enabled: true, + expect: expect{ + replica: math.MaxInt32, + ret: framework.NewResult(framework.Success, "ResourceQuotaEstimator found no quota constraints"), + }, + }, + "empty-priority-class-name": { + resourceQuotaList: []*corev1.ResourceQuota{fooResourceQuota}, + components: []pb.Component{ + { + Name: "app", + ReplicaRequirements: pb.ReplicaRequirements{ + Namespace: fooNamespace, + PriorityClassName: "", // Empty priority class + ResourceRequest: corev1.ResourceList{ + corev1.ResourceCPU: *resource.NewMilliQuantity(100, resource.DecimalSI), + }, + }, + Replicas: 1, + }, + }, + enabled: true, + expect: expect{ + // fooResourceQuota has PriorityClass scope selector + // Empty priorityClassName won't match the scope selector + replica: math.MaxInt32, + ret: framework.NewResult(framework.Success, "ResourceQuotaEstimator found no quota constraints"), + }, + }, + "empty-priority-class-name-with-no-scope-quota": { + resourceQuotaList: []*corev1.ResourceQuota{noScopeSelectorResourceQuota}, + components: []pb.Component{ + { + Name: "app", + ReplicaRequirements: pb.ReplicaRequirements{ + Namespace: fooNamespace, + PriorityClassName: "", // Empty priority class + ResourceRequest: corev1.ResourceList{ + corev1.ResourceCPU: *resource.NewMilliQuantity(100, resource.DecimalSI), + }, + }, + Replicas: 1, + }, + }, + enabled: true, + expect: expect{ + // noScopeSelectorResourceQuota has no scope selector - applies to all pods + // Available: 800m CPU (1000m - 200m) + // Per set: 100m CPU + // Max sets: 800m/100m = 8 + replica: 8, + ret: framework.NewResult(framework.Success), + }, + }, + + // ============================================ + // Resource aggregation and filtering + // ============================================ + "single-component-basic": { + resourceQuotaList: []*corev1.ResourceQuota{fooResourceQuota}, + components: []pb.Component{ + { + Name: "webserver", + ReplicaRequirements: pb.ReplicaRequirements{ + Namespace: fooNamespace, + PriorityClassName: fooPriorityClassName, + ResourceRequest: corev1.ResourceList{ + corev1.ResourceCPU: *resource.NewMilliQuantity(100, resource.DecimalSI), + corev1.ResourceMemory: *resource.NewQuantity(500*1024, resource.DecimalSI), + }, + }, + Replicas: 1, + }, + }, + enabled: true, + expect: expect{ + // Available: 800m CPU (1000m - 200m), 3MB memory (4MB - 1MB) + // Per set: 100m CPU, 0.5MB memory + // Max sets: min(800m/100m, 3MB/0.5MB) = min(8, 6) = 6 + replica: 6, + ret: framework.NewResult(framework.Success), + }, + }, + "multi-component-complex-aggregation": { + resourceQuotaList: []*corev1.ResourceQuota{fooResourceQuota}, + components: []pb.Component{ + { + Name: "app1", + ReplicaRequirements: pb.ReplicaRequirements{ + Namespace: fooNamespace, + PriorityClassName: fooPriorityClassName, + ResourceRequest: corev1.ResourceList{ + corev1.ResourceCPU: *resource.NewMilliQuantity(50, resource.DecimalSI), + corev1.ResourceMemory: *resource.NewQuantity(200*1024, resource.DecimalSI), + }, + }, + Replicas: 3, + }, + { + Name: "app2", + ReplicaRequirements: pb.ReplicaRequirements{ + Namespace: fooNamespace, + PriorityClassName: fooPriorityClassName, + ResourceRequest: corev1.ResourceList{ + corev1.ResourceCPU: *resource.NewMilliQuantity(100, resource.DecimalSI), + corev1.ResourceMemory: *resource.NewQuantity(500*1024, resource.DecimalSI), + }, + }, + Replicas: 2, + }, + }, + enabled: true, + expect: expect{ + // Per set: (50m*3 + 100m*2) = 350m CPU, (200*1024*3 + 500*1024*2) = 1,638,400 bytes = 1600KB memory + // Available: 800m CPU, 3MB (3,145,728 bytes = 3072KB) memory + // Max sets: min(800m/350m, 3072KB/1600KB) = min(2.28, 1.92) = 1 + replica: 1, + ret: framework.NewResult(framework.Success), + }, + }, + + // ============================================ + // Resource constraint bottlenecks + // ============================================ + "memory-bottleneck": { + resourceQuotaList: []*corev1.ResourceQuota{fooResourceQuota}, + components: []pb.Component{ + { + Name: "memory-intensive", + ReplicaRequirements: pb.ReplicaRequirements{ + Namespace: fooNamespace, + PriorityClassName: fooPriorityClassName, + ResourceRequest: corev1.ResourceList{ + corev1.ResourceCPU: *resource.NewMilliQuantity(50, resource.DecimalSI), + corev1.ResourceMemory: *resource.NewQuantity(2*1024*1024, resource.DecimalSI), + }, + }, + Replicas: 1, + }, + }, + enabled: true, + expect: expect{ + // Available: 800m CPU, 3MB memory + // Per set: 50m CPU, 2MB memory + // Max sets: min(800m/50m, 3MB/2MB) = min(16, 1) = 1 + // Tests single-resource bottleneck scenario + replica: 1, + ret: framework.NewResult(framework.Success), + }, + }, + "gpu-extended-resource-bottleneck": { + resourceQuotaList: []*corev1.ResourceQuota{fooResourceQuota}, + components: []pb.Component{ + { + Name: "ml-worker", + ReplicaRequirements: pb.ReplicaRequirements{ + Namespace: fooNamespace, + PriorityClassName: fooPriorityClassName, + ResourceRequest: corev1.ResourceList{ + corev1.ResourceCPU: *resource.NewMilliQuantity(100, resource.DecimalSI), + corev1.ResourceMemory: *resource.NewQuantity(500*1024, resource.DecimalSI), + corev1.ResourceName("nvidia.com/gpu"): *resource.NewQuantity(1, resource.DecimalSI), + }, + }, + Replicas: 2, + }, + }, + enabled: true, + expect: expect{ + // Available: 800m CPU, 3MB memory, 3 GPUs (5 - 2) + // Per set: 200m CPU, 1MB memory, 2 GPUs + // Max sets: min(800m/200m, 3MB/1MB, 3/2) = min(4, 3, 1) = 1 + replica: 1, + ret: framework.NewResult(framework.Success), + }, + }, + "quota-exhausted-zero-sets": { + resourceQuotaList: []*corev1.ResourceQuota{fooResourceQuota}, + components: []pb.Component{ + { + Name: "large-app", + ReplicaRequirements: pb.ReplicaRequirements{ + Namespace: fooNamespace, + PriorityClassName: fooPriorityClassName, + ResourceRequest: corev1.ResourceList{ + corev1.ResourceCPU: *resource.NewQuantity(10, resource.DecimalSI), + corev1.ResourceMemory: *resource.NewQuantity(10*1024*1024, resource.DecimalSI), + }, + }, + Replicas: 1, + }, + }, + enabled: true, + expect: expect{ + // Per set: 10000m CPU, 10MB memory + // Available: 800m CPU, 3MB memory + // Both constraints violated → 0 sets + replica: 0, + ret: framework.NewResult(framework.Unschedulable, "zero component sets estimated by ResourceQuotaEstimator"), + }, + }, + + // ============================================ + // Multiple quotas and special cases + // ============================================ + "multiple-quotas-minimum-constraint": { + resourceQuotaList: []*corev1.ResourceQuota{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "cpu-quota", + Namespace: fooNamespace, + }, + Spec: corev1.ResourceQuotaSpec{ + Hard: corev1.ResourceList{ + "cpu": *resource.NewQuantity(1, resource.DecimalSI), + }, + ScopeSelector: &corev1.ScopeSelector{ + MatchExpressions: []corev1.ScopedResourceSelectorRequirement{fooPrioritySelector}, + }, + }, + Status: corev1.ResourceQuotaStatus{ + Hard: corev1.ResourceList{ + "cpu": *resource.NewQuantity(1, resource.DecimalSI), + }, + Used: corev1.ResourceList{ + "cpu": *resource.NewMilliQuantity(200, resource.DecimalSI), + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "memory-quota", + Namespace: fooNamespace, + }, + Spec: corev1.ResourceQuotaSpec{ + Hard: corev1.ResourceList{ + "memory": *resource.NewQuantity(3*(1024*1024), resource.DecimalSI), + }, + ScopeSelector: &corev1.ScopeSelector{ + MatchExpressions: []corev1.ScopedResourceSelectorRequirement{fooPrioritySelector}, + }, + }, + Status: corev1.ResourceQuotaStatus{ + Hard: corev1.ResourceList{ + "memory": *resource.NewQuantity(3*(1024*1024), resource.DecimalSI), + }, + Used: corev1.ResourceList{ + "memory": *resource.NewQuantity(1024*1024, resource.DecimalSI), + }, + }, + }, + }, + components: []pb.Component{ + { + Name: "app", + ReplicaRequirements: pb.ReplicaRequirements{ + Namespace: fooNamespace, + PriorityClassName: fooPriorityClassName, + ResourceRequest: corev1.ResourceList{ + corev1.ResourceCPU: *resource.NewMilliQuantity(200, resource.DecimalSI), + corev1.ResourceMemory: *resource.NewQuantity(1024*1024, resource.DecimalSI), + }, + }, + Replicas: 1, + }, + }, + enabled: true, + expect: expect{ + // CPU quota: 800m / 200m = 4 sets + // Memory quota: 2MB / 1MB = 2 sets + // Minimum: 2 sets + replica: 2, + ret: framework.NewResult(framework.Success), + }, + }, + + // ============================================ + // Multi-priority component sets + // ============================================ + "multiple-priorities-different-quotas": { + resourceQuotaList: []*corev1.ResourceQuota{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "foo-priority-quota", + Namespace: fooNamespace, + }, + Spec: corev1.ResourceQuotaSpec{ + Hard: corev1.ResourceList{ + corev1.ResourceCPU: *resource.NewQuantity(2, resource.DecimalSI), + corev1.ResourceMemory: *resource.NewQuantity(4*1024*1024, resource.DecimalSI), + }, + ScopeSelector: &corev1.ScopeSelector{ + MatchExpressions: []corev1.ScopedResourceSelectorRequirement{fooPrioritySelector}, + }, + }, + Status: corev1.ResourceQuotaStatus{ + Hard: corev1.ResourceList{ + corev1.ResourceCPU: *resource.NewQuantity(2, resource.DecimalSI), + corev1.ResourceMemory: *resource.NewQuantity(4*1024*1024, resource.DecimalSI), + }, + Used: corev1.ResourceList{ + corev1.ResourceCPU: *resource.NewMilliQuantity(200, resource.DecimalSI), + corev1.ResourceMemory: *resource.NewQuantity(1024*1024, resource.DecimalSI), + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "bar-priority-quota", + Namespace: fooNamespace, + }, + Spec: corev1.ResourceQuotaSpec{ + Hard: corev1.ResourceList{ + corev1.ResourceCPU: *resource.NewQuantity(1, resource.DecimalSI), + corev1.ResourceMemory: *resource.NewQuantity(2*1024*1024, resource.DecimalSI), + }, + ScopeSelector: &corev1.ScopeSelector{ + MatchExpressions: []corev1.ScopedResourceSelectorRequirement{barPrioritySelector}, + }, + }, + Status: corev1.ResourceQuotaStatus{ + Hard: corev1.ResourceList{ + corev1.ResourceCPU: *resource.NewQuantity(1, resource.DecimalSI), + corev1.ResourceMemory: *resource.NewQuantity(2*1024*1024, resource.DecimalSI), + }, + Used: corev1.ResourceList{ + corev1.ResourceCPU: *resource.NewMilliQuantity(100, resource.DecimalSI), + corev1.ResourceMemory: *resource.NewQuantity(512*1024, resource.DecimalSI), + }, + }, + }, + }, + components: []pb.Component{ + { + Name: "frontend", + ReplicaRequirements: pb.ReplicaRequirements{ + Namespace: fooNamespace, + PriorityClassName: fooPriorityClassName, + ResourceRequest: corev1.ResourceList{ + corev1.ResourceCPU: *resource.NewMilliQuantity(300, resource.DecimalSI), + corev1.ResourceMemory: *resource.NewQuantity(500*1024, resource.DecimalSI), + }, + }, + Replicas: 2, + }, + { + Name: "backend", + ReplicaRequirements: pb.ReplicaRequirements{ + Namespace: fooNamespace, + PriorityClassName: barPriorityClassName, + ResourceRequest: corev1.ResourceList{ + corev1.ResourceCPU: *resource.NewMilliQuantity(200, resource.DecimalSI), + corev1.ResourceMemory: *resource.NewQuantity(400*1024, resource.DecimalSI), + }, + }, + Replicas: 1, + }, + }, + enabled: true, + expect: expect{ + // foo-priority group (frontend): 2 replicas × 300m = 600m CPU, 2 × 500KB = 1MB memory per set + // Available: 1800m CPU, 3MB memory + // Max sets: min(1800m/600m, 3MB/1MB) = min(3, 3) = 3 + // + // bar-priority group (backend): 1 replica × 200m = 200m CPU, 1 × 400KB = 400KB memory per set + // Available: 900m CPU, 1.5MB memory + // Max sets: min(900m/200m, 1.5MB/400KB) = min(4, 3) = 3 + // + // Final result: min(3, 3) = 3 + replica: 3, + ret: framework.NewResult(framework.Success), + }, + }, + "multiple-priorities-one-group-bottleneck": { + resourceQuotaList: []*corev1.ResourceQuota{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "foo-priority-quota", + Namespace: fooNamespace, + }, + Spec: corev1.ResourceQuotaSpec{ + Hard: corev1.ResourceList{ + corev1.ResourceCPU: *resource.NewQuantity(2, resource.DecimalSI), + corev1.ResourceMemory: *resource.NewQuantity(4*1024*1024, resource.DecimalSI), + }, + ScopeSelector: &corev1.ScopeSelector{ + MatchExpressions: []corev1.ScopedResourceSelectorRequirement{fooPrioritySelector}, + }, + }, + Status: corev1.ResourceQuotaStatus{ + Hard: corev1.ResourceList{ + corev1.ResourceCPU: *resource.NewQuantity(2, resource.DecimalSI), + corev1.ResourceMemory: *resource.NewQuantity(4*1024*1024, resource.DecimalSI), + }, + Used: corev1.ResourceList{ + corev1.ResourceCPU: *resource.NewMilliQuantity(1600, resource.DecimalSI), // High usage + corev1.ResourceMemory: *resource.NewQuantity(1024*1024, resource.DecimalSI), + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "bar-priority-quota", + Namespace: fooNamespace, + }, + Spec: corev1.ResourceQuotaSpec{ + Hard: corev1.ResourceList{ + corev1.ResourceCPU: *resource.NewQuantity(2, resource.DecimalSI), + corev1.ResourceMemory: *resource.NewQuantity(4*1024*1024, resource.DecimalSI), + }, + ScopeSelector: &corev1.ScopeSelector{ + MatchExpressions: []corev1.ScopedResourceSelectorRequirement{barPrioritySelector}, + }, + }, + Status: corev1.ResourceQuotaStatus{ + Hard: corev1.ResourceList{ + corev1.ResourceCPU: *resource.NewQuantity(2, resource.DecimalSI), + corev1.ResourceMemory: *resource.NewQuantity(4*1024*1024, resource.DecimalSI), + }, + Used: corev1.ResourceList{ + corev1.ResourceCPU: *resource.NewMilliQuantity(100, resource.DecimalSI), // Low usage + corev1.ResourceMemory: *resource.NewQuantity(512*1024, resource.DecimalSI), + }, + }, + }, + }, + components: []pb.Component{ + { + Name: "high-priority-app", + ReplicaRequirements: pb.ReplicaRequirements{ + Namespace: fooNamespace, + PriorityClassName: fooPriorityClassName, + ResourceRequest: corev1.ResourceList{ + corev1.ResourceCPU: *resource.NewMilliQuantity(300, resource.DecimalSI), + corev1.ResourceMemory: *resource.NewQuantity(500*1024, resource.DecimalSI), + }, + }, + Replicas: 1, + }, + { + Name: "low-priority-app", + ReplicaRequirements: pb.ReplicaRequirements{ + Namespace: fooNamespace, + PriorityClassName: barPriorityClassName, + ResourceRequest: corev1.ResourceList{ + corev1.ResourceCPU: *resource.NewMilliQuantity(100, resource.DecimalSI), + corev1.ResourceMemory: *resource.NewQuantity(200*1024, resource.DecimalSI), + }, + }, + Replicas: 1, + }, + }, + enabled: true, + expect: expect{ + // foo-priority group (high-priority-app): 300m CPU, 500KB memory per set + // Available: 400m CPU, 3MB memory + // Max sets: min(400m/300m, 3MB/500KB) = min(1, 6) = 1 + // + // bar-priority group (low-priority-app): 100m CPU, 200KB memory per set + // Available: 1900m CPU, 3.5MB memory + // Max sets: min(1900m/100m, 3.5MB/200KB) = min(19, 17) = 17 + // + // Final result: min(1, 17) = 1 (bottlenecked by foo-priority group) + replica: 1, + ret: framework.NewResult(framework.Success), + }, + }, + "mixed-scope-and-no-scope-quotas-prevents-over-admission": { + // This test validates the fix for the over-admission bug with mixed quota scopes. + // It tests the most complex scenario: + // 1. A no-scope quota that applies to ALL components + // 2. A scoped quota that applies only to high-priority components + // The algorithm must correctly: + // - Aggregate ALL components when evaluating the no-scope quota + // - Filter only matching components when evaluating the scoped quota + // - Return the minimum (most restrictive) result + resourceQuotaList: []*corev1.ResourceQuota{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "global-quota", + Namespace: fooNamespace, + }, + Spec: corev1.ResourceQuotaSpec{ + Hard: corev1.ResourceList{ + corev1.ResourceCPU: *resource.NewQuantity(1, resource.DecimalSI), // 1000m CPU total for ALL pods + corev1.ResourceMemory: *resource.NewQuantity(2*1024*1024, resource.DecimalSI), + }, + // No ScopeSelector - applies to ALL pods + }, + Status: corev1.ResourceQuotaStatus{ + Hard: corev1.ResourceList{ + corev1.ResourceCPU: *resource.NewQuantity(1, resource.DecimalSI), + corev1.ResourceMemory: *resource.NewQuantity(2*1024*1024, resource.DecimalSI), + }, + Used: corev1.ResourceList{ + corev1.ResourceCPU: *resource.NewMilliQuantity(0, resource.DecimalSI), + corev1.ResourceMemory: *resource.NewQuantity(0, resource.DecimalSI), + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "high-priority-quota", + Namespace: fooNamespace, + }, + Spec: corev1.ResourceQuotaSpec{ + Hard: corev1.ResourceList{ + corev1.ResourceCPU: *resource.NewQuantity(2, resource.DecimalSI), // 2000m CPU for high-priority only + corev1.ResourceMemory: *resource.NewQuantity(4*1024*1024, resource.DecimalSI), + }, + ScopeSelector: &corev1.ScopeSelector{ + MatchExpressions: []corev1.ScopedResourceSelectorRequirement{fooPrioritySelector}, + }, + }, + Status: corev1.ResourceQuotaStatus{ + Hard: corev1.ResourceList{ + corev1.ResourceCPU: *resource.NewQuantity(2, resource.DecimalSI), + corev1.ResourceMemory: *resource.NewQuantity(4*1024*1024, resource.DecimalSI), + }, + Used: corev1.ResourceList{ + corev1.ResourceCPU: *resource.NewMilliQuantity(0, resource.DecimalSI), + corev1.ResourceMemory: *resource.NewQuantity(0, resource.DecimalSI), + }, + }, + }, + }, + components: []pb.Component{ + { + Name: "high-priority-component", + ReplicaRequirements: pb.ReplicaRequirements{ + Namespace: fooNamespace, + PriorityClassName: fooPriorityClassName, + ResourceRequest: corev1.ResourceList{ + corev1.ResourceCPU: *resource.NewMilliQuantity(600, resource.DecimalSI), // 600m per replica + corev1.ResourceMemory: *resource.NewQuantity(1*1024*1024, resource.DecimalSI), + }, + }, + Replicas: 1, // 600m CPU per set + }, + { + Name: "low-priority-component", + ReplicaRequirements: pb.ReplicaRequirements{ + Namespace: fooNamespace, + PriorityClassName: barPriorityClassName, + ResourceRequest: corev1.ResourceList{ + corev1.ResourceCPU: *resource.NewMilliQuantity(500, resource.DecimalSI), // 500m per replica + corev1.ResourceMemory: *resource.NewQuantity(1*1024*1024, resource.DecimalSI), + }, + }, + Replicas: 1, // 500m CPU per set + }, + }, + enabled: true, + expect: expect{ + // Evaluation against global-quota (no scope - applies to ALL components): + // Aggregate: 600m (high-priority) + 500m (low-priority) = 1100m CPU per set + // Available: 1000m CPU + // Result: 1000m / 1100m = 0 sets + // + // Evaluation against high-priority-quota (scoped - applies ONLY to high-priority): + // Filter: Only high-priority-component matches + // Aggregate: 600m CPU per set + // Available: 2000m CPU + // Result: 2000m / 600m = 3 sets + // + // Final result: min(0, 3) = 0 sets (global-quota is the bottleneck) + replica: 0, + ret: framework.NewResult(framework.Unschedulable, "zero component sets estimated by ResourceQuotaEstimator"), + }, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + testCtx := setup(t, tt.resourceQuotaList, tt.enabled) + sets, ret := testCtx.p.EstimateComponents(testCtx.ctx, nil, tt.components) + + require.Equal(t, tt.expect.ret.Code(), ret.Code()) + assert.ElementsMatch(t, tt.expect.ret.Reasons(), ret.Reasons()) + require.Equal(t, tt.expect.replica, sets) + }) + } +}