mirror of
https://github.com/projectcapsule/capsule.git
synced 2026-08-21 13:36:41 +00:00
* fix(controller): decode old object for delete requests Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com> * chore: modernize golang Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com> * chore: modernize golang Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com> * chore: modernize golang Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com> * fix: preserve ca-bundles injected from external providers Signed-off-by: Oliver Baehler <oliver@sudo-i.net> * feat: add metadata enforcement Signed-off-by: Oliver Baehler <oliver@sudo-i.net> * feat: add metadata enforcement Signed-off-by: Oliver Baehler <oliver@sudo-i.net> * fix: add resourcepoolclaim validation Signed-off-by: Oliver Baehler <oliver@sudo-i.net> * fix: add resourcepoolclaim validation Signed-off-by: Oliver Baehler <oliver@sudo-i.net> * fix: add resourcepoolclaim validation Signed-off-by: Oliver Baehler <oliver@sudo-i.net> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * fix: add resourcepoolclaim validation Signed-off-by: Oliver Baehler <oliver@sudo-i.net> --------- Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com> Signed-off-by: Oliver Baehler <oliver@sudo-i.net> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
463 lines
13 KiB
Go
463 lines
13 KiB
Go
// Copyright 2020-2026 Project Capsule Authors
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
package resourcepools
|
|
|
|
import (
|
|
"sort"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/go-logr/logr"
|
|
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"
|
|
)
|
|
|
|
// ---------- helpers ----------
|
|
|
|
func q(s string) resource.Quantity { return resource.MustParse(s) }
|
|
|
|
func rl(m map[corev1.ResourceName]string) corev1.ResourceList {
|
|
out := corev1.ResourceList{}
|
|
for k, v := range m {
|
|
out[k] = q(v)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func claim(t *testing.T, uid, ns, name string, ts time.Time, req corev1.ResourceList) capsulev1beta2.ResourcePoolClaim {
|
|
t.Helper()
|
|
|
|
return capsulev1beta2.ResourcePoolClaim{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
UID: types.UID(uid),
|
|
Namespace: ns,
|
|
Name: name,
|
|
CreationTimestamp: metav1.NewTime(ts),
|
|
},
|
|
Spec: capsulev1beta2.ResourcePoolClaimSpec{
|
|
ResourceClaims: req,
|
|
},
|
|
}
|
|
}
|
|
|
|
func setEq(got map[string]struct{}, want []string) bool {
|
|
if len(got) != len(want) {
|
|
return false
|
|
}
|
|
for _, w := range want {
|
|
if _, ok := got[w]; !ok {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func poolWithClaims(hard corev1.ResourceList, claims ...capsulev1beta2.ResourcePoolClaim) *capsulev1beta2.ResourcePool {
|
|
pool := &capsulev1beta2.ResourcePool{
|
|
Status: capsulev1beta2.ResourcePoolStatus{
|
|
Allocation: capsulev1beta2.ResourcePoolQuotaStatus{
|
|
Hard: hard,
|
|
},
|
|
},
|
|
}
|
|
|
|
for i := range claims {
|
|
pool.AddClaimToStatus(&claims[i])
|
|
}
|
|
|
|
return pool
|
|
}
|
|
|
|
// ---------- filterResourceListByKeys tests ----------
|
|
|
|
func TestFilterResourceListByKeys(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("empty keys returns empty", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
in := rl(map[corev1.ResourceName]string{
|
|
corev1.ResourceCPU: "100m",
|
|
corev1.ResourceMemory: "128Mi",
|
|
})
|
|
keys := corev1.ResourceList{}
|
|
|
|
got := filterResourceListByKeys(in, keys)
|
|
if len(got) != 0 {
|
|
t.Fatalf("expected empty result, got=%v", got)
|
|
}
|
|
})
|
|
|
|
t.Run("filters by intersection", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
in := rl(map[corev1.ResourceName]string{
|
|
corev1.ResourceCPU: "250m",
|
|
corev1.ResourceMemory: "512Mi",
|
|
corev1.ResourcePods: "10",
|
|
})
|
|
keys := rl(map[corev1.ResourceName]string{
|
|
corev1.ResourceCPU: "0", // values ignored, only keys matter
|
|
corev1.ResourcePods: "0",
|
|
})
|
|
|
|
got := filterResourceListByKeys(in, keys)
|
|
if len(got) != 2 {
|
|
t.Fatalf("expected 2 keys, got=%v", got)
|
|
}
|
|
cpu := got[corev1.ResourceCPU]
|
|
if cpu.Cmp(q("250m")) != 0 {
|
|
t.Fatalf("expected cpu=250m, got=%v", cpu.String())
|
|
}
|
|
|
|
pods := got[corev1.ResourcePods]
|
|
if pods.Cmp(q("10")) != 0 {
|
|
t.Fatalf("expected pods=10, got=%v", pods.String())
|
|
}
|
|
if _, ok := got[corev1.ResourceMemory]; ok {
|
|
t.Fatalf("did not expect memory in result, got=%v", got)
|
|
}
|
|
})
|
|
|
|
t.Run("keys not present in input are ignored", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
in := rl(map[corev1.ResourceName]string{
|
|
corev1.ResourceCPU: "100m",
|
|
})
|
|
keys := rl(map[corev1.ResourceName]string{
|
|
corev1.ResourceCPU: "0",
|
|
corev1.ResourceMemory: "0",
|
|
})
|
|
|
|
got := filterResourceListByKeys(in, keys)
|
|
if len(got) != 1 {
|
|
t.Fatalf("expected 1 key, got=%v", got)
|
|
}
|
|
if _, ok := got[corev1.ResourceCPU]; !ok {
|
|
t.Fatalf("expected cpu key present")
|
|
}
|
|
if _, ok := got[corev1.ResourceMemory]; ok {
|
|
t.Fatalf("did not expect memory key present")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestCanClaimWithinPoolExcludingClaim(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
|
|
t.Run("allows resize when new claim fits after excluding previous allocation", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
existing := claim(t, "u1", "ns", "claim", base, rl(map[corev1.ResourceName]string{corev1.ResourceCPU: "5"}))
|
|
pool := poolWithClaims(
|
|
rl(map[corev1.ResourceName]string{corev1.ResourceCPU: "10"}),
|
|
existing,
|
|
)
|
|
|
|
resized := existing
|
|
resized.Spec.ResourceClaims = rl(map[corev1.ResourceName]string{corev1.ResourceCPU: "10"})
|
|
|
|
got := canClaimWithinPoolExcludingClaim(logr.Discard(), pool, &resized, pool.GetClaimFromStatus(&existing))
|
|
if len(got) != 0 {
|
|
t.Fatalf("expected resize to fit, got exhaustions=%v", got)
|
|
}
|
|
})
|
|
|
|
t.Run("rejects resize that would exceed pool capacity", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
existing := claim(t, "u1", "ns", "claim", base, rl(map[corev1.ResourceName]string{corev1.ResourceCPU: "5"}))
|
|
pool := poolWithClaims(
|
|
rl(map[corev1.ResourceName]string{corev1.ResourceCPU: "10"}),
|
|
existing,
|
|
)
|
|
|
|
resized := existing
|
|
resized.Spec.ResourceClaims = rl(map[corev1.ResourceName]string{corev1.ResourceCPU: "55"})
|
|
|
|
got := canClaimWithinPoolExcludingClaim(logr.Discard(), pool, &resized, pool.GetClaimFromStatus(&existing))
|
|
if len(got) != 1 {
|
|
t.Fatalf("expected one exhaustion, got=%v", got)
|
|
}
|
|
|
|
exhaustion := got[string(corev1.ResourceCPU)]
|
|
if exhaustion.Available.Cmp(q("10")) != 0 {
|
|
t.Fatalf("expected available=10, got=%s", exhaustion.Available.String())
|
|
}
|
|
if exhaustion.Requesting.Cmp(q("55")) != 0 {
|
|
t.Fatalf("expected requesting=55, got=%s", exhaustion.Requesting.String())
|
|
}
|
|
})
|
|
|
|
t.Run("accounts for other claims in the pool", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
existing := claim(t, "u1", "ns", "claim", base, rl(map[corev1.ResourceName]string{corev1.ResourceCPU: "5"}))
|
|
other := claim(t, "u2", "ns", "other", base.Add(time.Second), rl(map[corev1.ResourceName]string{corev1.ResourceCPU: "4"}))
|
|
pool := poolWithClaims(
|
|
rl(map[corev1.ResourceName]string{corev1.ResourceCPU: "10"}),
|
|
existing,
|
|
other,
|
|
)
|
|
|
|
resized := existing
|
|
resized.Spec.ResourceClaims = rl(map[corev1.ResourceName]string{corev1.ResourceCPU: "7"})
|
|
|
|
got := canClaimWithinPoolExcludingClaim(logr.Discard(), pool, &resized, pool.GetClaimFromStatus(&existing))
|
|
if len(got) != 1 {
|
|
t.Fatalf("expected resize to exhaust remaining capacity, got=%v", got)
|
|
}
|
|
|
|
exhaustion := got[string(corev1.ResourceCPU)]
|
|
if exhaustion.Available.Cmp(q("6")) != 0 {
|
|
t.Fatalf("expected available=6, got=%s", exhaustion.Available.String())
|
|
}
|
|
})
|
|
}
|
|
|
|
// ---------- resourceListAllZero tests ----------
|
|
|
|
func TestResourceListAllZero(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("nil list is all zero", func(t *testing.T) {
|
|
t.Parallel()
|
|
if !resourceListAllZero(nil) {
|
|
t.Fatalf("expected true")
|
|
}
|
|
})
|
|
|
|
t.Run("empty list is all zero", func(t *testing.T) {
|
|
t.Parallel()
|
|
if !resourceListAllZero(corev1.ResourceList{}) {
|
|
t.Fatalf("expected true")
|
|
}
|
|
})
|
|
|
|
t.Run("positive quantity returns false", func(t *testing.T) {
|
|
t.Parallel()
|
|
rl := rl(map[corev1.ResourceName]string{
|
|
corev1.ResourceCPU: "1",
|
|
})
|
|
if resourceListAllZero(rl) {
|
|
t.Fatalf("expected false")
|
|
}
|
|
})
|
|
|
|
t.Run("explicit zero quantities returns true", func(t *testing.T) {
|
|
t.Parallel()
|
|
rl := rl(map[corev1.ResourceName]string{
|
|
corev1.ResourceCPU: "0",
|
|
corev1.ResourceMemory: "0",
|
|
})
|
|
if !resourceListAllZero(rl) {
|
|
t.Fatalf("expected true")
|
|
}
|
|
})
|
|
}
|
|
|
|
// ---------- claimCoverageScore tests ----------
|
|
|
|
func TestClaimCoverageScore(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("no overlap yields zero score", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
remaining := rl(map[corev1.ResourceName]string{
|
|
corev1.ResourceCPU: "500m",
|
|
})
|
|
reqs := rl(map[corev1.ResourceName]string{
|
|
corev1.ResourceMemory: "128Mi",
|
|
})
|
|
|
|
if got := claimCoverageScore(remaining, reqs); got != 0 {
|
|
t.Fatalf("expected score 0, got=%v", got)
|
|
}
|
|
})
|
|
|
|
t.Run("caps at remaining (min(rem, req))", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
remaining := rl(map[corev1.ResourceName]string{
|
|
corev1.ResourceCPU: "500m",
|
|
})
|
|
reqs := rl(map[corev1.ResourceName]string{
|
|
corev1.ResourceCPU: "2",
|
|
})
|
|
|
|
// covered = remaining (500m) -> score > 0
|
|
got := claimCoverageScore(remaining, reqs)
|
|
if got <= 0 {
|
|
t.Fatalf("expected positive score, got=%v", got)
|
|
}
|
|
|
|
// Sanity: if remaining is smaller, score should not increase.
|
|
remaining2 := rl(map[corev1.ResourceName]string{
|
|
corev1.ResourceCPU: "250m",
|
|
})
|
|
got2 := claimCoverageScore(remaining2, reqs)
|
|
if got2 <= 0 || got2 >= got {
|
|
t.Fatalf("expected smaller positive score; got=%v got2=%v", got, got2)
|
|
}
|
|
})
|
|
|
|
t.Run("ignores zero remaining", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
remaining := rl(map[corev1.ResourceName]string{
|
|
corev1.ResourceCPU: "0",
|
|
})
|
|
reqs := rl(map[corev1.ResourceName]string{
|
|
corev1.ResourceCPU: "1",
|
|
})
|
|
|
|
if got := claimCoverageScore(remaining, reqs); got != 0 {
|
|
t.Fatalf("expected 0, got=%v", got)
|
|
}
|
|
})
|
|
|
|
t.Run("sums across resources", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
remaining := rl(map[corev1.ResourceName]string{
|
|
corev1.ResourceCPU: "500m",
|
|
corev1.ResourceMemory: "1Gi",
|
|
})
|
|
reqs := rl(map[corev1.ResourceName]string{
|
|
corev1.ResourceCPU: "250m",
|
|
corev1.ResourceMemory: "512Mi",
|
|
})
|
|
|
|
got := claimCoverageScore(remaining, reqs)
|
|
if got <= 0 {
|
|
t.Fatalf("expected positive score, got=%v", got)
|
|
}
|
|
})
|
|
}
|
|
|
|
// ---------- selectClaimsCoveringUsageGreedy tests ----------
|
|
|
|
func TestSelectClaimsCoveringUsageGreedy(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
|
|
t.Run("empty used returns empty selection", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
used := corev1.ResourceList{}
|
|
claims := []capsulev1beta2.ResourcePoolClaim{
|
|
claim(t, "u1", "ns", "c1", base, rl(map[corev1.ResourceName]string{corev1.ResourceCPU: "1"})),
|
|
}
|
|
|
|
got := selectClaimsCoveringUsageGreedy(used, claims)
|
|
if len(got) != 0 {
|
|
t.Fatalf("expected empty, got=%v", got)
|
|
}
|
|
})
|
|
|
|
t.Run("no claims returns empty selection", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
used := rl(map[corev1.ResourceName]string{corev1.ResourceCPU: "1"})
|
|
got := selectClaimsCoveringUsageGreedy(used, nil)
|
|
if len(got) != 0 {
|
|
t.Fatalf("expected empty, got=%v", got)
|
|
}
|
|
})
|
|
|
|
t.Run("selects single best claim that covers usage", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
used := rl(map[corev1.ResourceName]string{
|
|
corev1.ResourceCPU: "900m",
|
|
})
|
|
|
|
claims := []capsulev1beta2.ResourcePoolClaim{
|
|
claim(t, "u1", "ns", "small", base.Add(1*time.Second), rl(map[corev1.ResourceName]string{corev1.ResourceCPU: "500m"})),
|
|
claim(t, "u2", "ns", "big", base, rl(map[corev1.ResourceName]string{corev1.ResourceCPU: "2"})),
|
|
}
|
|
|
|
got := selectClaimsCoveringUsageGreedy(used, claims)
|
|
if !setEq(got, []string{"u2"}) {
|
|
t.Fatalf("expected [u2], got=%v", got)
|
|
}
|
|
})
|
|
|
|
t.Run("selects multiple claims to cover usage (clamps remaining at zero)", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
used := rl(map[corev1.ResourceName]string{
|
|
corev1.ResourceCPU: "1500m",
|
|
})
|
|
|
|
claims := []capsulev1beta2.ResourcePoolClaim{
|
|
claim(t, "u1", "ns", "c1", base, rl(map[corev1.ResourceName]string{corev1.ResourceCPU: "1"})), // 1000m
|
|
claim(t, "u2", "ns", "c2", base.Add(1*time.Second), rl(map[corev1.ResourceName]string{corev1.ResourceCPU: "1"})),
|
|
claim(t, "u3", "ns", "c3", base.Add(2*time.Second), rl(map[corev1.ResourceName]string{corev1.ResourceCPU: "250m"})),
|
|
}
|
|
|
|
got := selectClaimsCoveringUsageGreedy(used, claims)
|
|
|
|
// Greedy should take u1 (covers 1000m), then u2 (covers remaining 500m).
|
|
if !setEq(got, []string{"u1", "u2"}) {
|
|
t.Fatalf("expected [u1 u2], got=%v", got)
|
|
}
|
|
})
|
|
|
|
t.Run("handles used resources not present in any claim (stops when cannot improve)", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
used := rl(map[corev1.ResourceName]string{
|
|
corev1.ResourceCPU: "500m",
|
|
corev1.ResourceMemory: "256Mi",
|
|
})
|
|
|
|
claims := []capsulev1beta2.ResourcePoolClaim{
|
|
claim(t, "u1", "ns", "cpu-only", base, rl(map[corev1.ResourceName]string{corev1.ResourceCPU: "1"})),
|
|
}
|
|
|
|
got := selectClaimsCoveringUsageGreedy(used, claims)
|
|
// It can reduce CPU remaining, but cannot cover memory at all; algorithm should select cpu-only then stop.
|
|
if !setEq(got, []string{"u1"}) {
|
|
t.Fatalf("expected [u1], got=%v", got)
|
|
}
|
|
})
|
|
|
|
t.Run("deterministic tie-break: equal score chooses oldest, then name, then namespace", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
used := rl(map[corev1.ResourceName]string{
|
|
corev1.ResourceCPU: "500m",
|
|
})
|
|
|
|
// Three identical claims by resources; selection should be deterministic by the sort.
|
|
cA := claim(t, "uA", "ns-b", "a", base, rl(map[corev1.ResourceName]string{corev1.ResourceCPU: "1"}))
|
|
cB := claim(t, "uB", "ns-a", "b", base, rl(map[corev1.ResourceName]string{corev1.ResourceCPU: "1"}))
|
|
cC := claim(t, "uC", "ns-a", "a", base, rl(map[corev1.ResourceName]string{corev1.ResourceCPU: "1"}))
|
|
|
|
claims := []capsulev1beta2.ResourcePoolClaim{cA, cB, cC}
|
|
|
|
// Shuffle input order to ensure determinism isn't accidental
|
|
sort.Slice(claims, func(i, j int) bool { return i > j })
|
|
|
|
got := selectClaimsCoveringUsageGreedy(used, claims)
|
|
|
|
// After sorting: same ts -> name asc -> namespace asc
|
|
// Name "a" beats "b". For name "a", namespace "ns-a" beats "ns-b".
|
|
// That means uC (ns-a, a) should be selected.
|
|
if !setEq(got, []string{"uC"}) {
|
|
t.Fatalf("expected [uC], got=%v", got)
|
|
}
|
|
})
|
|
}
|