From 305cabd4f698d95283faf3b772cb7c2bdc33d1ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20B=C3=A4hler?= <26610571+oliverbaehler@users.noreply.github.com> Date: Mon, 17 Aug 2026 10:49:56 +0200 Subject: [PATCH] fix: consistently reconcile quotas from rules (#2083) * fix: do not mutate on update and bound pvcs Signed-off-by: Oliver Baehler * fix: do not mutate on update and bound pvcs Signed-off-by: Oliver Baehler * fix: consistently reconcile quotas from rules Signed-off-by: Oliver Baehler --------- Signed-off-by: Oliver Baehler --- e2e/rules_quota_test.go | 123 ++++++++++++---- .../globalresourcequotas/controller.go | 45 +++++- .../globalresourcequotas/controller_test.go | 41 ++++++ .../globalresourcequota/calculation.go | 26 +--- .../globalresourcequota/calculation_test.go | 55 +++++++ .../tenant/validation/rule_quota_test.go | 135 ++++++++++++++++++ .../tenant/validation/rule_validator.go | 105 +++++++++++++- pkg/runtime/quota/validation.go | 43 ++++++ 8 files changed, 511 insertions(+), 62 deletions(-) create mode 100644 internal/webhook/tenant/validation/rule_quota_test.go create mode 100644 pkg/runtime/quota/validation.go diff --git a/e2e/rules_quota_test.go b/e2e/rules_quota_test.go index f90f06a6..bd4aaadb 100644 --- a/e2e/rules_quota_test.go +++ b/e2e/rules_quota_test.go @@ -389,9 +389,73 @@ var _ = Describe("rule-generated GlobalResourceQuota", Ordered, Label("resourceq Expect(failed).To(Equal(5)) }) - It("keeps the generated quota identity when rules are reordered and limits change", func() { + It("rejects invalid rule limits and projects valid changes to every namespace", func() { quotaKey := clientKey("", tenantutils.RuleGlobalResourceQuotaName(tnt, "service-count")) marker := "e2e.projectcapsule.dev/stable-identity" + resourceName := corev1.ResourceServices + setServiceLimit := func(limit string) error { + current := &capsulev1beta2.Tenant{} + if err := k8sClient.Get(ctx, clientKey("", tenantName), current); err != nil { + return err + } + + var serviceRule *rules.NamespaceRuleBodyTenant + remaining := make([]*rules.NamespaceRuleBodyTenant, 0, len(current.Spec.Rules)-1) + for _, rule := range current.Spec.Rules { + if rule != nil && len(rule.Quota) == 1 && rule.Quota[0].Name == "service-count" { + serviceRule = rule + + continue + } + remaining = append(remaining, rule) + } + if serviceRule == nil { + return fmt.Errorf("service-count quota rule was not found") + } + + serviceRule.Quota[0].Hard[resourceName] = resource.MustParse(limit) + // Moving the updated rule also verifies that its name, rather than its + // position, remains the generated quota's durable identity. + current.Spec.Rules = append([]*rules.NamespaceRuleBodyTenant{serviceRule}, remaining...) + + return k8sClient.Update(ctx, current) + } + expectGlobalQuota := func(hard, used, available string) { + Eventually(func(g Gomega) { + current := &capsulev1beta2.GlobalResourceQuota{} + g.Expect(k8sClient.Get(ctx, quotaKey, current)).To(Succeed()) + g.Expect(current.Annotations).To(HaveKeyWithValue(marker, "preserved")) + g.Expect(current.Labels).To(HaveKeyWithValue(meta.RuleQuotaLabel, "service-count")) + g.Expect(current.Status.ObservedGeneration).To(Equal(current.Generation)) + + actualHard := current.Spec.Quota.Hard[resourceName] + g.Expect(actualHard.Cmp(resource.MustParse(hard))).To(Equal(0)) + statusHard := current.Status.Total.Hard[resourceName] + g.Expect(statusHard.Cmp(resource.MustParse(hard))).To(Equal(0)) + actualUsed := current.Status.Total.Used[resourceName] + g.Expect(actualUsed.Cmp(resource.MustParse(used))).To(Equal(0)) + actualAvailable := current.Status.Total.Available[resourceName] + g.Expect(actualAvailable.Cmp(resource.MustParse(available))).To(Equal(0)) + }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) + } + expectNamespaceRemaining := func(remaining string) { + current := &capsulev1beta2.GlobalResourceQuota{} + Expect(k8sClient.Get(ctx, quotaKey, current)).To(Succeed()) + + for _, namespace := range []string{serviceA, serviceB} { + Eventually(func(g Gomega) { + quota := &corev1.ResourceQuota{} + g.Expect(k8sClient.Get(ctx, types.NamespacedName{ + Namespace: namespace, + Name: current.GetResourceQuotaName(), + }, quota)).To(Succeed()) + + hard := quota.Spec.Hard[resourceName] + hard.Sub(quota.Status.Used[resourceName]) + g.Expect(hard.Cmp(resource.MustParse(remaining))).To(Equal(0)) + }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) + } + } Eventually(func() error { current := &capsulev1beta2.GlobalResourceQuota{} @@ -406,38 +470,39 @@ var _ = Describe("rule-generated GlobalResourceQuota", Ordered, Label("resourceq return k8sClient.Update(ctx, current) }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) + // The concurrent admission scenario immediately before this test creates + // exactly five Services. Wait for native quota accounting before changing + // the shared limit. + expectGlobalQuota("5", "5", "0") + + By("rejecting a Tenant rule decrease below current usage", func() { + err := setServiceLimit("3") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring(`hard["services"] cannot be reduced to 3 while 5 is allocated`)) + + // Admission rejects the Tenant update before the generated quota can + // become stale or diverge from its source rule. + expectGlobalQuota("5", "5", "0") + expectNamespaceRemaining("0") + }) + Eventually(func() error { - current := &capsulev1beta2.Tenant{} - if err := k8sClient.Get(ctx, clientKey("", tenantName), current); err != nil { - return err - } - - var serviceRule *rules.NamespaceRuleBodyTenant - remaining := make([]*rules.NamespaceRuleBodyTenant, 0, len(current.Spec.Rules)-1) - for _, rule := range current.Spec.Rules { - if rule != nil && len(rule.Quota) == 1 && rule.Quota[0].Name == "service-count" { - serviceRule = rule - continue - } - remaining = append(remaining, rule) - } - if serviceRule == nil { - return fmt.Errorf("service-count quota rule was not found") - } - serviceRule.Quota[0].Hard[corev1.ResourceServices] = resource.MustParse("6") - current.Spec.Rules = append([]*rules.NamespaceRuleBodyTenant{serviceRule}, remaining...) - - return k8sClient.Update(ctx, current) + return setServiceLimit("7") }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) - Eventually(func(g Gomega) { - current := &capsulev1beta2.GlobalResourceQuota{} - g.Expect(k8sClient.Get(ctx, quotaKey, current)).To(Succeed()) - g.Expect(current.Annotations).To(HaveKeyWithValue(marker, "preserved")) - g.Expect(current.Labels).To(HaveKeyWithValue(meta.RuleQuotaLabel, "service-count")) - actual := current.Spec.Quota.Hard[corev1.ResourceServices] - g.Expect(actual.Cmp(resource.MustParse("6"))).To(Equal(0)) + By("exposing the newly available capacity in every namespaced ResourceQuota", func() { + expectGlobalQuota("7", "5", "2") + expectNamespaceRemaining("2") + }) + + Eventually(func() error { + return setServiceLimit("5") }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) + + By("allowing a decrease exactly to current usage", func() { + expectGlobalQuota("5", "5", "0") + expectNamespaceRemaining("0") + }) }) }) diff --git a/internal/controllers/globalresourcequotas/controller.go b/internal/controllers/globalresourcequotas/controller.go index 434c4df0..bee5f38a 100644 --- a/internal/controllers/globalresourcequotas/controller.go +++ b/internal/controllers/globalresourcequotas/controller.go @@ -128,15 +128,15 @@ func (r *Controller) reconcile( return nil, false, err } - if err := r.syncResourceQuotas(ctx, instance, namespaces); err != nil { - return nil, false, err - } - status, initialized, err := r.observeUsage(ctx, instance, namespaces) if err != nil { return nil, false, err } + if err := r.syncResourceQuotas(ctx, instance, namespaces, status); err != nil { + return status, false, err + } + ledger, err := r.ensureLedger(ctx, instance) if err != nil { return status, false, err @@ -160,6 +160,7 @@ func (r *Controller) syncResourceQuotas( ctx context.Context, instance *capsulev1beta2.GlobalResourceQuota, namespaces []corev1.Namespace, + status *capsulev1beta2.GlobalResourceQuotaStatus, ) error { selected := make(map[string]struct{}, len(namespaces)) @@ -184,7 +185,7 @@ func (r *Controller) syncResourceQuotas( targetLabels[meta.NewManagedByCapsuleLabel] = meta.ValueController targetLabels[meta.GlobalResourceQuotaLabel] = instance.Name target.SetLabels(targetLabels) - target.Spec = *instance.Spec.Quota.DeepCopy() + target.Spec = projectedResourceQuotaSpec(instance.Spec.Quota, status, namespace.Name) return controllerutil.SetControllerReference(instance, target, r.Scheme()) }) @@ -221,6 +222,40 @@ func (r *Controller) syncResourceQuotas( return nil } +// projectedResourceQuotaSpec gives every selected namespace access to the +// quota which is still available globally, while retaining that namespace's +// already-observed usage in its native ResourceQuota hard limit. Consequently +// Spec.Hard-Status.Used exposes the same remaining capacity in every +// namespace. When the global quota is exhausted or over limit, Hard is pinned +// to the namespace's current usage so native ResourceQuota admission blocks +// further consumption. +func projectedResourceQuotaSpec( + quota corev1.ResourceQuotaSpec, + status *capsulev1beta2.GlobalResourceQuotaStatus, + namespace string, +) corev1.ResourceQuotaSpec { + desired := *quota.DeepCopy() + desired.Hard = make(corev1.ResourceList, len(quota.Hard)) + + var namespaceUsed corev1.ResourceList + if status != nil { + namespaceUsed = status.NamespaceUsage[namespace].Used + } + + for name, hard := range quota.Hard { + available := hard.DeepCopy() + if status != nil { + available = status.Total.Available[name].DeepCopy() + } + + projected := namespaceUsed[name].DeepCopy() + projected.Add(available) + desired.Hard[name] = projected + } + + return desired +} + func (r *Controller) observeUsage( ctx context.Context, instance *capsulev1beta2.GlobalResourceQuota, diff --git a/internal/controllers/globalresourcequotas/controller_test.go b/internal/controllers/globalresourcequotas/controller_test.go index fb6a455b..3f842693 100644 --- a/internal/controllers/globalresourcequotas/controller_test.go +++ b/internal/controllers/globalresourcequotas/controller_test.go @@ -149,6 +149,47 @@ func TestMatchingNamespaceSelectorsUseOR(t *testing.T) { } } +func TestProjectedResourceQuotaSpecExposesGlobalRemainingCapacity(t *testing.T) { + t.Parallel() + + quota := corev1.ResourceQuotaSpec{ + Hard: corev1.ResourceList{ + corev1.ResourceRequestsCPU: resource.MustParse("10"), + corev1.ResourceRequestsMemory: resource.MustParse("4Gi"), + }, + Scopes: []corev1.ResourceQuotaScope{corev1.ResourceQuotaScopeNotTerminating}, + } + status := &capsulev1beta2.GlobalResourceQuotaStatus{ + Total: capsulev1beta2.GlobalResourceQuotaUsage{ + Available: corev1.ResourceList{ + corev1.ResourceRequestsCPU: resource.MustParse("4"), + corev1.ResourceRequestsMemory: resource.MustParse("0"), + }, + }, + NamespaceUsage: capsulev1beta2.GlobalResourceQuotaNamespaceUsage{ + "team-a": { + Used: corev1.ResourceList{ + corev1.ResourceRequestsCPU: resource.MustParse("2"), + corev1.ResourceRequestsMemory: resource.MustParse("5Gi"), + }, + }, + }, + } + + projected := projectedResourceQuotaSpec(quota, status, "team-a") + + assertResource(t, projected.Hard, corev1.ResourceRequestsCPU, "6") + assertResource(t, projected.Hard, corev1.ResourceRequestsMemory, "5Gi") + if len(projected.Scopes) != 1 || projected.Scopes[0] != corev1.ResourceQuotaScopeNotTerminating { + t.Fatalf("projected scopes = %#v, want NotTerminating", projected.Scopes) + } + + // The desired shared limit remains immutable while projecting a namespace's + // native hard values. + assertResource(t, quota.Hard, corev1.ResourceRequestsCPU, "10") + assertResource(t, quota.Hard, corev1.ResourceRequestsMemory, "4Gi") +} + func observedResourceQuota( quota *capsulev1beta2.GlobalResourceQuota, namespace string, diff --git a/internal/webhook/globalresourcequota/calculation.go b/internal/webhook/globalresourcequota/calculation.go index c296d36c..bd14ee9e 100644 --- a/internal/webhook/globalresourcequota/calculation.go +++ b/internal/webhook/globalresourcequota/calculation.go @@ -310,31 +310,7 @@ func validateGlobalResourceQuota(quota *capsulev1beta2.GlobalResourceQuota) erro } func validateHardLimit(hard, allocated corev1.ResourceList) error { - for name, usage := range allocated { - if usage.Sign() <= 0 { - continue - } - - limit, exists := hard[name] - if !exists { - return fmt.Errorf( - "spec.quota.hard[%q] cannot be removed while %s is allocated", - name, - usage.String(), - ) - } - - if limit.Cmp(usage) < 0 { - return fmt.Errorf( - "spec.quota.hard[%q] cannot be reduced to %s while %s is allocated", - name, - limit.String(), - usage.String(), - ) - } - } - - return nil + return runtimequota.ValidateHardLimit("spec.quota.hard", hard, allocated) } func isManagedResourceQuota(req admission.Request, object any) bool { diff --git a/internal/webhook/globalresourcequota/calculation_test.go b/internal/webhook/globalresourcequota/calculation_test.go index 5ff2b395..2d6c50c4 100644 --- a/internal/webhook/globalresourcequota/calculation_test.go +++ b/internal/webhook/globalresourcequota/calculation_test.go @@ -5,6 +5,7 @@ package globalresourcequota import ( "context" + "encoding/json" "fmt" "sync" "sync/atomic" @@ -272,6 +273,37 @@ func TestValidateHardLimitAgainstAllocatedUsage(t *testing.T) { } } +func TestRuleManagedGlobalResourceQuotaCannotBeReducedBelowUsage(t *testing.T) { + t.Parallel() + + oldQuota := globalQuotaForTest("tenant-a-shared", corev1.ResourceList{ + corev1.ResourceRequestsCPU: resource.MustParse("3"), + }) + oldQuota.Status.Total.Used = corev1.ResourceList{ + corev1.ResourceRequestsCPU: resource.MustParse("3"), + } + controller := true + oldQuota.Labels = map[string]string{ + meta.NewManagedByCapsuleLabel: meta.ValueController, + meta.RuleQuotaLabel: "shared", + } + oldQuota.OwnerReferences = []metav1.OwnerReference{{ + APIVersion: capsulev1beta2.GroupVersion.String(), + Kind: "Tenant", + Name: "tenant-a", + UID: types.UID("tenant-a-uid"), + Controller: &controller, + }} + + newQuota := oldQuota.DeepCopy() + newQuota.Spec.Quota.Hard[corev1.ResourceRequestsCPU] = resource.MustParse("2") + + request := globalResourceQuotaUpdateRequest(t, oldQuota, newQuota) + if response := validateGlobalResourceQuotaRequest(context.Background(), ledgerClient(t), request); response == nil || response.Allowed { + t.Fatalf("managed rule quota decrease below usage was accepted: %#v", response) + } +} + func TestFormatExceededResources(t *testing.T) { t.Parallel() @@ -367,6 +399,29 @@ func globalQuotaForTest(name string, hard corev1.ResourceList) *capsulev1beta2.G } } +func globalResourceQuotaUpdateRequest( + t *testing.T, + oldQuota *capsulev1beta2.GlobalResourceQuota, + newQuota *capsulev1beta2.GlobalResourceQuota, +) admission.Request { + t.Helper() + + oldRaw, err := json.Marshal(oldQuota) + if err != nil { + t.Fatal(err) + } + newRaw, err := json.Marshal(newQuota) + if err != nil { + t.Fatal(err) + } + + return admission.Request{AdmissionRequest: admissionv1.AdmissionRequest{ + Operation: admissionv1.Update, + Object: runtime.RawExtension{Raw: newRaw}, + OldObject: runtime.RawExtension{Raw: oldRaw}, + }} +} + func reservationForTest( id string, delta corev1.ResourceList, diff --git a/internal/webhook/tenant/validation/rule_quota_test.go b/internal/webhook/tenant/validation/rule_quota_test.go new file mode 100644 index 00000000..55b34f41 --- /dev/null +++ b/internal/webhook/tenant/validation/rule_quota_test.go @@ -0,0 +1,135 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package validation + +import ( + "context" + "strings" + "testing" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + "github.com/projectcapsule/capsule/pkg/api/rules" + "github.com/projectcapsule/capsule/pkg/runtime/configuration" + tenantutils "github.com/projectcapsule/capsule/pkg/tenant" +) + +func TestValidateRuleQuotaUpdatesRejectsHardBelowUsageOrAllocation(t *testing.T) { + t.Setenv(configuration.EnvironmentControllerNamespace, "capsule-system") + + scheme := runtime.NewScheme() + if err := capsulev1beta2.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + + oldTenant := ruleQuotaTenant("5") + globalQuota := &capsulev1beta2.GlobalResourceQuota{ + ObjectMeta: metav1.ObjectMeta{ + Name: tenantutils.RuleGlobalResourceQuotaName(oldTenant, "services"), + UID: types.UID("global-quota-uid"), + }, + Status: capsulev1beta2.GlobalResourceQuotaStatus{ + Total: capsulev1beta2.GlobalResourceQuotaUsage{Used: corev1.ResourceList{ + corev1.ResourceServices: resource.MustParse("4"), + }}, + }, + } + + t.Run("observed usage", func(t *testing.T) { + reader := fake.NewClientBuilder().WithScheme(scheme).WithObjects(globalQuota).Build() + response := validateRuleQuotaUpdates(context.Background(), reader, ruleQuotaTenant("3"), oldTenant) + assertRuleQuotaDenied(t, response, "cannot be reduced to 3 while 4 is allocated") + }) + + t.Run("inflight allocation", func(t *testing.T) { + quota := globalQuota.DeepCopy() + quota.Status.Total.Used[corev1.ResourceServices] = resource.MustParse("2") + ledger := ruleQuotaLedger(quota, "4") + reader := fake.NewClientBuilder().WithScheme(scheme).WithObjects(quota, ledger).Build() + + response := validateRuleQuotaUpdates(context.Background(), reader, ruleQuotaTenant("3"), oldTenant) + assertRuleQuotaDenied(t, response, "cannot be reduced to 3 while 4 is allocated") + }) + + t.Run("equal to allocated", func(t *testing.T) { + quota := globalQuota.DeepCopy() + quota.Status.Total.Used[corev1.ResourceServices] = resource.MustParse("2") + ledger := ruleQuotaLedger(quota, "4") + reader := fake.NewClientBuilder().WithScheme(scheme).WithObjects(quota, ledger).Build() + + if response := validateRuleQuotaUpdates(context.Background(), reader, ruleQuotaTenant("4"), oldTenant); response != nil { + t.Fatalf("hard limit equal to allocated usage was rejected: %#v", response) + } + }) + + t.Run("unchanged", func(t *testing.T) { + reader := &failingReader{} + if response := validateRuleQuotaUpdates(context.Background(), reader, oldTenant.DeepCopy(), oldTenant); response != nil { + t.Fatalf("unchanged hard limit performed quota validation: %#v", response) + } + }) +} + +type failingReader struct{ client.Reader } + +func (f *failingReader) Get(context.Context, client.ObjectKey, client.Object, ...client.GetOption) error { + return context.Canceled +} + +func ruleQuotaTenant(hard string) *capsulev1beta2.Tenant { + return &capsulev1beta2.Tenant{ + ObjectMeta: metav1.ObjectMeta{Name: "tenant-a", UID: types.UID("tenant-a-uid")}, + Spec: capsulev1beta2.TenantSpec{Rules: []*rules.NamespaceRuleBodyTenant{{ + NamespaceRuleBodyNamespace: &rules.NamespaceRuleBodyNamespace{ + Quota: []rules.ResourceQuotaRule{{ + Name: "services", + ResourceQuotaSpec: corev1.ResourceQuotaSpec{Hard: corev1.ResourceList{ + corev1.ResourceServices: resource.MustParse(hard), + }}, + }}, + }, + }}}, + } +} + +func ruleQuotaLedger( + quota *capsulev1beta2.GlobalResourceQuota, + allocated string, +) *capsulev1beta2.QuantityLedger { + return &capsulev1beta2.QuantityLedger{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: configuration.ControllerNamespace(), + Name: quota.GetLedgerName(), + }, + Spec: capsulev1beta2.QuantityLedgerSpec{ + TargetRef: capsulev1beta2.QuantityLedgerTargetRef{UID: quota.UID}, + }, + Status: capsulev1beta2.QuantityLedgerStatus{ + ResourceQuota: &capsulev1beta2.QuantityLedgerResourceQuotaStatus{ + Allocated: corev1.ResourceList{ + corev1.ResourceServices: resource.MustParse(allocated), + }, + }, + }, + } +} + +func assertRuleQuotaDenied(t *testing.T, response *admission.Response, message string) { + t.Helper() + + if response == nil || response.Allowed || response.Result == nil { + t.Fatal("rule quota update was accepted") + } + if !strings.Contains(response.Result.Message, message) { + t.Fatalf("denial message = %q, want substring %q", response.Result.Message, message) + } +} diff --git a/internal/webhook/tenant/validation/rule_validator.go b/internal/webhook/tenant/validation/rule_validator.go index f7b41ca3..20dbc476 100644 --- a/internal/webhook/tenant/validation/rule_validator.go +++ b/internal/webhook/tenant/validation/rule_validator.go @@ -7,6 +7,9 @@ import ( "context" "fmt" + corev1 "k8s.io/api/core/v1" + apiequality "k8s.io/apimachinery/pkg/api/equality" + apierrors "k8s.io/apimachinery/pkg/api/errors" k8smeta "k8s.io/apimachinery/pkg/api/meta" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/webhook/admission" @@ -15,8 +18,10 @@ import ( "github.com/projectcapsule/capsule/pkg/api/rules" "github.com/projectcapsule/capsule/pkg/ruleengine" ad "github.com/projectcapsule/capsule/pkg/runtime/admission" + "github.com/projectcapsule/capsule/pkg/runtime/configuration" "github.com/projectcapsule/capsule/pkg/runtime/events" "github.com/projectcapsule/capsule/pkg/runtime/handlers" + runtimequota "github.com/projectcapsule/capsule/pkg/runtime/quota" tenantutils "github.com/projectcapsule/capsule/pkg/tenant" ) @@ -59,20 +64,114 @@ func (h *RuleValidationHandler) OnDelete( } func (h *RuleValidationHandler) OnUpdate( - _ client.Client, - _ client.Reader, + c client.Client, + reader client.Reader, tnt *capsulev1beta2.Tenant, old *capsulev1beta2.Tenant, decoder admission.Decoder, _ events.EventRecorder, ) handlers.Func { - return func(_ context.Context, req admission.Request) *admission.Response { + return func(ctx context.Context, req admission.Request) *admission.Response { if response := h.handle(tnt, req); response != nil { return response } + if reader == nil { + reader = c + } + + return validateRuleQuotaUpdates(ctx, reader, tnt, old) + } +} + +func validateRuleQuotaUpdates( + ctx context.Context, + reader client.Reader, + tnt *capsulev1beta2.Tenant, + old *capsulev1beta2.Tenant, +) *admission.Response { + if reader == nil || tnt == nil || old == nil { return nil } + + oldHard := make(map[string]corev1.ResourceList) + + for _, rule := range old.Spec.Rules { + if rule == nil || rule.NamespaceRuleBodyNamespace == nil { + continue + } + + for _, quota := range rule.Quota { + oldHard[quota.Name] = quota.Hard + } + } + + for ruleIndex, rule := range tnt.Spec.Rules { + if rule == nil || rule.NamespaceRuleBodyNamespace == nil { + continue + } + + for quotaIndex, quota := range rule.Quota { + previous, existed := oldHard[quota.Name] + if !existed || apiequality.Semantic.DeepEqual(previous, quota.Hard) { + continue + } + + globalQuota := &capsulev1beta2.GlobalResourceQuota{} + if err := reader.Get(ctx, client.ObjectKey{ + Name: tenantutils.RuleGlobalResourceQuotaName(tnt, quota.Name), + }, globalQuota); err != nil { + if apierrors.IsNotFound(err) { + continue + } + + return ad.ErroredResponse(fmt.Errorf( + "cannot validate rules[%d].quota[%d] against its GlobalResourceQuota: %w", + ruleIndex, + quotaIndex, + err, + )) + } + + path := fmt.Sprintf("rules[%d].quota[%d].hard", ruleIndex, quotaIndex) + if err := runtimequota.ValidateHardLimit(path, quota.Hard, globalQuota.Status.Total.Used); err != nil { + return ad.Deny(err.Error()) + } + + ledger := &capsulev1beta2.QuantityLedger{} + + err := reader.Get(ctx, client.ObjectKey{ + Namespace: configuration.ControllerNamespace(), + Name: globalQuota.GetLedgerName(), + }, ledger) + if apierrors.IsNotFound(err) { + continue + } + + if err != nil { + return ad.ErroredResponse(fmt.Errorf( + "cannot validate rules[%d].quota[%d] against its QuantityLedger: %w", + ruleIndex, + quotaIndex, + err, + )) + } + + if ledger.Spec.TargetRef.UID != globalQuota.UID || ledger.Status.ResourceQuota == nil { + continue + } + + if err := runtimequota.ValidateHardLimit( + path, + quota.Hard, + ledger.Status.ResourceQuota.Allocated, + ); err != nil { + return ad.Deny(err.Error()) + } + } + } + + return nil } func (h *RuleValidationHandler) handle( diff --git a/pkg/runtime/quota/validation.go b/pkg/runtime/quota/validation.go new file mode 100644 index 00000000..e8d3941e --- /dev/null +++ b/pkg/runtime/quota/validation.go @@ -0,0 +1,43 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package quota + +import ( + "fmt" + + corev1 "k8s.io/api/core/v1" +) + +// ValidateHardLimit rejects removal or reduction of a hard resource below an +// already allocated quantity. Path identifies the hard-limit field in the +// returned validation error. +func ValidateHardLimit(path string, hard, allocated corev1.ResourceList) error { + for name, usage := range allocated { + if usage.Sign() <= 0 { + continue + } + + limit, exists := hard[name] + if !exists { + return fmt.Errorf( + "%s[%q] cannot be removed while %s is allocated", + path, + name, + usage.String(), + ) + } + + if limit.Cmp(usage) < 0 { + return fmt.Errorf( + "%s[%q] cannot be reduced to %s while %s is allocated", + path, + name, + limit.String(), + usage.String(), + ) + } + } + + return nil +}