Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: fix quota overprovisioning #1333

Open
wants to merge 14 commits into
base: main
Choose a base branch
from
1 change: 0 additions & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ repos:
rev: v5.0.0
hooks:
- id: check-executables-have-shebangs
- id: check-yaml
- id: double-quote-string-fixer
- id: end-of-file-fixer
- id: trailing-whitespace
Expand Down
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,8 @@ generate: controller-gen
SRC_ROOT = $(shell git rev-parse --show-toplevel)

helm-controller-version:
$(eval VERSION := $(shell grep 'appVersion:' charts/capsule/Chart.yaml | awk '{print "v"$$2}'))
$(eval KO_TAGS := $(shell grep 'appVersion:' charts/capsule/Chart.yaml | awk '{print "v"$$2}'))
$(eval VERSION := $(shell grep 'appVersion:' charts/capsule/Chart.yaml | awk '{print $$2}'))
$(eval KO_TAGS := $(shell grep 'appVersion:' charts/capsule/Chart.yaml | awk '{print $$2}'))

helm-docs: helm-doc
$(HELM_DOCS) --chart-search-root ./charts
Expand Down
11 changes: 11 additions & 0 deletions PROJECT
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
# Code generated by tool. DO NOT EDIT.
# This file is used to track the info used to scaffold your project
# and allow the plugins properly work.
# More info: https://book.kubebuilder.io/reference/project-config.html
domain: clastix.io
layout:
- go.kubebuilder.io/v3
Expand Down Expand Up @@ -44,4 +48,11 @@ resources:
kind: GlobalTenantResource
path: github.com/projectcapsule/capsule/api/v1beta2
version: v1beta2
- api:
crdVersion: v1
domain: clastix.io
group: capsule
kind: GlobalResourceQuota
path: github.com/projectcapsule/capsule/api/v1beta2
version: v1beta2
version: "3"
112 changes: 112 additions & 0 deletions api/v1beta2/globalresourcequota_func.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
// Copyright 2020-2023 Project Capsule Authors.
// SPDX-License-Identifier: Apache-2.0

package v1beta2

import (
"fmt"
"sort"

corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/resource"

"github.com/projectcapsule/capsule/pkg/api"
)

func (g *GlobalResourceQuota) GetQuotaSpace(index api.Name) (corev1.ResourceList, error) {
quotaSpace := corev1.ResourceList{}

// First, check if quota exists in the status
if quotaStatus, exists := g.Status.Quota[index]; exists {
// Iterate over all resources in the status
for resourceName, hardValue := range quotaStatus.Hard {
usedValue, usedExists := quotaStatus.Used[resourceName]
if !usedExists {
usedValue = resource.MustParse("0") // Default to zero if no used value is found
}

// Compute remaining quota (hard - used)
remaining := hardValue.DeepCopy()
remaining.Sub(usedValue)

// Ensure we don't set negative values
if remaining.Sign() == -1 {
remaining.Set(0)
}

quotaSpace[resourceName] = remaining
}

return quotaSpace, nil
}

// If not in status, fall back to spec.Hard
if quotaSpec, exists := g.Spec.Items[index]; exists {
for resourceName, hardValue := range quotaSpec.Hard {
quotaSpace[resourceName] = hardValue.DeepCopy()
}

return quotaSpace, nil
}

return nil, fmt.Errorf("no item found")
}

func (g *GlobalResourceQuota) GetAggregatedQuotaSpace(index api.Name, used corev1.ResourceList) (corev1.ResourceList, error) {
quotaSpace := corev1.ResourceList{}

// First, check if quota exists in the status
if quotaStatus, exists := g.Status.Quota[index]; exists {
// Iterate over all resources in the status
for resourceName, hardValue := range quotaStatus.Hard {
usedValue, usedExists := quotaStatus.Used[resourceName]
if !usedExists {
usedValue = resource.MustParse("0") // Default to zero if no used value is found
}

// Compute remaining quota (hard - used)
remaining := hardValue.DeepCopy()
remaining.Sub(usedValue)

// Ensure we don't set negative values
if remaining.Sign() == -1 {
remaining.Set(0)
}

/// Add the remaining Quota with the used quota
if currentUsed, exists := used[resourceName]; exists {
remaining.Add(currentUsed)
}

quotaSpace[resourceName] = remaining
}

return quotaSpace, nil
}

// If not in status, fall back to spec.Hard
if quotaSpec, exists := g.Spec.Items[index]; exists {
for resourceName, hardValue := range quotaSpec.Hard {
quotaSpace[resourceName] = hardValue.DeepCopy()
}

return quotaSpace, nil
}

return nil, fmt.Errorf("no item found")
}

func (in *GlobalResourceQuota) AssignNamespaces(namespaces []corev1.Namespace) {

Check failure on line 99 in api/v1beta2/globalresourcequota_func.go

View workflow job for this annotation

GitHub Actions / lint

receiver-naming: receiver name in should be consistent with previous receiver name g for GlobalResourceQuota (revive)
var l []string

for _, ns := range namespaces {
if ns.Status.Phase == corev1.NamespaceActive {
l = append(l, ns.GetName())
}
}

sort.Strings(l)

in.Status.Namespaces = l
in.Status.Size = uint(len(l))
}
148 changes: 148 additions & 0 deletions api/v1beta2/globalresourcequota_func_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
package v1beta2_test

import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"

capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
"github.com/projectcapsule/capsule/pkg/api"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/resource"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)

var _ = Describe("GlobalResourceQuota", func() {

Context("GetQuotaSpace", func() {
var grq *capsulev1beta2.GlobalResourceQuota

BeforeEach(func() {
grq = &capsulev1beta2.GlobalResourceQuota{
Spec: capsulev1beta2.GlobalResourceQuotaSpec{
Items: map[api.Name]corev1.ResourceQuotaSpec{
"compute": {
Hard: corev1.ResourceList{
corev1.ResourceCPU: resource.MustParse("8"),
corev1.ResourceMemory: resource.MustParse("16Gi"),
},
},
},
},
Status: capsulev1beta2.GlobalResourceQuotaStatus{
Quota: map[api.Name]*corev1.ResourceQuotaStatus{
"compute": {
Hard: corev1.ResourceList{
corev1.ResourceCPU: resource.MustParse("10"),
corev1.ResourceMemory: resource.MustParse("32Gi"),
},
Used: corev1.ResourceList{
corev1.ResourceCPU: resource.MustParse("4"),
corev1.ResourceMemory: resource.MustParse("10Gi"),
},
},
},
},
}
})

It("should calculate available quota correctly when status exists", func() {
expected := corev1.ResourceList{
corev1.ResourceCPU: resource.MustParse("6"), // 10 - 4
corev1.ResourceMemory: resource.MustParse("22Gi"), // 32Gi - 10Gi
}

quotaSpace, _ := grq.GetQuotaSpace("compute")
Expect(quotaSpace).To(Equal(expected))
})

It("should return spec quota if status does not exist", func() {
expected := corev1.ResourceList{
corev1.ResourceCPU: resource.MustParse("8"),
corev1.ResourceMemory: resource.MustParse("16Gi"),
}

quotaSpace, _ := grq.GetQuotaSpace("network") // "network" is not in Status
Expect(quotaSpace).To(Equal(expected))
})

It("should handle cases where used quota is missing (default to 0)", func() {
grq.Status.Quota["compute"].Used = nil

expected := corev1.ResourceList{
corev1.ResourceCPU: resource.MustParse("10"), // 10 - 0
corev1.ResourceMemory: resource.MustParse("32Gi"), // 32Gi - 0
}

quotaSpace, _ := grq.GetQuotaSpace("compute")
Expect(quotaSpace).To(Equal(expected))
})

It("should return 0 quota if used exceeds hard limit", func() {
grq.Status.Quota["compute"].Used = corev1.ResourceList{
corev1.ResourceCPU: resource.MustParse("12"),
corev1.ResourceMemory: resource.MustParse("40Gi"),
}

expected := corev1.ResourceList{
corev1.ResourceCPU: resource.MustParse("0"), // Hard 10, Used 12 → should be 0
corev1.ResourceMemory: resource.MustParse("0"), // Hard 32, Used 40 → should be 0
}

quotaSpace, _ := grq.GetQuotaSpace("compute")
Expect(quotaSpace).To(Equal(expected))
})
})

Context("AssignNamespaces", func() {
var grq *capsulev1beta2.GlobalResourceQuota

BeforeEach(func() {
grq = &capsulev1beta2.GlobalResourceQuota{}
})

It("should assign only active namespaces and update status", func() {
namespaces := []corev1.Namespace{
{ObjectMeta: metav1.ObjectMeta{Name: "dev"}},
{ObjectMeta: metav1.ObjectMeta{Name: "staging"}, Status: corev1.NamespaceStatus{Phase: corev1.NamespaceActive}},
{ObjectMeta: metav1.ObjectMeta{Name: "prod"}, Status: corev1.NamespaceStatus{Phase: corev1.NamespaceActive}},
}

grq.AssignNamespaces(namespaces)

Expect(grq.Status.Namespaces).To(Equal([]string{"prod", "staging"})) // Sorted order
Expect(grq.Status.Size).To(Equal(uint(2)))
})

It("should handle empty namespace list", func() {
grq.AssignNamespaces([]corev1.Namespace{})

Expect(grq.Status.Namespaces).To(BeEmpty())
Expect(grq.Status.Size).To(Equal(uint(0)))
})

It("should ignore inactive namespaces", func() {
namespaces := []corev1.Namespace{
{ObjectMeta: metav1.ObjectMeta{Name: "inactive"}, Status: corev1.NamespaceStatus{Phase: corev1.NamespaceTerminating}},
{ObjectMeta: metav1.ObjectMeta{Name: "active"}, Status: corev1.NamespaceStatus{Phase: corev1.NamespaceActive}},
}

grq.AssignNamespaces(namespaces)

Expect(grq.Status.Namespaces).To(Equal([]string{"active"})) // Only active namespaces are assigned
Expect(grq.Status.Size).To(Equal(uint(1)))
})

It("should sort namespaces alphabetically", func() {
namespaces := []corev1.Namespace{
{ObjectMeta: metav1.ObjectMeta{Name: "zeta"}, Status: corev1.NamespaceStatus{Phase: corev1.NamespaceActive}},
{ObjectMeta: metav1.ObjectMeta{Name: "alpha"}, Status: corev1.NamespaceStatus{Phase: corev1.NamespaceActive}},
{ObjectMeta: metav1.ObjectMeta{Name: "beta"}, Status: corev1.NamespaceStatus{Phase: corev1.NamespaceActive}},
}

grq.AssignNamespaces(namespaces)

Expect(grq.Status.Namespaces).To(Equal([]string{"alpha", "beta", "zeta"}))
Expect(grq.Status.Size).To(Equal(uint(3)))
})
})
})
25 changes: 25 additions & 0 deletions api/v1beta2/globalresourcequota_status.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// Copyright 2020-2023 Project Capsule Authors.
// SPDX-License-Identifier: Apache-2.0

package v1beta2

import (
"github.com/projectcapsule/capsule/pkg/api"
corev1 "k8s.io/api/core/v1"
)

// GlobalResourceQuotaStatus defines the observed state of GlobalResourceQuota
type GlobalResourceQuotaStatus struct {
// If this quota is active or not.
// +kubebuilder:default=true
Active bool `json:"active"`
// How many namespaces are assigned to the Tenant.
// +kubebuilder:default=0
Size uint `json:"size"`
// List of namespaces assigned to the Tenant.
Namespaces []string `json:"namespaces,omitempty"`
// Tracks the quotas for the Resource.
Quota GlobalResourceQuotaStatusQuota `json:"quotas,omitempty"`
}

type GlobalResourceQuotaStatusQuota map[api.Name]*corev1.ResourceQuotaStatus
64 changes: 64 additions & 0 deletions api/v1beta2/globalresourcequota_types.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// Copyright 2020-2023 Project Capsule Authors.
// SPDX-License-Identifier: Apache-2.0

package v1beta2

import (
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"

"github.com/projectcapsule/capsule/pkg/api"
)

// GlobalResourceQuotaSpec defines the desired state of GlobalResourceQuota
type GlobalResourceQuotaSpec struct {
// When a quota is active it's checking for the resources in the cluster
// If not active the resourcequotas are removed and the webhook no longer blocks updates
// +kubebuilder:default=true
Active bool `json:"active,omitempty"`

// Selector to match the namespaces that should be managed by the GlobalResourceQuota
Selectors []GlobalResourceQuotaSelector `json:"selectors,omitempty"`

// Define resourcequotas for the namespaces
Items map[api.Name]corev1.ResourceQuotaSpec `json:"quotas,omitempty"`
}

type GlobalResourceQuotaSelector struct {
// Only considers namespaces which are part of a tenant, other namespaces which might match
// the label, but do not have a tenant, are ignored.
// +kubebuilder:default=true
MustTenantNamespace bool `json:"tenant,omitempty"`

// Selector to match the namespaces that should be managed by the GlobalResourceQuota
api.NamespaceSelector `json:",inline"`
}

// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
// +kubebuilder:resource:scope=Cluster,shortName=globalquota
// +kubebuilder:printcolumn:name="Active",type="boolean",JSONPath=".status.active",description="Active status of the GlobalResourceQuota"
// +kubebuilder:printcolumn:name="Namespaces",type="integer",JSONPath=".status.size",description="The total amount of Namespaces spanned across"
// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp",description="Age"

// GlobalResourceQuota is the Schema for the globalresourcequotas API
type GlobalResourceQuota struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`

Spec GlobalResourceQuotaSpec `json:"spec,omitempty"`
Status GlobalResourceQuotaStatus `json:"status,omitempty"`
}

// +kubebuilder:object:root=true

// GlobalResourceQuotaList contains a list of GlobalResourceQuota
type GlobalResourceQuotaList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty"`
Items []GlobalResourceQuota `json:"items"`
}

func init() {
SchemeBuilder.Register(&GlobalResourceQuota{}, &GlobalResourceQuotaList{})
}
Loading
Loading