diff --git a/pkg/kubeapiserver/options/plugins.go b/pkg/kubeapiserver/options/plugins.go index 4fb7d0b5b82..5f86e9a9802 100644 --- a/pkg/kubeapiserver/options/plugins.go +++ b/pkg/kubeapiserver/options/plugins.go @@ -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) diff --git a/pkg/kubelet/allocation/allocation_manager.go b/pkg/kubelet/allocation/allocation_manager.go index 7be1d828a49..da000887358 100644 --- a/pkg/kubelet/allocation/allocation_manager.go +++ b/pkg/kubelet/allocation/allocation_manager.go @@ -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 diff --git a/pkg/kubelet/allocation/features_linux.go b/pkg/kubelet/allocation/features_linux.go deleted file mode 100644 index 142afc415c5..00000000000 --- a/pkg/kubelet/allocation/features_linux.go +++ /dev/null @@ -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, "", "" -} diff --git a/pkg/kubelet/allocation/features_unsupported.go b/pkg/kubelet/allocation/features_unsupported.go deleted file mode 100644 index a81a51b4009..00000000000 --- a/pkg/kubelet/allocation/features_unsupported.go +++ /dev/null @@ -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" -} diff --git a/pkg/kubelet/allocation/features_windows.go b/pkg/kubelet/allocation/features_windows.go deleted file mode 100644 index eda519b9741..00000000000 --- a/pkg/kubelet/allocation/features_windows.go +++ /dev/null @@ -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" -} diff --git a/pkg/kubelet/kuberuntime/kuberuntime_manager.go b/pkg/kubelet/kuberuntime/kuberuntime_manager.go index 46eec1c2abc..139331c4c37 100644 --- a/pkg/kubelet/kuberuntime/kuberuntime_manager.go +++ b/pkg/kubelet/kuberuntime/kuberuntime_manager.go @@ -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)) } diff --git a/plugin/pkg/admission/podresize/admission.go b/plugin/pkg/admission/podresize/admission.go new file mode 100644 index 00000000000..3945be741c1 --- /dev/null +++ b/plugin/pkg/admission/podresize/admission.go @@ -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) +} diff --git a/plugin/pkg/admission/podresize/admission_test.go b/plugin/pkg/admission/podresize/admission_test.go new file mode 100644 index 00000000000..3572917814c --- /dev/null +++ b/plugin/pkg/admission/podresize/admission_test.go @@ -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) + } + }) + } +} diff --git a/test/integration/pods/pods_test.go b/test/integration/pods/pods_test.go index be9a0745858..523dc717115 100644 --- a/test/integration/pods/pods_test.go +++ b/test/integration/pods/pods_test.go @@ -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) + } + } + }) + } +}