mirror of
https://github.com/projectcapsule/capsule.git
synced 2026-08-22 14:07:10 +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:
@@ -5,16 +5,18 @@ package rules
|
||||
|
||||
import (
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
"k8s.io/apimachinery/pkg/api/resource"
|
||||
|
||||
"github.com/projectcapsule/capsule/pkg/api/runtime"
|
||||
)
|
||||
|
||||
// +kubebuilder:validation:Enum=pod/initcontainers;pod/ephemeralcontainers;pod/containers;pod/volumes
|
||||
// +kubebuilder:validation:Enum=pod;pod/initcontainers;pod/ephemeralcontainers;pod/containers;pod/volumes
|
||||
type WorkloadValidationTarget string
|
||||
|
||||
const (
|
||||
DeprecatedValidateImages WorkloadValidationTarget = "pod/images"
|
||||
|
||||
ValidatePod WorkloadValidationTarget = "pod"
|
||||
ValidateInitContainers WorkloadValidationTarget = "pod/initcontainers"
|
||||
ValidateEphemeralContainers WorkloadValidationTarget = "pod/ephemeralcontainers"
|
||||
ValidateContainers WorkloadValidationTarget = "pod/containers"
|
||||
@@ -28,6 +30,20 @@ type NamespaceRuleEnforceWorkloadsBody struct {
|
||||
// +optional
|
||||
Targets []WorkloadValidationTarget `json:"targets,omitempty"`
|
||||
|
||||
// Resources defines mutation and enforcement policies for Pod and container
|
||||
// resource requests and limits. The workload targets select where the
|
||||
// policies apply. With no targets, resource policies apply to all compatible
|
||||
// locations: Pod-level resources, regular containers, and init containers.
|
||||
// Resource names unsupported at Pod level still apply to compatible container
|
||||
// locations.
|
||||
// Mutation is applied when a Pod is created. Remove and MatchRequest manage
|
||||
// explicit values, Default fills an absent value, and Ratio fills an absent
|
||||
// limit from its request. An explicit Ratio violation is then handled by the
|
||||
// enclosing allow, deny, or audit action.
|
||||
//
|
||||
// +optional
|
||||
Resources *WorkloadResourceRules `json:"resources,omitempty"`
|
||||
|
||||
// Define Pod QoS classes matched by this enforcement rule.
|
||||
// Supported values are Guaranteed, Burstable and BestEffort.
|
||||
// +optional
|
||||
@@ -46,3 +62,70 @@ type NamespaceRuleEnforceWorkloadsBody struct {
|
||||
// +optional
|
||||
Schedulers []runtime.ExpressionMatch `json:"schedulers,omitempty"`
|
||||
}
|
||||
|
||||
type WorkloadResourceRequestPolicyType string
|
||||
|
||||
const (
|
||||
WorkloadResourceRequestPolicyPreserve WorkloadResourceRequestPolicyType = "Preserve"
|
||||
WorkloadResourceRequestPolicyDefault WorkloadResourceRequestPolicyType = "Default"
|
||||
WorkloadResourceRequestPolicyRemove WorkloadResourceRequestPolicyType = "Remove"
|
||||
)
|
||||
|
||||
type WorkloadResourceLimitPolicyType string
|
||||
|
||||
const (
|
||||
WorkloadResourceLimitPolicyPreserve WorkloadResourceLimitPolicyType = "Preserve"
|
||||
WorkloadResourceLimitPolicyDefault WorkloadResourceLimitPolicyType = "Default"
|
||||
WorkloadResourceLimitPolicyRemove WorkloadResourceLimitPolicyType = "Remove"
|
||||
WorkloadResourceLimitPolicyMatchRequest WorkloadResourceLimitPolicyType = "MatchRequest"
|
||||
WorkloadResourceLimitPolicyRatio WorkloadResourceLimitPolicyType = "Ratio"
|
||||
)
|
||||
|
||||
// WorkloadResourceRules defines policies keyed by Kubernetes resource name.
|
||||
//
|
||||
// +kubebuilder:object:generate=true
|
||||
// +kubebuilder:validation:XValidation:rule="has(self.requests) || has(self.limits)",message="at least one of requests or limits must be set"
|
||||
type WorkloadResourceRules struct {
|
||||
// Requests defines policies for resource requests.
|
||||
// +optional
|
||||
// +kubebuilder:validation:MinProperties=1
|
||||
Requests map[corev1.ResourceName]WorkloadResourceRequestPolicy `json:"requests,omitempty"`
|
||||
|
||||
// Limits defines policies for resource limits.
|
||||
// +optional
|
||||
// +kubebuilder:validation:MinProperties=1
|
||||
Limits map[corev1.ResourceName]WorkloadResourceLimitPolicy `json:"limits,omitempty"`
|
||||
}
|
||||
|
||||
// WorkloadResourceRequestPolicy defines how a resource request is mutated.
|
||||
//
|
||||
// +kubebuilder:object:generate=true
|
||||
// +kubebuilder:validation:XValidation:rule="self.policy == 'Default' ? has(self.value) : !has(self.value)",message="value must be set only for the Default policy"
|
||||
type WorkloadResourceRequestPolicy struct {
|
||||
// Policy selects how the request is handled: Preserve leaves it unchanged,
|
||||
// Default fills an absent request, and Remove deletes it.
|
||||
// +kubebuilder:validation:Enum=Preserve;Default;Remove
|
||||
Policy WorkloadResourceRequestPolicyType `json:"policy"`
|
||||
|
||||
// Value is the quantity applied by the Default policy.
|
||||
// +optional
|
||||
Value *resource.Quantity `json:"value,omitempty"`
|
||||
}
|
||||
|
||||
// WorkloadResourceLimitPolicy defines how a resource limit is mutated and enforced.
|
||||
//
|
||||
// +kubebuilder:object:generate=true
|
||||
// +kubebuilder:validation:XValidation:rule="self.policy == 'Default' || self.policy == 'Ratio' ? has(self.value) : !has(self.value)",message="value must be set only for the Default and Ratio policies"
|
||||
type WorkloadResourceLimitPolicy struct {
|
||||
// Policy selects how the limit is handled: Preserve leaves it unchanged,
|
||||
// Default fills an absent limit, Remove deletes it, MatchRequest manages it
|
||||
// to equal the request, and Ratio defaults an absent limit and enforces the
|
||||
// maximum multiplier against explicitly supplied limits.
|
||||
// +kubebuilder:validation:Enum=Preserve;Default;Remove;MatchRequest;Ratio
|
||||
Policy WorkloadResourceLimitPolicyType `json:"policy"`
|
||||
|
||||
// Value is the quantity applied by Default or the maximum limit-to-request
|
||||
// multiplier applied by Ratio.
|
||||
// +optional
|
||||
Value *resource.Quantity `json:"value,omitempty"`
|
||||
}
|
||||
|
||||
@@ -244,6 +244,11 @@ func (in *NamespaceRuleEnforceWorkloadsBody) DeepCopyInto(out *NamespaceRuleEnfo
|
||||
*out = make([]WorkloadValidationTarget, len(*in))
|
||||
copy(*out, *in)
|
||||
}
|
||||
if in.Resources != nil {
|
||||
in, out := &in.Resources, &out.Resources
|
||||
*out = new(WorkloadResourceRules)
|
||||
(*in).DeepCopyInto(*out)
|
||||
}
|
||||
if in.QoSClasses != nil {
|
||||
in, out := &in.QoSClasses, &out.QoSClasses
|
||||
*out = make([]v1.PodQOSClass, len(*in))
|
||||
@@ -446,3 +451,72 @@ func (in *ServiceNodePortRule) DeepCopy() *ServiceNodePortRule {
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *WorkloadResourceLimitPolicy) DeepCopyInto(out *WorkloadResourceLimitPolicy) {
|
||||
*out = *in
|
||||
if in.Value != nil {
|
||||
in, out := &in.Value, &out.Value
|
||||
x := (*in).DeepCopy()
|
||||
*out = &x
|
||||
}
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WorkloadResourceLimitPolicy.
|
||||
func (in *WorkloadResourceLimitPolicy) DeepCopy() *WorkloadResourceLimitPolicy {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(WorkloadResourceLimitPolicy)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *WorkloadResourceRequestPolicy) DeepCopyInto(out *WorkloadResourceRequestPolicy) {
|
||||
*out = *in
|
||||
if in.Value != nil {
|
||||
in, out := &in.Value, &out.Value
|
||||
x := (*in).DeepCopy()
|
||||
*out = &x
|
||||
}
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WorkloadResourceRequestPolicy.
|
||||
func (in *WorkloadResourceRequestPolicy) DeepCopy() *WorkloadResourceRequestPolicy {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(WorkloadResourceRequestPolicy)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *WorkloadResourceRules) DeepCopyInto(out *WorkloadResourceRules) {
|
||||
*out = *in
|
||||
if in.Requests != nil {
|
||||
in, out := &in.Requests, &out.Requests
|
||||
*out = make(map[v1.ResourceName]WorkloadResourceRequestPolicy, len(*in))
|
||||
for key, val := range *in {
|
||||
(*out)[key] = *val.DeepCopy()
|
||||
}
|
||||
}
|
||||
if in.Limits != nil {
|
||||
in, out := &in.Limits, &out.Limits
|
||||
*out = make(map[v1.ResourceName]WorkloadResourceLimitPolicy, len(*in))
|
||||
for key, val := range *in {
|
||||
(*out)[key] = *val.DeepCopy()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WorkloadResourceRules.
|
||||
func (in *WorkloadResourceRules) DeepCopy() *WorkloadResourceRules {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(WorkloadResourceRules)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -9,12 +9,15 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
k8smeta "k8s.io/apimachinery/pkg/api/meta"
|
||||
"k8s.io/apimachinery/pkg/api/resource"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
k8svalidation "k8s.io/apimachinery/pkg/util/validation"
|
||||
|
||||
"github.com/projectcapsule/capsule/pkg/api/rules"
|
||||
"github.com/projectcapsule/capsule/pkg/api/runtime"
|
||||
workloadruntime "github.com/projectcapsule/capsule/pkg/runtime/workloads"
|
||||
)
|
||||
|
||||
func ValidateRuleStatusBody(
|
||||
@@ -172,6 +175,28 @@ func validateWorkloadRules(
|
||||
ruleIndex int,
|
||||
workloads rules.NamespaceRuleEnforceWorkloadsBody,
|
||||
) error {
|
||||
for j, target := range workloads.Targets {
|
||||
switch target {
|
||||
case rules.DeprecatedValidateImages,
|
||||
rules.ValidatePod,
|
||||
rules.ValidateInitContainers,
|
||||
rules.ValidateEphemeralContainers,
|
||||
rules.ValidateContainers,
|
||||
rules.ValidateVolumes:
|
||||
default:
|
||||
return fmt.Errorf(
|
||||
"rules[%d].enforce.workloads.targets[%d] %q is invalid: unsupported workload target",
|
||||
ruleIndex,
|
||||
j,
|
||||
target,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if err := validateWorkloadResourceRules(ruleIndex, workloads); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for j, registry := range workloads.Registries {
|
||||
if err := validateExpression(
|
||||
registry.Expression,
|
||||
@@ -193,6 +218,173 @@ func validateWorkloadRules(
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateWorkloadResourceRules(
|
||||
ruleIndex int,
|
||||
workloads rules.NamespaceRuleEnforceWorkloadsBody,
|
||||
) error {
|
||||
resources := workloads.Resources
|
||||
if resources == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
path := fmt.Sprintf("rules[%d].enforce.workloads.resources", ruleIndex)
|
||||
if len(resources.Requests) == 0 && len(resources.Limits) == 0 {
|
||||
return fmt.Errorf("%s is invalid: at least one request or limit policy is required", path)
|
||||
}
|
||||
|
||||
podTarget, err := validateWorkloadResourceTargets(path, workloads.Targets)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := validateWorkloadRequestPolicies(path, resources.Requests, podTarget); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return validateWorkloadLimitPolicies(path, resources, podTarget)
|
||||
}
|
||||
|
||||
func validateWorkloadResourceTargets(
|
||||
path string,
|
||||
targets []rules.WorkloadValidationTarget,
|
||||
) (bool, error) {
|
||||
podTarget := false
|
||||
|
||||
for _, target := range targets {
|
||||
switch target {
|
||||
case rules.ValidatePod:
|
||||
podTarget = true
|
||||
case rules.ValidateContainers, rules.ValidateInitContainers:
|
||||
case rules.ValidateEphemeralContainers, rules.ValidateVolumes, rules.DeprecatedValidateImages:
|
||||
return false, fmt.Errorf(
|
||||
"%s is invalid: workload target %q does not support resource policies",
|
||||
path,
|
||||
target,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return podTarget, nil
|
||||
}
|
||||
|
||||
func validateWorkloadRequestPolicies(
|
||||
path string,
|
||||
policies map[corev1.ResourceName]rules.WorkloadResourceRequestPolicy,
|
||||
podTarget bool,
|
||||
) error {
|
||||
for name, policy := range policies {
|
||||
policyPath := fmt.Sprintf("%s.requests[%q]", path, name)
|
||||
if err := validateWorkloadResourceName(name, podTarget); err != nil {
|
||||
return fmt.Errorf("%s is invalid: %w", policyPath, err)
|
||||
}
|
||||
|
||||
switch policy.Policy {
|
||||
case rules.WorkloadResourceRequestPolicyPreserve,
|
||||
rules.WorkloadResourceRequestPolicyRemove:
|
||||
if policy.Value != nil {
|
||||
return fmt.Errorf("%s.value is invalid: value is only supported by the Default policy", policyPath)
|
||||
}
|
||||
case rules.WorkloadResourceRequestPolicyDefault:
|
||||
if err := validateDefaultResourceQuantity(policyPath, policy.Value); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("%s.policy %q is invalid: unsupported request policy", policyPath, policy.Policy)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateWorkloadLimitPolicies(
|
||||
path string,
|
||||
resources *rules.WorkloadResourceRules,
|
||||
podTarget bool,
|
||||
) error {
|
||||
one := resource.MustParse("1")
|
||||
|
||||
for name, policy := range resources.Limits {
|
||||
policyPath := fmt.Sprintf("%s.limits[%q]", path, name)
|
||||
if err := validateWorkloadResourceName(name, podTarget); err != nil {
|
||||
return fmt.Errorf("%s is invalid: %w", policyPath, err)
|
||||
}
|
||||
|
||||
switch policy.Policy {
|
||||
case rules.WorkloadResourceLimitPolicyPreserve,
|
||||
rules.WorkloadResourceLimitPolicyRemove,
|
||||
rules.WorkloadResourceLimitPolicyMatchRequest:
|
||||
if policy.Value != nil {
|
||||
return fmt.Errorf(
|
||||
"%s.value is invalid: value is only supported by the Default and Ratio policies",
|
||||
policyPath,
|
||||
)
|
||||
}
|
||||
case rules.WorkloadResourceLimitPolicyDefault:
|
||||
if err := validateDefaultResourceQuantity(policyPath, policy.Value); err != nil {
|
||||
return err
|
||||
}
|
||||
case rules.WorkloadResourceLimitPolicyRatio:
|
||||
if policy.Value == nil {
|
||||
return fmt.Errorf("%s.value is invalid: Ratio requires a value", policyPath)
|
||||
}
|
||||
|
||||
if !workloadruntime.RatioSupportedResource(name) {
|
||||
return fmt.Errorf(
|
||||
"%s.policy is invalid: Ratio is only supported for cpu, memory, and ephemeral-storage",
|
||||
policyPath,
|
||||
)
|
||||
}
|
||||
|
||||
if policy.Value.Cmp(one) < 0 {
|
||||
return fmt.Errorf("%s.value is invalid: Ratio must be greater than or equal to 1", policyPath)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("%s.policy %q is invalid: unsupported limit policy", policyPath, policy.Policy)
|
||||
}
|
||||
|
||||
if requestPolicy, found := resources.Requests[name]; found &&
|
||||
requestPolicy.Policy == rules.WorkloadResourceRequestPolicyRemove &&
|
||||
(policy.Policy == rules.WorkloadResourceLimitPolicyMatchRequest ||
|
||||
policy.Policy == rules.WorkloadResourceLimitPolicyRatio) {
|
||||
return fmt.Errorf(
|
||||
"%s is invalid: %s requires a request which is removed by the request policy",
|
||||
policyPath,
|
||||
policy.Policy,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateDefaultResourceQuantity(path string, value *resource.Quantity) error {
|
||||
if value == nil {
|
||||
return fmt.Errorf("%s.value is invalid: Default requires a value", path)
|
||||
}
|
||||
|
||||
if value.Sign() < 0 {
|
||||
return fmt.Errorf("%s.value is invalid: quantity must not be negative", path)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateWorkloadResourceName(name corev1.ResourceName, podTarget bool) error {
|
||||
if errs := k8svalidation.IsQualifiedName(string(name)); len(errs) > 0 {
|
||||
return fmt.Errorf("resource name is invalid: %s", strings.Join(errs, "; "))
|
||||
}
|
||||
|
||||
if !podTarget {
|
||||
return nil
|
||||
}
|
||||
|
||||
if workloadruntime.PodLevelResourceSupported(name) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("resource %q is not supported by pod-level resources", name)
|
||||
}
|
||||
|
||||
func validateServiceRules(
|
||||
ruleIndex int,
|
||||
services rules.NamespaceRuleEnforceServicesBody,
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
// Copyright 2020-2026 Project Capsule Authors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package ruleengine
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
"k8s.io/apimachinery/pkg/api/resource"
|
||||
|
||||
"github.com/projectcapsule/capsule/pkg/api/rules"
|
||||
)
|
||||
|
||||
func TestValidateRuleStatusBodyWorkloadResources(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
body *rules.NamespaceRuleBodyNamespace
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "valid",
|
||||
body: workloadResourceRuleForValidation(
|
||||
[]rules.WorkloadValidationTarget{rules.ValidateContainers},
|
||||
rules.WorkloadResourceRequestPolicy{Policy: rules.WorkloadResourceRequestPolicyDefault, Value: validationQuantity("1Gi")},
|
||||
rules.WorkloadResourceLimitPolicy{Policy: rules.WorkloadResourceLimitPolicyRatio, Value: validationQuantity("1.5")},
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "omitted target permits container-only resource names",
|
||||
body: workloadResourceRuleForValidationWithName(
|
||||
nil,
|
||||
corev1.ResourceEphemeralStorage,
|
||||
rules.WorkloadResourceRequestPolicy{Policy: rules.WorkloadResourceRequestPolicyPreserve},
|
||||
rules.WorkloadResourceLimitPolicy{Policy: rules.WorkloadResourceLimitPolicyRatio, Value: validationQuantity("1.5")},
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "ratio below one",
|
||||
body: workloadResourceRuleForValidation(
|
||||
nil,
|
||||
rules.WorkloadResourceRequestPolicy{Policy: rules.WorkloadResourceRequestPolicyPreserve},
|
||||
rules.WorkloadResourceLimitPolicy{Policy: rules.WorkloadResourceLimitPolicyRatio, Value: validationQuantity("0.5")},
|
||||
),
|
||||
wantErr: "Ratio must be greater than or equal to 1",
|
||||
},
|
||||
{
|
||||
name: "ratio missing value",
|
||||
body: workloadResourceRuleForValidation(
|
||||
nil,
|
||||
rules.WorkloadResourceRequestPolicy{Policy: rules.WorkloadResourceRequestPolicyPreserve},
|
||||
rules.WorkloadResourceLimitPolicy{Policy: rules.WorkloadResourceLimitPolicyRatio},
|
||||
),
|
||||
wantErr: "Ratio requires a value",
|
||||
},
|
||||
{
|
||||
name: "unsupported target",
|
||||
body: workloadResourceRuleForValidation(
|
||||
[]rules.WorkloadValidationTarget{rules.ValidateEphemeralContainers},
|
||||
rules.WorkloadResourceRequestPolicy{Policy: rules.WorkloadResourceRequestPolicyPreserve},
|
||||
rules.WorkloadResourceLimitPolicy{Policy: rules.WorkloadResourceLimitPolicyRatio, Value: validationQuantity("1.5")},
|
||||
),
|
||||
wantErr: "does not support resource policies",
|
||||
},
|
||||
{
|
||||
name: "pod-level ephemeral storage",
|
||||
body: workloadResourceRuleForValidation(
|
||||
[]rules.WorkloadValidationTarget{rules.ValidatePod},
|
||||
rules.WorkloadResourceRequestPolicy{Policy: rules.WorkloadResourceRequestPolicyPreserve},
|
||||
rules.WorkloadResourceLimitPolicy{Policy: rules.WorkloadResourceLimitPolicyRatio, Value: validationQuantity("1.5")},
|
||||
),
|
||||
wantErr: "is not supported by pod-level resources",
|
||||
},
|
||||
{
|
||||
name: "ratio request removed",
|
||||
body: workloadResourceRuleForValidation(
|
||||
nil,
|
||||
rules.WorkloadResourceRequestPolicy{Policy: rules.WorkloadResourceRequestPolicyRemove},
|
||||
rules.WorkloadResourceLimitPolicy{Policy: rules.WorkloadResourceLimitPolicyRatio, Value: validationQuantity("1.5")},
|
||||
),
|
||||
wantErr: "requires a request which is removed",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := ValidateRuleStatusBody(nil, []*rules.NamespaceRuleBodyNamespace{tt.body})
|
||||
if tt.wantErr == "" {
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateRuleStatusBody() error = %v", err)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("ValidateRuleStatusBody() error = %v, want containing %q", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func workloadResourceRuleForValidation(
|
||||
targets []rules.WorkloadValidationTarget,
|
||||
requestPolicy rules.WorkloadResourceRequestPolicy,
|
||||
limitPolicy rules.WorkloadResourceLimitPolicy,
|
||||
) *rules.NamespaceRuleBodyNamespace {
|
||||
name := corev1.ResourceMemory
|
||||
if len(targets) == 1 && targets[0] == rules.ValidatePod {
|
||||
name = corev1.ResourceEphemeralStorage
|
||||
}
|
||||
|
||||
return workloadResourceRuleForValidationWithName(targets, name, requestPolicy, limitPolicy)
|
||||
}
|
||||
|
||||
func workloadResourceRuleForValidationWithName(
|
||||
targets []rules.WorkloadValidationTarget,
|
||||
name corev1.ResourceName,
|
||||
requestPolicy rules.WorkloadResourceRequestPolicy,
|
||||
limitPolicy rules.WorkloadResourceLimitPolicy,
|
||||
) *rules.NamespaceRuleBodyNamespace {
|
||||
|
||||
return &rules.NamespaceRuleBodyNamespace{
|
||||
Enforce: &rules.NamespaceRuleEnforceBody{
|
||||
Workloads: rules.NamespaceRuleEnforceWorkloadsBody{
|
||||
Targets: targets,
|
||||
Resources: &rules.WorkloadResourceRules{
|
||||
Requests: map[corev1.ResourceName]rules.WorkloadResourceRequestPolicy{name: requestPolicy},
|
||||
Limits: map[corev1.ResourceName]rules.WorkloadResourceLimitPolicy{name: limitPolicy},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func validationQuantity(value string) *resource.Quantity {
|
||||
quantity := resource.MustParse(value)
|
||||
|
||||
return &quantity
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ package template
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"go.yaml.in/yaml/v2"
|
||||
"sigs.k8s.io/yaml"
|
||||
|
||||
"github.com/projectcapsule/capsule/pkg/api/rules"
|
||||
)
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"testing"
|
||||
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
"k8s.io/apimachinery/pkg/api/resource"
|
||||
|
||||
"github.com/projectcapsule/capsule/pkg/api/rules"
|
||||
"github.com/projectcapsule/capsule/pkg/api/runtime"
|
||||
@@ -274,6 +275,57 @@ func TestRenderNamespaceRuleBodies(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderNamespaceRuleBodiesPreservesResourceQuantities(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ratio := resource.MustParse("1.5")
|
||||
request := resource.MustParse("250m")
|
||||
bodies := []*rules.NamespaceRuleBodyNamespace{{
|
||||
Enforce: &rules.NamespaceRuleEnforceBody{
|
||||
Workloads: rules.NamespaceRuleEnforceWorkloadsBody{
|
||||
Resources: &rules.WorkloadResourceRules{
|
||||
Requests: map[corev1.ResourceName]rules.WorkloadResourceRequestPolicy{
|
||||
corev1.ResourceCPU: {
|
||||
Policy: rules.WorkloadResourceRequestPolicyDefault,
|
||||
Value: &request,
|
||||
},
|
||||
},
|
||||
Limits: map[corev1.ResourceName]rules.WorkloadResourceLimitPolicy{
|
||||
corev1.ResourceCPU: {
|
||||
Policy: rules.WorkloadResourceLimitPolicyRatio,
|
||||
Value: &ratio,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
|
||||
got, err := RenderNamespaceRuleBodies(nil, MissingKeyError, bodies)
|
||||
if err != nil {
|
||||
t.Fatalf("RenderNamespaceRuleBodies() unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if len(got) != 1 || got[0] == nil || got[0].Enforce == nil {
|
||||
t.Fatalf("RenderNamespaceRuleBodies() = %#v, want one enforce body", got)
|
||||
}
|
||||
|
||||
resources := got[0].Enforce.Workloads.Resources
|
||||
if resources == nil {
|
||||
t.Fatal("rendered resources are nil")
|
||||
}
|
||||
|
||||
gotRequest := resources.Requests[corev1.ResourceCPU].Value
|
||||
if gotRequest == nil || gotRequest.Cmp(request) != 0 {
|
||||
t.Fatalf("rendered request = %v, want %s", gotRequest, request.String())
|
||||
}
|
||||
|
||||
gotRatio := resources.Limits[corev1.ResourceCPU].Value
|
||||
if gotRatio == nil || gotRatio.Cmp(ratio) != 0 {
|
||||
t.Fatalf("rendered ratio = %v, want %s", gotRatio, ratio.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderNamespaceRuleBodies_DoesNotMutateInput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user