This commit is contained in:
Natasha Sarkar 2026-02-03 16:02:39 -08:00 committed by GitHub
commit c101e59f0b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 632 additions and 117 deletions

View file

@ -48,6 +48,7 @@ import (
"k8s.io/kubernetes/plugin/pkg/admission/noderestriction"
"k8s.io/kubernetes/plugin/pkg/admission/nodetaint"
"k8s.io/kubernetes/plugin/pkg/admission/podnodeselector"
"k8s.io/kubernetes/plugin/pkg/admission/podresize"
"k8s.io/kubernetes/plugin/pkg/admission/podtolerationrestriction"
"k8s.io/kubernetes/plugin/pkg/admission/podtopologylabels"
podpriority "k8s.io/kubernetes/plugin/pkg/admission/priority"
@ -99,6 +100,7 @@ var AllOrderedPlugins = []string{
denyserviceexternalips.PluginName, // DenyServiceExternalIPs
podtopologylabels.PluginName, // PodTopologyLabels
nodedeclaredfeatures.PluginName, // NodeDeclaredFeatureValidator
podresize.PluginName, // PodResizeValidator
// new admission plugins should generally be inserted above here
// webhook, resourcequota, and deny plugins must go at the end
@ -152,6 +154,7 @@ func RegisterAllAdmissionPlugins(plugins *admission.Plugins) {
certsubjectrestriction.Register(plugins)
podtopologylabels.Register(plugins)
nodedeclaredfeatures.Register(plugins)
podresize.Register(plugins)
}
// DefaultOffAdmissionPlugins get admission plugins off by default for kube-apiserver.
@ -180,6 +183,7 @@ func DefaultOffAdmissionPlugins() sets.Set[string] {
mutatingadmissionpolicy.PluginName, // Mutatingadmissionpolicy, only active when feature gate MutatingAdmissionpolicy is enabled
validatingadmissionpolicy.PluginName, // ValidatingAdmissionPolicy, only active when feature gate ValidatingAdmissionPolicy is enabled
nodedeclaredfeatures.PluginName, // NodeDeclaredFeatureValidator, only active when feature gate NodeDeclaredFeatures is enabled
podresize.PluginName, // PodResizeValidator, only active when feature gate InPlacePodVerticalScaling is enabled
)
return sets.New(AllOrderedPlugins...).Difference(defaultOnPlugins)

View file

@ -21,6 +21,7 @@ import (
"fmt"
"path/filepath"
"slices"
"strings"
"sync"
"time"
@ -588,10 +589,10 @@ func (m *manager) handlePodResourcesResize(logger klog.Logger, pod *v1.Pod) (boo
m.statusManager.ClearPodResizePendingCondition(pod.UID)
return false, nil
} else if resizable, msg, reason := IsInPlacePodVerticalScalingAllowed(pod); !resizable {
} else if !utilfeature.DefaultFeatureGate.Enabled(features.InPlacePodVerticalScaling) {
// If there is a pending resize but the resize is not allowed, always use the allocated resources.
metrics.PodInfeasibleResizes.WithLabelValues(reason).Inc()
m.statusManager.SetPodResizePendingCondition(pod.UID, v1.PodReasonInfeasible, msg, pod.Generation)
metrics.PodInfeasibleResizes.WithLabelValues("feature_gate_off").Inc()
m.statusManager.SetPodResizePendingCondition(pod.UID, v1.PodReasonInfeasible, "InPlacePodVerticalScaling is disabled", pod.Generation)
return false, nil
} else if resizeNotAllowed, msg := disallowResizeForSwappableContainers(m.containerRuntime, pod, allocatedPod); resizeNotAllowed {
@ -602,10 +603,10 @@ func (m *manager) handlePodResourcesResize(logger klog.Logger, pod *v1.Pod) (boo
}
if !apiequality.Semantic.DeepEqual(pod.Spec.Resources, allocatedPod.Spec.Resources) {
if resizable, msg, reason := IsInPlacePodLevelResourcesVerticalScalingAllowed(pod); !resizable {
if !utilfeature.DefaultFeatureGate.Enabled(features.InPlacePodLevelResourcesVerticalScaling) {
// If there is a pending pod-level resources resize but the resize is not allowed, always use the allocated resources.
metrics.PodInfeasibleResizes.WithLabelValues(reason).Inc()
m.statusManager.SetPodResizePendingCondition(pod.UID, v1.PodReasonInfeasible, msg, pod.Generation)
metrics.PodInfeasibleResizes.WithLabelValues("plr_feature_gate_off").Inc()
m.statusManager.SetPodResizePendingCondition(pod.UID, v1.PodReasonInfeasible, "InPlacePodLevelResourcesVerticalScaling is disabled", pod.Generation)
return false, nil
}
}
@ -728,18 +729,19 @@ func (m *manager) canResizePod(logger klog.Logger, allocatedPods []*v1.Pod, pod
}
}
cpuAvailable := m.nodeAllocatableAbsolute.Cpu().MilliValue()
memAvailable := m.nodeAllocatableAbsolute.Memory().Value()
cpuAllocatable := m.nodeAllocatableAbsolute.Cpu().MilliValue()
memAllocatable := m.nodeAllocatableAbsolute.Memory().Value()
cpuRequests := resource.GetResourceRequest(pod, v1.ResourceCPU)
memRequests := resource.GetResourceRequest(pod, v1.ResourceMemory)
if cpuRequests > cpuAvailable || memRequests > memAvailable {
var msg string
if memRequests > memAvailable {
msg = fmt.Sprintf("memory, requested: %d, capacity: %d", memRequests, memAvailable)
} else {
msg = fmt.Sprintf("cpu, requested: %d, capacity: %d", cpuRequests, cpuAvailable)
}
msg = "Node didn't have enough capacity: " + msg
var msgs []string
if cpuRequests > cpuAllocatable {
msgs = append(msgs, fmt.Sprintf("cpu, requested: %d, allocatable: %d", cpuRequests, cpuAllocatable))
}
if memRequests > memAllocatable {
msgs = append(msgs, fmt.Sprintf("memory, requested: %d, allocatable: %d", memRequests, memAllocatable))
}
if len(msgs) > 0 {
msg := fmt.Sprintf("Node didn't have enough allocatable resources: %s", strings.Join(msgs, "; "))
logger.V(3).Info(msg, "pod", klog.KObj(pod))
metrics.PodInfeasibleResizes.WithLabelValues("insufficient_node_allocatable").Inc()
return false, v1.PodReasonInfeasible, msg

View file

@ -1,39 +0,0 @@
//go:build linux
/*
Copyright 2025 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package allocation
import (
v1 "k8s.io/api/core/v1"
utilfeature "k8s.io/apiserver/pkg/util/feature"
"k8s.io/kubernetes/pkg/features"
)
func IsInPlacePodVerticalScalingAllowed(pod *v1.Pod) (allowed bool, msg, reason string) {
if !utilfeature.DefaultFeatureGate.Enabled(features.InPlacePodVerticalScaling) {
return false, "InPlacePodVerticalScaling is disabled", "feature_gate_off"
}
return true, "", ""
}
func IsInPlacePodLevelResourcesVerticalScalingAllowed(pod *v1.Pod) (allowed bool, msg, reason string) {
if !utilfeature.DefaultFeatureGate.Enabled(features.InPlacePodLevelResourcesVerticalScaling) {
return false, "InPlacePodLevelResourcesVerticalScaling is disabled", "plr_feature_gate_off"
}
return true, "", ""
}

View file

@ -1,29 +0,0 @@
//go:build !linux && !windows
/*
Copyright 2025 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package allocation
import v1 "k8s.io/api/core/v1"
func IsInPlacePodVerticalScalingAllowed(_ *v1.Pod) (allowed bool, msg, reason string) {
return false, "In-place pod resize is not supported on this node", "unsupported_platform"
}
func IsInPlacePodLevelResourcesVerticalScalingAllowed(pod *v1.Pod) (allowed bool, msg, reason string) {
return false, "In-place pod-level resources resize is not supported on this node", "unsupported_platform"
}

View file

@ -1,29 +0,0 @@
//go:build windows
/*
Copyright 2025 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package allocation
import v1 "k8s.io/api/core/v1"
func IsInPlacePodVerticalScalingAllowed(_ *v1.Pod) (allowed bool, msg, reason string) {
return false, "In-place pod resize is not supported on Windows", "windows"
}
func IsInPlacePodLevelResourcesVerticalScalingAllowed(pod *v1.Pod) (allowed bool, msg, reason string) {
return false, "In-place pod-level resources resize is not supported on Windows", "windows"
}

View file

@ -53,7 +53,6 @@ import (
"k8s.io/kubernetes/pkg/credentialprovider"
"k8s.io/kubernetes/pkg/credentialprovider/plugin"
"k8s.io/kubernetes/pkg/features"
"k8s.io/kubernetes/pkg/kubelet/allocation"
"k8s.io/kubernetes/pkg/kubelet/allocation/state"
kubeletconfiginternal "k8s.io/kubernetes/pkg/kubelet/apis/config"
"k8s.io/kubernetes/pkg/kubelet/cm"
@ -670,7 +669,7 @@ func podResourcesFromRequirements(requirements *v1.ResourceRequirements) podLeve
// TODO(vibansal): Make this function to be agnostic to whether it is dealing with a restartable init container or not (i.e. remove the argument `isRestartableInitContainer`).
func (m *kubeGenericRuntimeManager) computePodResizeAction(ctx context.Context, pod *v1.Pod, containerIdx int, isRestartableInitContainer bool, kubeContainerStatus *kubecontainer.Status, changes *podActions) (keepContainer bool) {
logger := klog.FromContext(ctx)
if resizable, _, _ := allocation.IsInPlacePodVerticalScalingAllowed(pod); !resizable {
if !utilfeature.DefaultFeatureGate.Enabled(features.InPlacePodVerticalScaling) {
return true
}
@ -1218,7 +1217,7 @@ func (m *kubeGenericRuntimeManager) computePodActions(ctx context.Context, pod *
}
}
if resizable, _, _ := allocation.IsInPlacePodVerticalScalingAllowed(pod); resizable {
if utilfeature.DefaultFeatureGate.Enabled(features.InPlacePodVerticalScaling) {
changes.ContainersToUpdate = make(map[v1.ResourceName][]containerToUpdateInfo)
}
@ -1695,7 +1694,7 @@ func (m *kubeGenericRuntimeManager) SyncPod(ctx context.Context, pod *v1.Pod, po
}
// Step 7: For containers in podContainerChanges.ContainersToUpdate[CPU,Memory] list, invoke UpdateContainerResources
if resizable, _, _ := allocation.IsInPlacePodVerticalScalingAllowed(pod); resizable {
if utilfeature.DefaultFeatureGate.Enabled(features.InPlacePodVerticalScaling) {
if len(podContainerChanges.ContainersToUpdate) > 0 || podContainerChanges.UpdatePodResources || podContainerChanges.UpdatePodLevelResources {
result.SyncResults = append(result.SyncResults, m.doPodResizeAction(ctx, pod, podStatus, podContainerChanges))
}

View file

@ -0,0 +1,208 @@
/*
Copyright 2026 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package podresize
import (
"context"
"fmt"
"io"
"strings"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/errors"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apiserver/pkg/admission"
genericadmissioninitializer "k8s.io/apiserver/pkg/admission/initializer"
"k8s.io/client-go/informers"
corev1listers "k8s.io/client-go/listers/core/v1"
"k8s.io/component-base/featuregate"
"k8s.io/kubernetes/pkg/api/v1/resource"
"k8s.io/kubernetes/pkg/apis/core"
v1 "k8s.io/kubernetes/pkg/apis/core/v1"
"k8s.io/kubernetes/pkg/features"
)
const (
// PluginName is the name of this admission controller plugin.
PluginName = "PodResizeValidator"
ReasonNodeCapacity metav1.CauseType = "NodeCapacity"
ReasonUnsupportedPlatform metav1.CauseType = "UnsupportedPlatform"
)
// Register registers a plugin.
func Register(plugins *admission.Plugins) {
plugins.Register(PluginName, func(config io.Reader) (admission.Interface, error) {
return NewPlugin()
})
}
// Plugin is an admission controller that validates pod resize requests against the node's capabilities.
type Plugin struct {
*admission.Handler
nodeLister corev1listers.NodeLister
inPlacePodVerticalScalingEnabled bool
}
var _ admission.ValidationInterface = &Plugin{}
var _ = genericadmissioninitializer.WantsExternalKubeInformerFactory(&Plugin{})
// New creates a new Plugin admission plugin
func NewPlugin() (*Plugin, error) {
return &Plugin{
Handler: admission.NewHandler(admission.Update),
}, nil
}
// SetExternalKubeInformerFactory sets the informer factory for the plugin.
func (p *Plugin) SetExternalKubeInformerFactory(f informers.SharedInformerFactory) {
nodeInformer := f.Core().V1().Nodes()
p.nodeLister = f.Core().V1().Nodes().Lister()
p.SetReadyFunc(nodeInformer.Informer().HasSynced)
}
// SetFeatures sets the feature gates for the plugin.
func (p *Plugin) InspectFeatureGates(featureGates featuregate.FeatureGate) {
p.inPlacePodVerticalScalingEnabled = featureGates.Enabled(features.InPlacePodVerticalScaling)
}
// ValidateInitialization ensures that the plugin is properly initialized.
func (p *Plugin) ValidateInitialization() error {
if p.nodeLister == nil {
return fmt.Errorf("missing node lister for %s", PluginName)
}
return nil
}
// Validate is the core of the admission controller logic.
func (p *Plugin) Validate(ctx context.Context, a admission.Attributes, o admission.ObjectInterfaces) error {
// Ignore if the feature gate is not enabled.
if !p.inPlacePodVerticalScalingEnabled {
return nil
}
if a.GetOperation() != admission.Update {
return nil
}
// We only care about Pod updates.
if a.GetResource().GroupResource() != core.Resource("pods") {
return nil
}
// Only validate updates to the custom "resize" subresource.
if a.GetSubresource() != "resize" {
return nil
}
pod, ok := a.GetObject().(*core.Pod)
if !ok {
return errors.NewBadRequest(fmt.Sprintf("expected a pod but got a %T", a.GetObject()))
}
// We only care about pods that are already bound to a node.
if len(pod.Spec.NodeName) == 0 {
return nil
}
oldPod, ok := a.GetOldObject().(*core.Pod)
if !ok {
return errors.NewBadRequest(fmt.Sprintf("expected an old pod but got a %T", a.GetOldObject()))
}
// Since generation is only incremented when the spec changes, we can skip validation if it doesnt.
if oldPod.Generation == pod.Generation {
return nil
}
if !p.WaitForReady() {
return admission.NewForbidden(a, fmt.Errorf("not yet ready to handle request"))
}
return p.validatePodResize(pod, a)
}
func (p *Plugin) validatePodResize(pod *core.Pod, a admission.Attributes) error {
// Convert internal to external pods for the helper library.
podV1 := &corev1.Pod{}
if err := v1.Convert_core_Pod_To_v1_Pod(pod, podV1, nil); err != nil {
return errors.NewBadRequest(fmt.Sprintf("failed to convert pod: %v", err))
}
node, err := p.nodeLister.Get(pod.Spec.NodeName)
if err != nil {
if errors.IsNotFound(err) {
return admission.NewForbidden(a, fmt.Errorf("node %q not found", pod.Spec.NodeName))
}
return admission.NewForbidden(a, fmt.Errorf("failed to get node %q: %w", pod.Spec.NodeName, err))
}
// If the new requests are larger than the node allocatable, reject the update.
if err := validateNodeAllocatable(podV1, node); err != nil {
statusErr := admission.NewForbidden(a, err).(*apierrors.StatusError)
statusErr.ErrStatus.Details.Causes = append(statusErr.ErrStatus.Details.Causes, metav1.StatusCause{
Type: ReasonNodeCapacity,
})
return statusErr
}
// If the node is not a linux node, reject the update.
if err := validateLinuxNode(node); err != nil {
statusErr := admission.NewForbidden(a, err).(*apierrors.StatusError)
statusErr.ErrStatus.Details.Causes = append(statusErr.ErrStatus.Details.Causes, metav1.StatusCause{
Type: ReasonUnsupportedPlatform,
})
return statusErr
}
return nil
}
// Returns nil if the pod's resource requests fit within the node's allocatable, err otherwise.
func validateNodeAllocatable(pod *corev1.Pod, node *corev1.Node) error {
cpuAllocatable := node.Status.Allocatable.Cpu().MilliValue()
memAllocatable := node.Status.Allocatable.Memory().Value()
cpuRequests := resource.GetResourceRequest(pod, corev1.ResourceCPU)
memRequests := resource.GetResourceRequest(pod, corev1.ResourceMemory)
var msg []string
if cpuRequests > cpuAllocatable {
msg = append(msg, fmt.Sprintf("cpu, requested: %d, allocatable: %d", cpuRequests, cpuAllocatable))
}
if memRequests > memAllocatable {
msg = append(msg, fmt.Sprintf("memory, requested: %d, allocatable: %d", memRequests, memAllocatable))
}
if len(msg) > 0 {
return fmt.Errorf("Node didn't have enough allocatable resources: %s", strings.Join(msg, "; "))
}
return nil
}
// Returns nil if the node is a linux node, err otherwise.
func validateLinuxNode(node *corev1.Node) error {
if node == nil {
return fmt.Errorf("node is nil")
}
// This label should always be populated.
val, ok := node.Labels[corev1.LabelOSStable]
if ok && val == "linux" {
return nil
}
return fmt.Errorf("pod resize is only supported on linux nodes, node %q is %q", node.Name, val)
}

View file

@ -0,0 +1,291 @@
/*
Copyright 2026 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package podresize
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/resource"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apiserver/pkg/admission"
"k8s.io/client-go/informers"
"k8s.io/client-go/kubernetes/fake"
"k8s.io/kubernetes/pkg/apis/core"
)
func TestResizeAdmission(t *testing.T) {
linuxNode := &corev1.Node{
ObjectMeta: metav1.ObjectMeta{
Name: "linux-node",
Labels: map[string]string{
corev1.LabelOSStable: "linux",
},
},
Status: corev1.NodeStatus{
Allocatable: corev1.ResourceList{
corev1.ResourceCPU: resource.MustParse("4"),
corev1.ResourceMemory: resource.MustParse("8Gi"),
},
},
}
windowsNode := &corev1.Node{
ObjectMeta: metav1.ObjectMeta{
Name: "windows-node",
Labels: map[string]string{
corev1.LabelOSStable: "windows",
},
},
Status: corev1.NodeStatus{
Allocatable: corev1.ResourceList{
corev1.ResourceCPU: resource.MustParse("4"),
corev1.ResourceMemory: resource.MustParse("8Gi"),
},
},
}
testPod := &core.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: "test-pod",
Namespace: "default",
Generation: 1,
},
Spec: core.PodSpec{
NodeName: "linux-node",
Containers: []core.Container{
{
Name: "main",
Resources: core.ResourceRequirements{
Requests: core.ResourceList{
core.ResourceCPU: resource.MustParse("1"),
core.ResourceMemory: resource.MustParse("1Gi"),
},
},
},
},
},
}
testCases := []struct {
name string
subresource string
nodeName string
oldPod *core.Pod
newPod func() *core.Pod
expectErr bool
errContains string
}{
{
name: "Successful cpu resize on Linux node",
subresource: "resize",
nodeName: "linux-node",
oldPod: testPod,
newPod: func() *core.Pod {
p := testPod.DeepCopy()
p.Generation = 2
p.Spec.Containers[0].Resources.Requests[core.ResourceCPU] = resource.MustParse("2")
return p
},
expectErr: false,
},
{
name: "Successful memory resize on Linux node",
subresource: "resize",
nodeName: "linux-node",
oldPod: testPod,
newPod: func() *core.Pod {
p := testPod.DeepCopy()
p.Generation = 2
p.Spec.Containers[0].Resources.Requests[core.ResourceMemory] = resource.MustParse("2Gi")
return p
},
expectErr: false,
},
{
name: "Skip validation if not resize subresource",
subresource: "",
nodeName: "linux-node",
oldPod: testPod,
newPod: func() *core.Pod {
p := testPod.DeepCopy()
p.Generation = 2
p.Spec.Containers[0].Resources.Requests[core.ResourceCPU] = resource.MustParse("10") // Would fail if checked
return p
},
expectErr: false,
},
{
name: "Skip validation if generation is unchanged",
subresource: "",
nodeName: "linux-node",
oldPod: testPod,
newPod: func() *core.Pod {
p := testPod.DeepCopy()
p.Generation = 1
p.Spec.Containers[0].Resources.Requests[core.ResourceCPU] = resource.MustParse("10") // Would fail if checked
return p
},
expectErr: false,
},
{
name: "Skip validation if pod not bound to a node",
subresource: "resize",
nodeName: "",
oldPod: func() *core.Pod {
p := testPod.DeepCopy()
p.Spec.NodeName = ""
return p
}(),
newPod: func() *core.Pod {
p := testPod.DeepCopy()
p.Spec.NodeName = ""
p.Generation = 2
p.Spec.Containers[0].Resources.Requests[core.ResourceCPU] = resource.MustParse("10")
return p
},
expectErr: false,
},
{
name: "Reject resize exceeding CPU allocatable",
subresource: "resize",
nodeName: "linux-node",
oldPod: testPod,
newPod: func() *core.Pod {
p := testPod.DeepCopy()
p.Generation = 2
p.Spec.Containers[0].Resources.Requests[core.ResourceCPU] = resource.MustParse("10")
return p
},
expectErr: true,
errContains: "Node didn't have enough allocatable resources: cpu",
},
{
name: "Reject resize exceeding memory allocatable",
subresource: "resize",
nodeName: "linux-node",
oldPod: testPod,
newPod: func() *core.Pod {
p := testPod.DeepCopy()
p.Generation = 2
p.Spec.Containers[0].Resources.Requests[core.ResourceMemory] = resource.MustParse("10Gi")
return p
},
expectErr: true,
errContains: "Node didn't have enough allocatable resources: memory",
},
{
name: "Reject resize exceeding both CPU and memory allocatable",
subresource: "resize",
nodeName: "linux-node",
oldPod: testPod,
newPod: func() *core.Pod {
p := testPod.DeepCopy()
p.Generation = 2
p.Spec.Containers[0].Resources.Requests[core.ResourceCPU] = resource.MustParse("10")
p.Spec.Containers[0].Resources.Requests[core.ResourceMemory] = resource.MustParse("10Gi")
return p
},
expectErr: true,
errContains: "Node didn't have enough allocatable resources: cpu, requested: 10000, allocatable: 4000; memory, requested: 10737418240, allocatable: 8589934592",
},
{
name: "Reject resize on non-Linux node",
subresource: "resize",
nodeName: "windows-node",
oldPod: func() *core.Pod {
p := testPod.DeepCopy()
p.Spec.NodeName = "windows-node"
return p
}(),
newPod: func() *core.Pod {
p := testPod.DeepCopy()
p.Spec.NodeName = "windows-node"
p.Generation = 2
p.Spec.Containers[0].Resources.Requests[core.ResourceCPU] = resource.MustParse("2")
return p
},
expectErr: true,
errContains: "pod resize is only supported on linux nodes",
},
{
name: "Reject if node not found",
subresource: "resize",
nodeName: "missing-node",
oldPod: func() *core.Pod {
p := testPod.DeepCopy()
p.Spec.NodeName = "missing-node"
return p
}(),
newPod: func() *core.Pod {
p := testPod.DeepCopy()
p.Spec.NodeName = "missing-node"
p.Generation = 2
return p
},
expectErr: true,
errContains: "node \"missing-node\" not found",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
client := fake.NewClientset(linuxNode, windowsNode)
informerFactory := informers.NewSharedInformerFactory(client, 0)
plugin, err := NewPlugin()
require.NoError(t, err)
plugin.SetExternalKubeInformerFactory(informerFactory)
plugin.inPlacePodVerticalScalingEnabled = true
stopCh := make(chan struct{})
defer close(stopCh)
informerFactory.Start(stopCh)
informerFactory.WaitForCacheSync(stopCh)
newPodObj := tc.newPod()
attrs := admission.NewAttributesRecord(
newPodObj,
tc.oldPod,
core.Kind("Pod").WithVersion("v1"),
newPodObj.Namespace,
newPodObj.Name,
core.Resource("pods").WithVersion("v1"),
tc.subresource,
admission.Update,
&metav1.UpdateOptions{},
false,
nil,
)
err = plugin.Validate(context.Background(), attrs, nil)
if tc.expectErr {
require.Error(t, err)
if tc.errContains != "" {
assert.Contains(t, err.Error(), tc.errContains)
}
} else {
require.NoError(t, err)
}
})
}
}

View file

@ -1639,3 +1639,111 @@ func TestNodeDeclaredFeatureAdmission(t *testing.T) {
})
}
}
func TestPodResizeValidation(t *testing.T) {
server := kubeapiservertesting.StartTestServerOrDie(t, nil,
append(framework.DefaultTestServerFlags(), "--enable-admission-plugins=ResizeValidator"),
framework.SharedEtcd())
defer server.TearDownFn()
client := clientset.NewForConfigOrDie(server.ClientConfig)
ctx := context.Background()
ns := framework.CreateNamespaceOrDie(client, "resize-validation", t)
defer framework.DeleteNamespaceOrDie(client, ns, t)
createNode := func(name string, os string, cpu string, mem string) {
node := &v1.Node{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Labels: map[string]string{
v1.LabelOSStable: os,
},
},
Status: v1.NodeStatus{
Allocatable: v1.ResourceList{
v1.ResourceCPU: resource.MustParse(cpu),
v1.ResourceMemory: resource.MustParse(mem),
},
},
}
if _, err := client.CoreV1().Nodes().Create(ctx, node, metav1.CreateOptions{}); err != nil {
t.Fatalf("Failed to create node %s: %v", name, err)
}
}
createNode("linux-node-small", "linux", "2", "2Gi")
createNode("windows-node", "windows", "8", "16Gi")
testPod := &v1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: "test-pod",
},
Spec: v1.PodSpec{
Containers: []v1.Container{
{
Name: "c1",
Image: "pause",
Resources: v1.ResourceRequirements{
Requests: v1.ResourceList{
v1.ResourceCPU: resource.MustParse("500m"),
v1.ResourceMemory: resource.MustParse("512Mi"),
},
},
},
},
},
}
tests := []struct {
name string
targetNode string
resizeCPU string
expectError string
}{
{
name: "valid resize on linux node",
targetNode: "linux-node-small",
resizeCPU: "1",
},
{
name: "fail resize exceeding node allocatable",
targetNode: "linux-node-small",
resizeCPU: "4", // Node only has 2
expectError: "Node didn't have enough allocatable resources: cpu",
},
{
name: "fail resize on non-linux node",
targetNode: "windows-node",
resizeCPU: "1",
expectError: "pod resize is only supported on linux nodes",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
p := testPod.DeepCopy()
p.Spec.NodeName = tc.targetNode
pod, err := client.CoreV1().Pods(ns.Name).Create(ctx, p, metav1.CreateOptions{})
if err != nil {
t.Fatalf("Error creating pod: %v", err)
}
defer client.CoreV1().Pods(ns.Name).Delete(ctx, pod.Name, metav1.DeleteOptions{})
pod.Spec.Containers[0].Resources.Requests[v1.ResourceCPU] = resource.MustParse(tc.resizeCPU)
_, err = client.CoreV1().Pods(ns.Name).UpdateResize(ctx, pod.Name, pod, metav1.UpdateOptions{})
if tc.expectError == "" {
if err != nil {
t.Errorf("Expected success, got error: %v", err)
}
} else {
if err == nil {
t.Error("Expected error but got success")
} else if !strings.Contains(err.Error(), tc.expectError) {
t.Errorf("Expected error containing %q, got: %v", tc.expectError, err)
}
}
})
}
}