feat: add globalresourcequota api (#2068)

* feat: add globalresourcequota api

Signed-off-by: Oliver Baehler <oliver@sudo-i.net>
This commit is contained in:
Oliver Bähler
2026-08-10 21:25:10 +02:00
committed by GitHub
parent d92453427c
commit bdcdcefe63
60 changed files with 6804 additions and 107 deletions
+3
View File
@@ -22,6 +22,8 @@ const (
ResourcePoolLabel = "projectcapsule.dev/pool"
GlobalResourceQuotaLabel = "projectcapsule.dev/global-resource-quota"
FreezeLabel = "projectcapsule.dev/freeze"
OwnerPromotionLabel = "owner.projectcapsule.dev/promote"
@@ -41,6 +43,7 @@ const (
LimitRangeLabel = "capsule.clastix.io/limit-range"
NetworkPolicyLabel = "capsule.clastix.io/network-policy"
ResourceQuotaLabel = "capsule.clastix.io/resource-quota"
RuleQuotaLabel = "projectcapsule.dev/rule-quota"
RolebindingLabel = "capsule.clastix.io/role-binding"
)
+2
View File
@@ -26,6 +26,7 @@ func NewManagedMetadata(
TenantLabel,
NewTenantLabel,
ResourcePoolLabel,
GlobalResourceQuotaLabel,
FreezeLabel,
OwnerPromotionLabel,
ServiceAccountPromotionLabel,
@@ -38,6 +39,7 @@ func NewManagedMetadata(
LimitRangeLabel,
NetworkPolicyLabel,
ResourceQuotaLabel,
RuleQuotaLabel,
RolebindingLabel,
),
annotations: stringSet(
+22
View File
@@ -0,0 +1,22 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package rules
import corev1 "k8s.io/api/core/v1"
// ResourceQuotaRule defines a named ResourceQuota specification generated by a
// Tenant rule. Name is the durable identity of the generated
// GlobalResourceQuota and must be unique across all rules of a Tenant.
// +kubebuilder:object:generate=true
type ResourceQuotaRule struct {
corev1.ResourceQuotaSpec `json:",inline"`
// Name is the stable identity of this quota within the Tenant. Changing the
// name replaces the generated GlobalResourceQuota; changing the quota or its
// namespace selector updates the existing object.
// +kubebuilder:validation:MinLength=1
// +kubebuilder:validation:MaxLength=63
// +kubebuilder:validation:Pattern=`^[a-z0-9]([-a-z0-9]*[a-z0-9])?$`
Name string `json:"name"`
}
+9 -3
View File
@@ -3,9 +3,7 @@
package rules
import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
// For future implementation where users might manage RuleStatus CRs themselves
// +kubebuilder:object:generate=true
@@ -15,6 +13,14 @@ type NamespaceRuleBodyNamespace struct {
// +optional
Audience []Audience `json:"audience,omitempty"`
// Quota contains native Kubernetes ResourceQuota specifications shared by
// all namespaces selected by this rule. Unlike Enforce, quota accounting is
// independent of the request audience.
// +optional
// +listType=map
// +listMapKey=name
Quota []ResourceQuotaRule `json:"quota,omitempty"`
// Enforcement for given rule
//+optional
Enforce *NamespaceRuleEnforceBody `json:"enforce,omitzero"`
+23
View File
@@ -99,6 +99,13 @@ func (in *NamespaceRuleBodyNamespace) DeepCopyInto(out *NamespaceRuleBodyNamespa
*out = make([]Audience, len(*in))
copy(*out, *in)
}
if in.Quota != nil {
in, out := &in.Quota, &out.Quota
*out = make([]ResourceQuotaRule, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
if in.Enforce != nil {
in, out := &in.Enforce, &out.Enforce
*out = new(NamespaceRuleEnforceBody)
@@ -347,6 +354,22 @@ func (in *OCIRegistry) DeepCopy() *OCIRegistry {
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ResourceQuotaRule) DeepCopyInto(out *ResourceQuotaRule) {
*out = *in
in.ResourceQuotaSpec.DeepCopyInto(&out.ResourceQuotaSpec)
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ResourceQuotaRule.
func (in *ResourceQuotaRule) DeepCopy() *ResourceQuotaRule {
if in == nil {
return nil
}
out := new(ResourceQuotaRule)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ServiceExternalNameRule) DeepCopyInto(out *ServiceExternalNameRule) {
*out = *in
+43
View File
@@ -21,6 +21,8 @@ func ValidateRuleStatusBody(
mapper k8smeta.RESTMapper,
bodies []*rules.NamespaceRuleBodyNamespace,
) error {
quotaNames := make(map[string]string)
for i, rule := range bodies {
if rule == nil {
continue
@@ -30,6 +32,10 @@ func ValidateRuleStatusBody(
return err
}
if err := validateQuotaRules(i, rule.Quota, quotaNames); err != nil {
return err
}
if rule.Enforce == nil {
continue
}
@@ -54,6 +60,43 @@ func ValidateRuleStatusBody(
return nil
}
func validateQuotaRules(ruleIndex int, quotas []rules.ResourceQuotaRule, names map[string]string) error {
for quotaIndex, quota := range quotas {
path := fmt.Sprintf("rules[%d].quota[%d]", ruleIndex, quotaIndex)
if errs := k8svalidation.IsDNS1123Label(quota.Name); len(errs) > 0 {
return fmt.Errorf("%s.name %q is invalid: %s", path, quota.Name, strings.Join(errs, "; "))
}
if previous, found := names[quota.Name]; found {
return fmt.Errorf(
"%s.name %q is invalid: quota name is already used by %s",
path,
quota.Name,
previous,
)
}
names[quota.Name] = path
if len(quota.Hard) == 0 {
return fmt.Errorf("%s.hard is invalid: at least one resource is required", path)
}
for name, quantity := range quota.Hard {
if quantity.Sign() < 0 {
return fmt.Errorf(
"rules[%d].quota[%d].hard[%q] is invalid: quantity must not be negative",
ruleIndex,
quotaIndex,
name,
)
}
}
}
return nil
}
func validateIngressRules(
ruleIndex int,
ingress rules.NamespaceRuleEnforceIngressBody,
+69
View File
@@ -7,7 +7,9 @@ import (
"strings"
"testing"
corev1 "k8s.io/api/core/v1"
apimeta "k8s.io/apimachinery/pkg/api/meta"
"k8s.io/apimachinery/pkg/api/resource"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/utils/ptr"
@@ -37,6 +39,73 @@ func TestValidateRuleStatusBody(t *testing.T) {
},
},
},
{
name: "valid quota-only rule",
mapper: mapper,
bodies: []*rules.NamespaceRuleBodyNamespace{{
Quota: []rules.ResourceQuotaRule{{
Name: "shared-compute",
ResourceQuotaSpec: corev1.ResourceQuotaSpec{Hard: corev1.ResourceList{
corev1.ResourceRequestsCPU: resource.MustParse("8"),
}},
}},
}},
},
{
name: "quota name is required",
mapper: mapper,
bodies: []*rules.NamespaceRuleBodyNamespace{{
Quota: []rules.ResourceQuotaRule{{
ResourceQuotaSpec: corev1.ResourceQuotaSpec{Hard: corev1.ResourceList{
corev1.ResourceRequestsCPU: resource.MustParse("8"),
}},
}},
}},
wantErr: "rules[0].quota[0].name",
},
{
name: "quota name must be a DNS label",
mapper: mapper,
bodies: []*rules.NamespaceRuleBodyNamespace{{
Quota: []rules.ResourceQuotaRule{{
Name: "Shared_Compute",
ResourceQuotaSpec: corev1.ResourceQuotaSpec{Hard: corev1.ResourceList{
corev1.ResourceRequestsCPU: resource.MustParse("8"),
}},
}},
}},
wantErr: `rules[0].quota[0].name "Shared_Compute" is invalid`,
},
{
name: "quota hard must not be empty",
mapper: mapper,
bodies: []*rules.NamespaceRuleBodyNamespace{{
Quota: []rules.ResourceQuotaRule{{Name: "shared-compute"}},
}},
wantErr: "rules[0].quota[0].hard is invalid",
},
{
name: "quota hard must not be negative",
mapper: mapper,
bodies: []*rules.NamespaceRuleBodyNamespace{{
Quota: []rules.ResourceQuotaRule{{
Name: "shared-compute",
ResourceQuotaSpec: corev1.ResourceQuotaSpec{Hard: corev1.ResourceList{
corev1.ResourceRequestsCPU: resource.MustParse("-1"),
}},
}},
}},
wantErr: `rules[0].quota[0].hard["requests.cpu"] is invalid`,
},
{
name: "quota names must be unique across rules",
mapper: mapper,
bodies: []*rules.NamespaceRuleBodyNamespace{
{Quota: []rules.ResourceQuotaRule{{Name: "shared-compute", ResourceQuotaSpec: corev1.ResourceQuotaSpec{Hard: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("1")}}}}},
{Quota: []rules.ResourceQuotaRule{{Name: "shared-compute", ResourceQuotaSpec: corev1.ResourceQuotaSpec{Hard: corev1.ResourceList{corev1.ResourceMemory: resource.MustParse("1Gi")}}}}},
},
wantErr: `rules[1].quota[0].name "shared-compute" is invalid: quota name is already used by rules[0].quota[0]`,
},
{
name: "valid workload service and metadata rules",
mapper: mapper,
+1 -7
View File
@@ -144,12 +144,6 @@ func positiveResource(resources corev1.ResourceList, name corev1.ResourceName) (
return quantity, true
}
//nolint:exhaustive
func isSupportedQoSComputeResource(name corev1.ResourceName) bool {
switch name {
case corev1.ResourceCPU, corev1.ResourceMemory:
return true
default:
return false
}
return name == corev1.ResourceCPU || name == corev1.ResourceMemory
}
+2 -2
View File
@@ -177,7 +177,7 @@ func BuildNamespaceAnnotationsForTenant(tnt *capsulev1beta2.Tenant) map[string]s
annotations[meta.AvailableIngressClassesAnnotation] = strings.Join(ic.Exact, ",")
}
//nolint:staticcheck
//nolint:staticcheck,nolintlint // Preserve annotations for the deprecated v1beta2 field until it is removed.
if len(ic.Regex) > 0 {
annotations[meta.AvailableIngressClassesRegexpAnnotation] = ic.Regex
}
@@ -188,7 +188,7 @@ func BuildNamespaceAnnotationsForTenant(tnt *capsulev1beta2.Tenant) map[string]s
annotations[meta.AvailableStorageClassesAnnotation] = strings.Join(sc.Exact, ",")
}
//nolint:staticcheck
//nolint:staticcheck,nolintlint // Preserve annotations for the deprecated v1beta2 field until it is removed.
if len(sc.Regex) > 0 {
annotations[meta.AvailableStorageClassesRegexpAnnotation] = sc.Regex
}
+61
View File
@@ -0,0 +1,61 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package tenant
import (
"fmt"
"strings"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
k8svalidation "k8s.io/apimachinery/pkg/util/validation"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
"github.com/projectcapsule/capsule/pkg/api/meta"
"github.com/projectcapsule/capsule/pkg/runtime/selectors"
)
func RuleGlobalResourceQuotaName(tnt *capsulev1beta2.Tenant, quotaName string) string {
return fmt.Sprintf("%s-%s", tnt.Name, quotaName)
}
func ValidateRuleGlobalResourceQuotaName(tnt *capsulev1beta2.Tenant, quotaName string) error {
name := RuleGlobalResourceQuotaName(tnt, quotaName)
if errs := k8svalidation.IsDNS1123Subdomain(name); len(errs) > 0 {
return fmt.Errorf("generated GlobalResourceQuota name %q is invalid: %s", name, strings.Join(errs, "; "))
}
return nil
}
func RuleGlobalResourceQuota(
tnt *capsulev1beta2.Tenant,
ruleIndex int,
itemIndex int,
) *capsulev1beta2.GlobalResourceQuota {
rule := tnt.Spec.Rules[ruleIndex]
quota := rule.Quota[itemIndex]
selector := &metav1.LabelSelector{}
if rule.NamespaceSelector != nil {
selector = rule.NamespaceSelector.DeepCopy()
}
if selector.MatchLabels == nil {
selector.MatchLabels = map[string]string{}
}
// Tenant membership is part of the generated selector so a rule can never
// consume quota from a namespace owned by another Tenant.
selector.MatchLabels[meta.TenantLabel] = tnt.Name
return &capsulev1beta2.GlobalResourceQuota{
ObjectMeta: metav1.ObjectMeta{
Name: RuleGlobalResourceQuotaName(tnt, quota.Name),
},
Spec: capsulev1beta2.GlobalResourceQuotaSpec{
NamespaceSelectors: []selectors.NamespaceSelector{{LabelSelector: selector}},
Quota: *quota.ResourceQuotaSpec.DeepCopy(),
},
}
}
+83
View File
@@ -0,0 +1,83 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package tenant
import (
"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/types"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
"github.com/projectcapsule/capsule/pkg/api/meta"
"github.com/projectcapsule/capsule/pkg/api/rules"
)
func TestRuleGlobalResourceQuota(t *testing.T) {
t.Parallel()
tnt := &capsulev1beta2.Tenant{
ObjectMeta: metav1.ObjectMeta{Name: "tenant-a", UID: types.UID("tenant-uid")},
Spec: capsulev1beta2.TenantSpec{Rules: []*rules.NamespaceRuleBodyTenant{{
NamespaceRuleBodyNamespace: &rules.NamespaceRuleBodyNamespace{
Quota: []rules.ResourceQuotaRule{{
Name: "shared-compute",
ResourceQuotaSpec: corev1.ResourceQuotaSpec{Hard: corev1.ResourceList{
corev1.ResourceRequestsMemory: resource.MustParse("16Gi"),
}},
}},
},
NamespaceSelector: &metav1.LabelSelector{MatchLabels: map[string]string{"tier": "paid"}},
}}},
}
quota := RuleGlobalResourceQuota(tnt, 0, 0)
if quota.Name != RuleGlobalResourceQuotaName(tnt, "shared-compute") {
t.Fatalf("generated name = %q, want deterministic helper name", quota.Name)
}
if len(quota.Spec.NamespaceSelectors) != 1 {
t.Fatalf("namespace selectors = %d, want 1", len(quota.Spec.NamespaceSelectors))
}
selector := quota.Spec.NamespaceSelectors[0].LabelSelector
if selector.MatchLabels["tier"] != "paid" || selector.MatchLabels[meta.TenantLabel] != tnt.Name {
t.Fatalf("generated selector = %#v, want tenant and rule labels", selector)
}
if got := quota.Spec.Quota.Hard[corev1.ResourceRequestsMemory]; got.Cmp(resource.MustParse("16Gi")) != 0 {
t.Fatalf("generated quota hard = %s, want 16Gi", got.String())
}
}
func TestRuleGlobalResourceQuotaNameIsStableAcrossRuleChanges(t *testing.T) {
t.Parallel()
tnt := &capsulev1beta2.Tenant{
ObjectMeta: metav1.ObjectMeta{Name: "tenant-a", UID: types.UID("tenant-uid")},
}
want := RuleGlobalResourceQuotaName(tnt, "shared-compute")
if want != "tenant-a-shared-compute" {
t.Fatalf("generated name = %q, want Tenant and quota name", want)
}
if got := RuleGlobalResourceQuotaName(tnt, "shared-compute"); got != want {
t.Fatalf("same quota name generated %q, want %q", got, want)
}
if got := RuleGlobalResourceQuotaName(tnt, "service-count"); got == want {
t.Fatalf("different quota names generated the same object name %q", got)
}
recreated := tnt.DeepCopy()
recreated.UID = types.UID("replacement-tenant-uid")
if got := RuleGlobalResourceQuotaName(recreated, "shared-compute"); got != want {
t.Fatalf("recreated Tenant generated %q, want stable name %q", got, want)
}
longTenant := tnt.DeepCopy()
longTenant.Name = strings.Repeat("a", 250)
if err := ValidateRuleGlobalResourceQuotaName(longTenant, "shared-compute"); err == nil {
t.Fatal("overlong generated name was accepted")
}
}
+7 -1
View File
@@ -85,7 +85,12 @@ func BuildNamespaceRuleBodyStatus(
continue
}
selected = append(selected, body.DeepCopy())
statusBody := body.DeepCopy()
// Quotas are Tenant-level inputs used to generate cluster-scoped
// GlobalResourceQuotas. They are not evaluated from per-namespace
// RuleStatus objects and must not be projected into them.
statusBody.Quota = nil
selected = append(selected, statusBody)
}
rendered, err := template.RenderNamespaceRuleBodies(
@@ -104,6 +109,7 @@ func BuildNamespaceRuleBodyStatus(
continue
}
body.Quota = nil
out = append(out, body)
}
+9 -1
View File
@@ -62,7 +62,8 @@ func TestBuildNamespaceRuleBodyStatus(t *testing.T) {
"{{ .namespace.status.phase }}",
}}},
},
}}
}, Quota: []rules.ResourceQuotaRule{{Name: "shared-compute"}}}
quotaOnly := &rules.NamespaceRuleBodyNamespace{Quota: []rules.ResourceQuotaRule{{Name: "object-counts"}}}
unmatched := &rules.NamespaceRuleBodyNamespace{Enforce: &rules.NamespaceRuleEnforceBody{Action: rules.ActionTypeDeny}}
tnt.Spec.Rules = []*rules.NamespaceRuleBodyTenant{
{
@@ -73,6 +74,10 @@ func TestBuildNamespaceRuleBodyStatus(t *testing.T) {
NamespaceRuleBodyNamespace: unmatched,
NamespaceSelector: &metav1.LabelSelector{MatchLabels: map[string]string{"env": "dev"}},
},
{
NamespaceRuleBodyNamespace: quotaOnly,
NamespaceSelector: &metav1.LabelSelector{MatchLabels: map[string]string{"env": "prod"}},
},
nil,
{},
}
@@ -88,6 +93,9 @@ func TestBuildNamespaceRuleBodyStatus(t *testing.T) {
if len(got) != 1 || got[0].Enforce.Action != rules.ActionTypeAudit {
t.Fatalf("BuildNamespaceRuleBodyStatus() = %#v, want one audit rule", got)
}
if len(got[0].Quota) != 0 {
t.Fatalf("BuildNamespaceRuleBodyStatus() quota = %#v, want none", got[0].Quota)
}
if !reflect.DeepEqual(got[0].Enforce.Workloads.Schedulers[0].Exact, []string{"Active", "Active"}) {
t.Fatalf("BuildNamespaceRuleBodyStatus() scheduler = %#v", got[0].Enforce.Workloads.Schedulers)
}