mirror of
https://github.com/kubernetes/kubernetes.git
synced 2026-02-03 20:40:26 -05:00
Merge 45c2557a4b into 1861c1a072
This commit is contained in:
commit
c101e59f0b
9 changed files with 632 additions and 117 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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, "", ""
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
208
plugin/pkg/admission/podresize/admission.go
Normal file
208
plugin/pkg/admission/podresize/admission.go
Normal 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)
|
||||
}
|
||||
291
plugin/pkg/admission/podresize/admission_test.go
Normal file
291
plugin/pkg/admission/podresize/admission_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue