mirror of
https://github.com/projectcapsule/capsule.git
synced 2026-08-23 14:36:54 +00:00
* fix: do not mutate on update and bound pvcs Signed-off-by: Oliver Baehler <oliver@sudo-i.net> * fix: do not mutate on update and bound pvcs Signed-off-by: Oliver Baehler <oliver@sudo-i.net> * fix: consistently reconcile quotas from rules Signed-off-by: Oliver Baehler <oliver@sudo-i.net> --------- Signed-off-by: Oliver Baehler <oliver@sudo-i.net>
223 lines
7.0 KiB
Go
223 lines
7.0 KiB
Go
// Copyright 2020-2026 Project Capsule Authors
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
package globalresourcequotas
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
|
|
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/fake"
|
|
|
|
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
|
|
"github.com/projectcapsule/capsule/pkg/runtime/selectors"
|
|
)
|
|
|
|
func TestObserveUsageTracksTotalAndNamespaces(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
scheme := runtime.NewScheme()
|
|
if err := corev1.AddToScheme(scheme); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := capsulev1beta2.AddToScheme(scheme); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
hard := corev1.ResourceList{
|
|
corev1.ResourceRequestsCPU: resource.MustParse("8"),
|
|
corev1.ResourceRequestsMemory: resource.MustParse("16Gi"),
|
|
}
|
|
quota := &capsulev1beta2.GlobalResourceQuota{
|
|
ObjectMeta: metav1.ObjectMeta{Name: "shared", UID: types.UID("quota-uid")},
|
|
Spec: capsulev1beta2.GlobalResourceQuotaSpec{
|
|
Quota: corev1.ResourceQuotaSpec{Hard: hard},
|
|
},
|
|
}
|
|
namespaceA := corev1.Namespace{
|
|
ObjectMeta: metav1.ObjectMeta{Name: "a"},
|
|
Status: corev1.NamespaceStatus{Phase: corev1.NamespaceActive},
|
|
}
|
|
namespaceB := corev1.Namespace{
|
|
ObjectMeta: metav1.ObjectMeta{Name: "b"},
|
|
Status: corev1.NamespaceStatus{Phase: corev1.NamespaceActive},
|
|
}
|
|
resourceQuotaA := observedResourceQuota(quota, namespaceA.Name, corev1.ResourceList{
|
|
corev1.ResourceRequestsCPU: resource.MustParse("2"),
|
|
corev1.ResourceRequestsMemory: resource.MustParse("3Gi"),
|
|
})
|
|
resourceQuotaB := observedResourceQuota(quota, namespaceB.Name, corev1.ResourceList{
|
|
corev1.ResourceRequestsCPU: resource.MustParse("1"),
|
|
corev1.ResourceRequestsMemory: resource.MustParse("4Gi"),
|
|
})
|
|
|
|
cl := fake.NewClientBuilder().
|
|
WithScheme(scheme).
|
|
WithObjects(quota, resourceQuotaA, resourceQuotaB).
|
|
Build()
|
|
controller := &Controller{Client: cl, reader: cl}
|
|
|
|
status, initialized, err := controller.observeUsage(
|
|
context.Background(),
|
|
quota,
|
|
[]corev1.Namespace{namespaceB, namespaceA},
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("observeUsage() error = %v", err)
|
|
}
|
|
if !initialized {
|
|
t.Fatal("observeUsage() initialized = false, want true")
|
|
}
|
|
if status.NamespaceSize != 2 || len(status.NamespaceUsage) != 2 {
|
|
t.Fatalf("namespace status = size %d usage %#v", status.NamespaceSize, status.NamespaceUsage)
|
|
}
|
|
if status.Namespaces[0] != "a" || status.Namespaces[1] != "b" {
|
|
t.Fatalf("ordered namespaces = %#v, want [a b]", status.Namespaces)
|
|
}
|
|
assertResource(t, status.Total.Used, corev1.ResourceRequestsCPU, "3")
|
|
assertResource(t, status.Total.Used, corev1.ResourceRequestsMemory, "7Gi")
|
|
assertResource(t, status.Total.Available, corev1.ResourceRequestsCPU, "5")
|
|
assertResource(t, status.NamespaceUsage["b"].Used, corev1.ResourceRequestsMemory, "4Gi")
|
|
}
|
|
|
|
func TestReconcileLedgerStatusConsumesObservedUsage(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
now := metav1.Now()
|
|
expires := metav1.NewTime(now.Add(time.Minute))
|
|
current := &capsulev1beta2.QuantityLedgerResourceQuotaStatus{
|
|
Used: corev1.ResourceList{corev1.ResourceRequestsCPU: resource.MustParse("0")},
|
|
Reservations: []capsulev1beta2.QuantityLedgerResourceQuotaReservation{{
|
|
ID: "request",
|
|
Delta: corev1.ResourceList{corev1.ResourceRequestsCPU: resource.MustParse("2")},
|
|
ExpiresAt: &expires,
|
|
}},
|
|
}
|
|
|
|
next := reconcileLedgerStatus(
|
|
current,
|
|
3,
|
|
[]string{"a", "b"},
|
|
corev1.ResourceList{corev1.ResourceRequestsCPU: resource.MustParse("1")},
|
|
true,
|
|
now,
|
|
)
|
|
|
|
if len(next.Reservations) != 1 {
|
|
t.Fatalf("reservations = %d, want 1", len(next.Reservations))
|
|
}
|
|
assertResource(t, next.Reservations[0].Delta, corev1.ResourceRequestsCPU, "1")
|
|
assertResource(t, next.Used, corev1.ResourceRequestsCPU, "1")
|
|
assertResource(t, next.Allocated, corev1.ResourceRequestsCPU, "2")
|
|
if next.ObservedGeneration != 3 || len(next.Namespaces) != 2 {
|
|
t.Fatalf("ledger snapshot = generation %d, namespaces %#v", next.ObservedGeneration, next.Namespaces)
|
|
}
|
|
}
|
|
|
|
func TestMatchingNamespaceSelectorsUseOR(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
quota := &capsulev1beta2.GlobalResourceQuota{
|
|
Spec: capsulev1beta2.GlobalResourceQuotaSpec{
|
|
NamespaceSelectors: []selectors.NamespaceSelector{
|
|
{LabelSelector: &metav1.LabelSelector{MatchLabels: map[string]string{"team": "a"}}},
|
|
{LabelSelector: &metav1.LabelSelector{MatchLabels: map[string]string{"team": "b"}}},
|
|
},
|
|
},
|
|
}
|
|
namespace := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{
|
|
Name: "b", Labels: map[string]string{"team": "b"},
|
|
}}
|
|
|
|
cl := fake.NewClientBuilder().WithObjects(namespace).Build()
|
|
matched, err := selectors.GetNamespacesMatchingSelectors(
|
|
context.Background(),
|
|
cl,
|
|
quota.Spec.NamespaceSelectors,
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(matched) != 1 || matched[0].Name != namespace.Name {
|
|
t.Fatalf("matched namespaces = %#v, want b", matched)
|
|
}
|
|
}
|
|
|
|
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,
|
|
used corev1.ResourceList,
|
|
) *corev1.ResourceQuota {
|
|
return &corev1.ResourceQuota{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: quota.GetResourceQuotaName(),
|
|
Namespace: namespace,
|
|
},
|
|
Status: corev1.ResourceQuotaStatus{
|
|
Hard: quota.Spec.Quota.Hard.DeepCopy(),
|
|
Used: used.DeepCopy(),
|
|
},
|
|
}
|
|
}
|
|
|
|
func assertResource(
|
|
t *testing.T,
|
|
resources corev1.ResourceList,
|
|
name corev1.ResourceName,
|
|
want string,
|
|
) {
|
|
t.Helper()
|
|
|
|
got := resources[name]
|
|
if got.Cmp(resource.MustParse(want)) != 0 {
|
|
t.Fatalf("%s = %s, want %s", name, got.String(), want)
|
|
}
|
|
}
|