feat: requests and limit policies (#2095)

* chore: save progress

Signed-off-by: Oliver Baehler <oliver@sudo-i.net>

* feat: requests and limit policies

Signed-off-by: Oliver Baehler <oliver@sudo-i.net>

* feat: requests and limit policies

Signed-off-by: Oliver Baehler <oliver@sudo-i.net>

* feat: requests and limit policies

Signed-off-by: Oliver Baehler <oliver@sudo-i.net>

* feat: requests and limit policies

Signed-off-by: Oliver Baehler <oliver@sudo-i.net>

* feat: requests and limit policies

Signed-off-by: Oliver Baehler <oliver@sudo-i.net>

* feat: requests and limit policies

Signed-off-by: Oliver Baehler <oliver@sudo-i.net>

* feat: requests and limit policies

Signed-off-by: Oliver Baehler <oliver@sudo-i.net>

* feat: requests and limit policies

Signed-off-by: Oliver Baehler <oliver@sudo-i.net>

* feat: requests and limit policies

Signed-off-by: Oliver Baehler <oliver@sudo-i.net>

---------

Signed-off-by: Oliver Baehler <oliver@sudo-i.net>
This commit is contained in:
Oliver Bähler
2026-08-21 14:31:06 +02:00
committed by GitHub
parent 88217e9562
commit 65a4e19e02
57 changed files with 5627 additions and 265 deletions
+1
View File
@@ -49,6 +49,7 @@ const (
ReasonForbiddenPullPolicy string = "ForbiddenPullPolicy"
ReasonForbiddenPodQoSClass string = "ForbiddenQoSClass"
ReasonForbiddenPodScheduler string = "ForbiddenScheduler"
ReasonForbiddenPodResources string = "ForbiddenPodResources"
// Ingress.
ReasonWildcardDenied string = "WildcardDenied"
+38
View File
@@ -41,3 +41,41 @@ func ValidateHardLimit(path string, hard, allocated corev1.ResourceList) error {
return nil
}
// ValidateHardLimitScopeChange prevents a quota from reducing or removing a
// hard limit in the same update that changes its namespace selection. Usage
// from newly selected namespaces is not represented by the quota's previous
// status yet, so the scope must reconcile before a safe lower bound is known.
func ValidateHardLimitScopeChange(
path string,
hard corev1.ResourceList,
previous corev1.ResourceList,
scopeChanged bool,
) error {
if !scopeChanged {
return nil
}
for name, previousLimit := range previous {
limit, exists := hard[name]
if !exists {
return fmt.Errorf(
"%s[%q] cannot be removed while namespace selectors are changing; update the selectors first and wait for usage reconciliation",
path,
name,
)
}
if limit.Cmp(previousLimit) < 0 {
return fmt.Errorf(
"%s[%q] cannot be reduced from %s to %s while namespace selectors are changing; update the selectors first and wait for usage reconciliation",
path,
name,
previousLimit.String(),
limit.String(),
)
}
}
return nil
}
+86
View File
@@ -0,0 +1,86 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package quota
import (
"strings"
"testing"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/resource"
)
func TestValidateHardLimitScopeChange(t *testing.T) {
t.Parallel()
previous := corev1.ResourceList{
corev1.ResourceLimitsCPU: resource.MustParse("8"),
}
tests := []struct {
name string
hard corev1.ResourceList
scopeChanged bool
wantErr string
}{
{
name: "rejects decrease while scope changes",
hard: corev1.ResourceList{
corev1.ResourceLimitsCPU: resource.MustParse("0"),
},
scopeChanged: true,
wantErr: "cannot be reduced from 8 to 0 while namespace selectors are changing",
},
{
name: "rejects removal while scope changes",
hard: corev1.ResourceList{},
scopeChanged: true,
wantErr: "cannot be removed while namespace selectors are changing",
},
{
name: "allows equal limit while scope changes",
hard: corev1.ResourceList{
corev1.ResourceLimitsCPU: resource.MustParse("8"),
},
scopeChanged: true,
},
{
name: "allows increase while scope changes",
hard: corev1.ResourceList{
corev1.ResourceLimitsCPU: resource.MustParse("10"),
},
scopeChanged: true,
},
{
name: "defers unchanged-scope decrease to usage validation",
hard: corev1.ResourceList{
corev1.ResourceLimitsCPU: resource.MustParse("0"),
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
err := ValidateHardLimitScopeChange(
"spec.quota.hard",
test.hard,
previous,
test.scopeChanged,
)
if test.wantErr == "" {
if err != nil {
t.Fatalf("ValidateHardLimitScopeChange() error = %v", err)
}
return
}
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
t.Fatalf("ValidateHardLimitScopeChange() error = %v, want containing %q", err, test.wantErr)
}
})
}
}
@@ -0,0 +1,62 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package workloads
import (
"fmt"
"strings"
inf "gopkg.in/inf.v0"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/resource"
)
// PodLevelResourceSupported reports whether Kubernetes permits the resource
// name in Pod-level resource requirements.
func PodLevelResourceSupported(name corev1.ResourceName) bool {
return name == corev1.ResourceCPU ||
name == corev1.ResourceMemory ||
strings.HasPrefix(string(name), corev1.ResourceHugePagesPrefix)
}
// RatioSupportedResource reports whether Capsule can safely calculate a
// limit-to-request ratio for the resource. CPU is rounded down to milliCPU;
// byte-based resources are rounded down to whole bytes.
func RatioSupportedResource(name corev1.ResourceName) bool {
return name == corev1.ResourceCPU ||
name == corev1.ResourceMemory ||
name == corev1.ResourceEphemeralStorage
}
// LimitForRatio calculates request * ratio without floating-point arithmetic.
// The result is rounded down so it never exceeds the configured maximum ratio.
func LimitForRatio(
name corev1.ResourceName,
request resource.Quantity,
ratio resource.Quantity,
) (resource.Quantity, error) {
if !RatioSupportedResource(name) {
return resource.Quantity{}, fmt.Errorf("ratio is not supported for resource %q", name)
}
if request.Sign() <= 0 {
return resource.Quantity{}, fmt.Errorf("request for resource %q must be greater than zero", name)
}
if ratio.Cmp(resource.MustParse("1")) < 0 {
return resource.Quantity{}, fmt.Errorf("ratio for resource %q must be greater than or equal to 1", name)
}
product := new(inf.Dec).Mul(request.AsDec(), ratio.AsDec())
scale := inf.Scale(0)
if name == corev1.ResourceCPU {
scale = inf.Scale(3)
}
rounded := new(inf.Dec).Round(product, scale, inf.RoundDown)
return *resource.NewDecimalQuantity(*rounded, request.Format), nil
}
@@ -0,0 +1,113 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package workloads
import (
"testing"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/resource"
)
func TestPodLevelResourceSupported(t *testing.T) {
t.Parallel()
tests := []struct {
name corev1.ResourceName
supported bool
}{
{name: corev1.ResourceCPU, supported: true},
{name: corev1.ResourceMemory, supported: true},
{name: corev1.ResourceName("hugepages-2Mi"), supported: true},
{name: corev1.ResourceEphemeralStorage, supported: false},
{name: corev1.ResourceName("example.com/gpu"), supported: false},
}
for _, tt := range tests {
t.Run(string(tt.name), func(t *testing.T) {
t.Parallel()
if got := PodLevelResourceSupported(tt.name); got != tt.supported {
t.Fatalf("PodLevelResourceSupported(%q) = %t, want %t", tt.name, got, tt.supported)
}
})
}
}
func TestLimitForRatio(t *testing.T) {
t.Parallel()
tests := []struct {
name string
resource corev1.ResourceName
request string
ratio string
want string
}{
{
name: "memory",
resource: corev1.ResourceMemory,
request: "1Gi",
ratio: "1.5",
want: "1536Mi",
},
{
name: "cpu",
resource: corev1.ResourceCPU,
request: "100m",
ratio: "1.5",
want: "150m",
},
{
name: "cpu rounds down to milliCPU",
resource: corev1.ResourceCPU,
request: "1m",
ratio: "1.5",
want: "1m",
},
{
name: "storage rounds down to bytes",
resource: corev1.ResourceEphemeralStorage,
request: "3",
ratio: "1.5",
want: "4",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := LimitForRatio(
tt.resource,
resource.MustParse(tt.request),
resource.MustParse(tt.ratio),
)
if err != nil {
t.Fatalf("LimitForRatio() error = %v", err)
}
want := resource.MustParse(tt.want)
if got.Cmp(want) != 0 {
t.Fatalf("LimitForRatio() = %s, want %s", got.String(), want.String())
}
})
}
}
func TestLimitForRatioRejectsInvalidInputs(t *testing.T) {
t.Parallel()
if _, err := LimitForRatio(corev1.ResourceName("example.com/gpu"), resource.MustParse("1"), resource.MustParse("1.5")); err == nil {
t.Fatal("LimitForRatio() accepted an extended resource")
}
if _, err := LimitForRatio(corev1.ResourceCPU, resource.MustParse("0"), resource.MustParse("1.5")); err == nil {
t.Fatal("LimitForRatio() accepted a zero request")
}
if _, err := LimitForRatio(corev1.ResourceCPU, resource.MustParse("1"), resource.MustParse("0.5")); err == nil {
t.Fatal("LimitForRatio() accepted a ratio below one")
}
}