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 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -198,9 +198,10 @@ CONTROLLER_GEN_VERSION := v0.16.1
controller-gen: ## Download controller-gen locally if necessary.
$(call go-install-tool,$(CONTROLLER_GEN),sigs.k8s.io/controller-tools/cmd/controller-gen@$(CONTROLLER_GEN_VERSION))

GINKGO_VERSION := v2.20.2
GINKGO := $(shell pwd)/bin/ginkgo
ginkgo: ## Download ginkgo locally if necessary.
$(call go-install-tool,$(GINKGO),github.com/onsi/ginkgo/v2/ginkgo)
$(call go-install-tool,$(GINKGO),github.com/onsi/ginkgo/v2/ginkgo@$(GINKGO_VERSION))

CT := $(shell pwd)/bin/ct
CT_VERSION := v3.10.1
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"
67 changes: 67 additions & 0 deletions api/v1beta2/globalresourcequota_func.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// Copyright 2020-2023 Project Capsule Authors.
// SPDX-License-Identifier: Apache-2.0

package v1beta2

import (
"fmt"
"sort"

"github.com/projectcapsule/capsule/pkg/api"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/resource"
)

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 (in *GlobalResourceQuota) AssignNamespaces(namespaces []corev1.Namespace) {

Check failure on line 54 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

Check failure on line 11 in api/v1beta2/globalresourcequota_status.go

View workflow job for this annotation

GitHub Actions / lint

Comment should end in a period (godot)
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

Check failure on line 13 in api/v1beta2/globalresourcequota_types.go

View workflow job for this annotation

GitHub Actions / lint

Comment should end in a period (godot)
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

Check failure on line 44 in api/v1beta2/globalresourcequota_types.go

View workflow job for this annotation

GitHub Actions / lint

Comment should end in a period (godot)
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