Skip to content

Commit ffad281

Browse files
authored
Merge pull request kubernetes#130060 from carlory/fix-quota-scope
Fix the `ResourceQuota` admission plugin does not respect ANY scope change
2 parents d66928b + eb0f003 commit ffad281

File tree

6 files changed

+267
-25
lines changed

6 files changed

+267
-25
lines changed

pkg/quota/v1/evaluator/core/pods.go

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,17 @@ func (p *podEvaluator) Handles(a admission.Attributes) bool {
170170
op := a.GetOperation()
171171
switch a.GetSubresource() {
172172
case "":
173+
if op == admission.Update {
174+
pod, err1 := toExternalPodOrError(a.GetObject())
175+
oldPod, err2 := toExternalPodOrError(a.GetOldObject())
176+
if err1 != nil || err2 != nil {
177+
return false
178+
}
179+
// when scope changed
180+
if IsTerminating(oldPod) != IsTerminating(pod) {
181+
return true
182+
}
183+
}
173184
return op == admission.Create
174185
case "resize":
175186
return op == admission.Update
@@ -333,9 +344,9 @@ func podMatchesScopeFunc(selector corev1.ScopedResourceSelectorRequirement, obje
333344
}
334345
switch selector.ScopeName {
335346
case corev1.ResourceQuotaScopeTerminating:
336-
return isTerminating(pod), nil
347+
return IsTerminating(pod), nil
337348
case corev1.ResourceQuotaScopeNotTerminating:
338-
return !isTerminating(pod), nil
349+
return !IsTerminating(pod), nil
339350
case corev1.ResourceQuotaScopeBestEffort:
340351
return isBestEffort(pod), nil
341352
case corev1.ResourceQuotaScopeNotBestEffort:
@@ -393,7 +404,7 @@ func isBestEffort(pod *corev1.Pod) bool {
393404
return qos.GetPodQOS(pod) == corev1.PodQOSBestEffort
394405
}
395406

396-
func isTerminating(pod *corev1.Pod) bool {
407+
func IsTerminating(pod *corev1.Pod) bool {
397408
if pod.Spec.ActiveDeadlineSeconds != nil && *pod.Spec.ActiveDeadlineSeconds >= int64(0) {
398409
return true
399410
}

pkg/quota/v1/evaluator/core/pods_test.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import (
3838
"k8s.io/kubernetes/pkg/util/node"
3939
"k8s.io/utils/clock"
4040
testingclock "k8s.io/utils/clock/testing"
41+
"k8s.io/utils/ptr"
4142
)
4243

4344
func TestPodConstraintsFunc(t *testing.T) {
@@ -1297,6 +1298,21 @@ func TestPodEvaluatorHandles(t *testing.T) {
12971298
attrs: admission.NewAttributesRecord(nil, nil, schema.GroupVersionKind{Group: "core", Version: "v1", Kind: "Pod"}, "", "", schema.GroupVersionResource{Group: "core", Version: "v1", Resource: "pods"}, "", admission.Create, nil, false, nil),
12981299
want: true,
12991300
},
1301+
{
1302+
name: "update-activeDeadlineSeconds-to-nil",
1303+
attrs: admission.NewAttributesRecord(&corev1.Pod{}, &corev1.Pod{Spec: corev1.PodSpec{ActiveDeadlineSeconds: ptr.To[int64](1)}}, schema.GroupVersionKind{Group: "core", Version: "v1", Kind: "Pod"}, "", "", schema.GroupVersionResource{Group: "core", Version: "v1", Resource: "pods"}, "", admission.Update, nil, false, nil),
1304+
want: true,
1305+
},
1306+
{
1307+
name: "update-activeDeadlineSeconds-from-nil",
1308+
attrs: admission.NewAttributesRecord(&corev1.Pod{Spec: corev1.PodSpec{ActiveDeadlineSeconds: ptr.To[int64](1)}}, &corev1.Pod{}, schema.GroupVersionKind{Group: "core", Version: "v1", Kind: "Pod"}, "", "", schema.GroupVersionResource{Group: "core", Version: "v1", Resource: "pods"}, "", admission.Update, nil, false, nil),
1309+
want: true,
1310+
},
1311+
{
1312+
name: "update-activeDeadlineSeconds-with-different-values",
1313+
attrs: admission.NewAttributesRecord(&corev1.Pod{Spec: corev1.PodSpec{ActiveDeadlineSeconds: ptr.To[int64](1)}}, &corev1.Pod{Spec: corev1.PodSpec{ActiveDeadlineSeconds: ptr.To[int64](2)}}, schema.GroupVersionKind{Group: "core", Version: "v1", Kind: "Pod"}, "", "", schema.GroupVersionResource{Group: "core", Version: "v1", Resource: "pods"}, "", admission.Update, nil, false, nil),
1314+
want: false,
1315+
},
13001316
{
13011317
name: "update",
13021318
attrs: admission.NewAttributesRecord(nil, nil, schema.GroupVersionKind{Group: "core", Version: "v1", Kind: "Pod"}, "", "", schema.GroupVersionResource{Group: "core", Version: "v1", Resource: "pods"}, "", admission.Update, nil, false, nil),

pkg/quota/v1/install/update_filter.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,12 @@ func DefaultUpdateFilter() func(resource schema.GroupVersionResource, oldObj, ne
3838
if feature.DefaultFeatureGate.Enabled(features.InPlacePodVerticalScaling) && hasResourcesChanged(oldPod, newPod) {
3939
return true
4040
}
41+
42+
// when scope changed
43+
if core.IsTerminating(oldPod) != core.IsTerminating(newPod) {
44+
return true
45+
}
46+
4147
return core.QuotaV1Pod(oldPod, clock.RealClock{}) && !core.QuotaV1Pod(newPod, clock.RealClock{})
4248
case schema.GroupResource{Resource: "services"}:
4349
oldService := oldObj.(*v1.Service)

plugin/pkg/admission/resourcequota/admission_test.go

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -860,6 +860,128 @@ func TestAdmitBelowTerminatingQuotaLimit(t *testing.T) {
860860
}
861861
}
862862

863+
// TestAdmitBelowTerminatingQuotaLimitWhenPodScopeUpdated ensures that terminating pods are charged to the right quota.
864+
// It creates a terminating and non-terminating quota, and changes an existing pod to be terminating.
865+
// It ensures that the terminating quota is incremented, and the non-terminating quota is not.
866+
//
867+
// The Quota admission is intended to fail "high" in this case, and depends on a controller reconciling actual persisted
868+
// use to lower / free the reserved quota. We need always overcount in the admission plugin if something later causes
869+
// the request to be rejected, so you can not reduce quota with requests that aren't completed.
870+
func TestAdmitBelowTerminatingQuotaLimitWhenPodScopeUpdated(t *testing.T) {
871+
resourceQuotaNonTerminating := &corev1.ResourceQuota{
872+
ObjectMeta: metav1.ObjectMeta{Name: "quota-non-terminating", Namespace: "test", ResourceVersion: "124"},
873+
Spec: corev1.ResourceQuotaSpec{
874+
Scopes: []corev1.ResourceQuotaScope{corev1.ResourceQuotaScopeNotTerminating},
875+
},
876+
Status: corev1.ResourceQuotaStatus{
877+
Hard: corev1.ResourceList{
878+
corev1.ResourceCPU: resource.MustParse("3"),
879+
corev1.ResourceMemory: resource.MustParse("100Gi"),
880+
corev1.ResourcePods: resource.MustParse("5"),
881+
},
882+
Used: corev1.ResourceList{
883+
corev1.ResourceCPU: resource.MustParse("1"),
884+
corev1.ResourceMemory: resource.MustParse("50Gi"),
885+
corev1.ResourcePods: resource.MustParse("3"),
886+
},
887+
},
888+
}
889+
resourceQuotaTerminating := &corev1.ResourceQuota{
890+
ObjectMeta: metav1.ObjectMeta{Name: "quota-terminating", Namespace: "test", ResourceVersion: "124"},
891+
Spec: corev1.ResourceQuotaSpec{
892+
Scopes: []corev1.ResourceQuotaScope{corev1.ResourceQuotaScopeTerminating},
893+
},
894+
Status: corev1.ResourceQuotaStatus{
895+
Hard: corev1.ResourceList{
896+
corev1.ResourceCPU: resource.MustParse("3"),
897+
corev1.ResourceMemory: resource.MustParse("100Gi"),
898+
corev1.ResourcePods: resource.MustParse("5"),
899+
},
900+
Used: corev1.ResourceList{
901+
corev1.ResourceCPU: resource.MustParse("1"),
902+
corev1.ResourceMemory: resource.MustParse("50Gi"),
903+
corev1.ResourcePods: resource.MustParse("3"),
904+
},
905+
},
906+
}
907+
stopCh := make(chan struct{})
908+
defer close(stopCh)
909+
910+
kubeClient := fake.NewSimpleClientset(resourceQuotaTerminating, resourceQuotaNonTerminating)
911+
informerFactory := informers.NewSharedInformerFactory(kubeClient, 0)
912+
913+
handler, err := createHandler(kubeClient, informerFactory, stopCh)
914+
if err != nil {
915+
t.Errorf("Error occurred while creating admission plugin: %v", err)
916+
}
917+
918+
err = informerFactory.Core().V1().ResourceQuotas().Informer().GetIndexer().Add(resourceQuotaNonTerminating)
919+
if err != nil {
920+
t.Errorf("Error occurred while adding resource quota to the indexer: %v", err)
921+
}
922+
err = informerFactory.Core().V1().ResourceQuotas().Informer().GetIndexer().Add(resourceQuotaTerminating)
923+
if err != nil {
924+
t.Errorf("Error occurred while adding resource quota to the indexer: %v", err)
925+
}
926+
927+
// old pod belonged to the non-terminating scope, but updated version belongs to the terminating scope
928+
existingPod := validPod("allowed-pod", 1, getResourceRequirements(getResourceList("100m", "2Gi"), getResourceList("", "")))
929+
existingPod.ResourceVersion = "1"
930+
newPod := validPod("allowed-pod", 1, getResourceRequirements(getResourceList("100m", "2Gi"), getResourceList("", "")))
931+
activeDeadlineSeconds := int64(30)
932+
newPod.Spec.ActiveDeadlineSeconds = &activeDeadlineSeconds
933+
err = handler.Validate(context.TODO(), admission.NewAttributesRecord(newPod, existingPod, api.Kind("Pod").WithVersion("version"), newPod.Namespace, newPod.Name, corev1.Resource("pods").WithVersion("version"), "", admission.Update, &metav1.CreateOptions{}, false, nil), nil)
934+
if err != nil {
935+
t.Errorf("Unexpected error: %v", err)
936+
}
937+
if len(kubeClient.Actions()) == 0 {
938+
t.Errorf("Expected a client action")
939+
}
940+
941+
expectedActionSet := sets.NewString(
942+
strings.Join([]string{"update", "resourcequotas", "status"}, "-"),
943+
)
944+
actionSet := sets.NewString()
945+
for _, action := range kubeClient.Actions() {
946+
actionSet.Insert(strings.Join([]string{action.GetVerb(), action.GetResource().Resource, action.GetSubresource()}, "-"))
947+
}
948+
if !actionSet.HasAll(expectedActionSet.List()...) {
949+
t.Errorf("Expected actions:\n%v\n but got:\n%v\nDifference:\n%v", expectedActionSet, actionSet, expectedActionSet.Difference(actionSet))
950+
}
951+
952+
decimatedActions := removeListWatch(kubeClient.Actions())
953+
lastActionIndex := len(decimatedActions) - 1
954+
usage := decimatedActions[lastActionIndex].(testcore.UpdateAction).GetObject().(*corev1.ResourceQuota)
955+
956+
// ensure only the quota-terminating was updated
957+
if usage.Name != resourceQuotaTerminating.Name {
958+
t.Errorf("Incremented the wrong quota, expected %v, actual %v", resourceQuotaTerminating.Name, usage.Name)
959+
}
960+
961+
expectedUsage := corev1.ResourceQuota{
962+
Status: corev1.ResourceQuotaStatus{
963+
Hard: corev1.ResourceList{
964+
corev1.ResourceCPU: resource.MustParse("3"),
965+
corev1.ResourceMemory: resource.MustParse("100Gi"),
966+
corev1.ResourcePods: resource.MustParse("5"),
967+
},
968+
Used: corev1.ResourceList{
969+
corev1.ResourceCPU: resource.MustParse("1100m"),
970+
corev1.ResourceMemory: resource.MustParse("52Gi"),
971+
corev1.ResourcePods: resource.MustParse("4"),
972+
},
973+
},
974+
}
975+
for k, v := range expectedUsage.Status.Used {
976+
actual := usage.Status.Used[k]
977+
actualValue := actual.String()
978+
expectedValue := v.String()
979+
if expectedValue != actualValue {
980+
t.Errorf("Usage Used: Key: %v, Expected: %v, Actual: %v", k, expectedValue, actualValue)
981+
}
982+
}
983+
}
984+
863985
// TestAdmitBelowBestEffortQuotaLimit creates a best effort and non-best effort quota.
864986
// It verifies that best effort pods are properly scoped to the best effort quota document.
865987
func TestAdmitBelowBestEffortQuotaLimit(t *testing.T) {

staging/src/k8s.io/apiserver/pkg/admission/plugin/resourcequota/controller.go

Lines changed: 60 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -492,16 +492,26 @@ func CheckRequest(quotas []corev1.ResourceQuota, a admission.Attributes, evaluat
492492
// as a result, we need to measure the usage of this object for quota
493493
// on updates, we need to subtract the previous measured usage
494494
// if usage shows no change, just return since it has no impact on quota
495-
deltaUsage, err := evaluator.Usage(inputObject)
495+
inputUsage, err := evaluator.Usage(inputObject)
496496
if err != nil {
497497
return quotas, err
498498
}
499499

500500
// ensure that usage for input object is never negative (this would mean a resource made a negative resource requirement)
501-
if negativeUsage := quota.IsNegative(deltaUsage); len(negativeUsage) > 0 {
501+
if negativeUsage := quota.IsNegative(inputUsage); len(negativeUsage) > 0 {
502502
return nil, admission.NewForbidden(a, fmt.Errorf("quota usage is negative for resource(s): %s", prettyPrintResourceNames(negativeUsage)))
503503
}
504504

505+
// initialize a map of delta usage for each interesting quota index.
506+
deltaUsageIndexMap := make(map[int]corev1.ResourceList, len(interestingQuotaIndexes))
507+
for _, index := range interestingQuotaIndexes {
508+
deltaUsageIndexMap[index] = inputUsage
509+
}
510+
var deltaUsageWhenNoInterestingQuota corev1.ResourceList
511+
if admission.Create == a.GetOperation() && len(interestingQuotaIndexes) == 0 {
512+
deltaUsageWhenNoInterestingQuota = inputUsage
513+
}
514+
505515
if admission.Update == a.GetOperation() {
506516
prevItem := a.GetOldObject()
507517
if prevItem == nil {
@@ -511,20 +521,55 @@ func CheckRequest(quotas []corev1.ResourceQuota, a admission.Attributes, evaluat
511521
// if we can definitively determine that this is not a case of "create on update",
512522
// then charge based on the delta. Otherwise, bill the maximum
513523
metadata, err := meta.Accessor(prevItem)
514-
if err == nil && len(metadata.GetResourceVersion()) > 0 {
515-
prevUsage, innerErr := evaluator.Usage(prevItem)
516-
if innerErr != nil {
517-
return quotas, innerErr
524+
if err == nil {
525+
if len(metadata.GetResourceVersion()) > 0 {
526+
prevUsage, innerErr := evaluator.Usage(prevItem)
527+
if innerErr != nil {
528+
return quotas, innerErr
529+
}
530+
531+
deltaUsage := quota.SubtractWithNonNegativeResult(inputUsage, prevUsage)
532+
if len(interestingQuotaIndexes) == 0 {
533+
deltaUsageWhenNoInterestingQuota = deltaUsage
534+
}
535+
536+
for _, index := range interestingQuotaIndexes {
537+
resourceQuota := quotas[index]
538+
match, err := evaluator.Matches(&resourceQuota, prevItem)
539+
if err != nil {
540+
klog.ErrorS(err, "Error occurred while matching resource quota against the existing object",
541+
"resourceQuota", resourceQuota)
542+
return quotas, err
543+
}
544+
if match {
545+
deltaUsageIndexMap[index] = deltaUsage
546+
}
547+
}
548+
} else if len(interestingQuotaIndexes) == 0 {
549+
deltaUsageWhenNoInterestingQuota = inputUsage
518550
}
519-
deltaUsage = quota.SubtractWithNonNegativeResult(deltaUsage, prevUsage)
520551
}
521552
}
522553

523-
// ignore items in deltaUsage with zero usage
524-
deltaUsage = quota.RemoveZeros(deltaUsage)
554+
// ignore items in deltaUsageIndexMap with zero usage,
555+
// as they will not impact the quota.
556+
for index := range deltaUsageIndexMap {
557+
deltaUsageIndexMap[index] = quota.RemoveZeros(deltaUsageIndexMap[index])
558+
if len(deltaUsageIndexMap[index]) == 0 {
559+
delete(deltaUsageIndexMap, index)
560+
}
561+
}
562+
525563
// if there is no remaining non-zero usage, short-circuit and return
526-
if len(deltaUsage) == 0 {
527-
return quotas, nil
564+
if len(interestingQuotaIndexes) != 0 {
565+
if len(deltaUsageIndexMap) == 0 {
566+
return quotas, nil
567+
}
568+
} else {
569+
deltaUsage := quota.RemoveZeros(deltaUsageWhenNoInterestingQuota)
570+
if len(deltaUsage) == 0 {
571+
return quotas, nil
572+
}
528573
}
529574

530575
// verify that for every resource that had limited by default consumption
@@ -557,6 +602,10 @@ func CheckRequest(quotas []corev1.ResourceQuota, a admission.Attributes, evaluat
557602

558603
for _, index := range interestingQuotaIndexes {
559604
resourceQuota := outQuotas[index]
605+
deltaUsage, ok := deltaUsageIndexMap[index]
606+
if !ok {
607+
continue
608+
}
560609

561610
hardResources := quota.ResourceNames(resourceQuota.Status.Hard)
562611
requestedUsage := quota.Mask(deltaUsage, hardResources)

0 commit comments

Comments
 (0)