mirror of
https://github.com/projectcapsule/capsule.git
synced 2026-08-23 22:46:59 +00:00
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:
@@ -49,6 +49,7 @@ const (
|
||||
ReasonForbiddenPullPolicy string = "ForbiddenPullPolicy"
|
||||
ReasonForbiddenPodQoSClass string = "ForbiddenQoSClass"
|
||||
ReasonForbiddenPodScheduler string = "ForbiddenScheduler"
|
||||
ReasonForbiddenPodResources string = "ForbiddenPodResources"
|
||||
|
||||
// Ingress.
|
||||
ReasonWildcardDenied string = "WildcardDenied"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user