diff --git a/pkg/kubelet/kubelet_node_declared_features_test.go b/pkg/kubelet/kubelet_node_declared_features_test.go new file mode 100644 index 00000000000..2228845b6a1 --- /dev/null +++ b/pkg/kubelet/kubelet_node_declared_features_test.go @@ -0,0 +1,94 @@ +/* +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 kubelet + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + utilfeature "k8s.io/apiserver/pkg/util/feature" + featuregatetesting "k8s.io/component-base/featuregate/testing" + ndflib "k8s.io/component-helpers/nodedeclaredfeatures" + ndffeatures "k8s.io/component-helpers/nodedeclaredfeatures/features" + "k8s.io/kubernetes/pkg/features" + "k8s.io/kubernetes/pkg/kubelet/cm" +) + +func TestGuaranteedPodExclusiveCPUsFeatureDiscovery(t *testing.T) { + featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.NodeDeclaredFeatures, true) + + testcases := []struct { + name string + cpuManagerPolicy string + inplacePodResizeExclusiveCPUsEnable bool + expectedFeature string + expectFeature bool + }{ + { + name: "feature enabled with static cpu manager policy", + cpuManagerPolicy: "static", + inplacePodResizeExclusiveCPUsEnable: true, + expectedFeature: "GuaranteedQoSPodCPUResize", + expectFeature: true, + }, + { + name: "feature enabled with none cpu manager policy", + cpuManagerPolicy: "none", + inplacePodResizeExclusiveCPUsEnable: true, + expectedFeature: "GuaranteedQoSPodCPUResize", + expectFeature: true, + }, + { + name: "feature disabled with static cpu manager policy", + cpuManagerPolicy: "static", + inplacePodResizeExclusiveCPUsEnable: false, + expectedFeature: "GuaranteedQoSPodCPUResize", + expectFeature: false, + }, + { + name: "feature disabled with none cpu manager policy", + cpuManagerPolicy: "none", + inplacePodResizeExclusiveCPUsEnable: false, + expectedFeature: "GuaranteedQoSPodCPUResize", + expectFeature: true, + }, + } + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.InPlacePodVerticalScalingExclusiveCPUs, tc.inplacePodResizeExclusiveCPUsEnable) + + testKubelet := newTestKubelet(t, false /* controllerAttachDetachEnabled */) + defer testKubelet.Cleanup() + kubelet := testKubelet.kubelet + fakeCM := cm.NewFakeContainerManagerWithNodeConfig(cm.NodeConfig{ + CPUManagerPolicy: tc.cpuManagerPolicy, + }) + kubelet.containerManager = fakeCM + framework, err := ndflib.New(ndffeatures.AllFeatures) + require.NoError(t, err) + kubelet.nodeDeclaredFeaturesFramework = framework + + features := kubelet.discoverNodeDeclaredFeatures() + if tc.expectFeature { + assert.Contains(t, features, tc.expectedFeature) + } else { + assert.NotContains(t, features, tc.expectedFeature) + } + }) + } +} diff --git a/staging/src/k8s.io/component-helpers/nodedeclaredfeatures/features/inplacepodresize/guaranteed_cpu_resize.go b/staging/src/k8s.io/component-helpers/nodedeclaredfeatures/features/inplacepodresize/guaranteed_cpu_resize.go new file mode 100644 index 00000000000..b91b951175d --- /dev/null +++ b/staging/src/k8s.io/component-helpers/nodedeclaredfeatures/features/inplacepodresize/guaranteed_cpu_resize.go @@ -0,0 +1,85 @@ +/* +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 inplacepodresize + +import ( + v1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/util/version" + "k8s.io/component-helpers/nodedeclaredfeatures" +) + +// Ensure the feature struct implements the unified Feature interface. +var _ nodedeclaredfeatures.Feature = &guaranteedQoSPodCPUResizeFeature{} + +const ( + // IPPRExclusiveCPUsFeatureGate is the feature gate for IPPRExclusiveCPUsFeatureGate. + IPPRExclusiveCPUsFeatureGate = "InPlacePodVerticalScalingExclusiveCPUs" + // CPUManagerPolicyStatic is the value for the static CPUManagerPolicy. + CPUManagerPolicyStatic = "static" + // CPUManagerPolicyNone is the value for the none CPUManagerPolicy. + CPUManagerPolicyNone = "none" + // GuaranteedQoSPodCPUResize is a declared feature that indicates a node supports in-place pod resize for guaranteed QoS pods with exclusive CPUs. + GuaranteedQoSPodCPUResize = "GuaranteedQoSPodCPUResize" +) + +// Feature is the implementation of the `GuaranteedQoSPodCPUResize` feature. +var Feature = &guaranteedQoSPodCPUResizeFeature{} + +type guaranteedQoSPodCPUResizeFeature struct{} + +func (f *guaranteedQoSPodCPUResizeFeature) Name() string { + return GuaranteedQoSPodCPUResize +} + +func (f *guaranteedQoSPodCPUResizeFeature) Discover(cfg *nodedeclaredfeatures.NodeConfiguration) bool { + featureGateEnabled := cfg.FeatureGates.Enabled(IPPRExclusiveCPUsFeatureGate) + cpuManagerPolicy := cfg.StaticConfig.CPUManagerPolicy + if (featureGateEnabled && cpuManagerPolicy == CPUManagerPolicyStatic) || (cpuManagerPolicy == CPUManagerPolicyNone) { + return true + } + return false +} + +func (f *guaranteedQoSPodCPUResizeFeature) InferForScheduling(podInfo *nodedeclaredfeatures.PodInfo) bool { + // This feature is only relevant for pod updates (resizes). + return false +} + +func (f *guaranteedQoSPodCPUResizeFeature) InferForUpdate(oldPodInfo, newPodInfo *nodedeclaredfeatures.PodInfo) bool { + // Since this is an update, the QOS class must already be se ans must be the same for old and new pod spec. + if oldPodInfo.Status != nil && oldPodInfo.Status.QOSClass != v1.PodQOSGuaranteed { + return false + } + + oldPodSpec := oldPodInfo.Spec + newPodSpec := newPodInfo.Spec + + // Check if CPU request is changing for any container. + for i := range oldPodSpec.Containers { + oldCPU := oldPodSpec.Containers[i].Resources.Requests.Cpu() + newCPU := newPodSpec.Containers[i].Resources.Requests.Cpu() + if oldCPU != nil && newCPU != nil && !oldCPU.Equal(*newCPU) { + return true + } + } + + return false +} + +func (f *guaranteedQoSPodCPUResizeFeature) MaxVersion() *version.Version { + return nil +} diff --git a/staging/src/k8s.io/component-helpers/nodedeclaredfeatures/features/inplacepodresize/guaranteed_cpu_resize_test.go b/staging/src/k8s.io/component-helpers/nodedeclaredfeatures/features/inplacepodresize/guaranteed_cpu_resize_test.go new file mode 100644 index 00000000000..2cd986a8ead --- /dev/null +++ b/staging/src/k8s.io/component-helpers/nodedeclaredfeatures/features/inplacepodresize/guaranteed_cpu_resize_test.go @@ -0,0 +1,153 @@ +/* +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 inplacepodresize + +import ( + "testing" + + "github.com/stretchr/testify/assert" + v1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/component-helpers/nodedeclaredfeatures" + test "k8s.io/component-helpers/nodedeclaredfeatures/testing" +) + +func TestGuaranteedQoSPodCPUResizeFeature_Discover(t *testing.T) { + testCases := []struct { + name string + cpuManagerPolicy string + gateEnabled bool + expected bool + }{ + { + name: "feature gate enabled, static policy", + cpuManagerPolicy: CPUManagerPolicyStatic, + gateEnabled: true, + expected: true, + }, + { + name: "feature gate disabled, static policy", + cpuManagerPolicy: CPUManagerPolicyStatic, + gateEnabled: false, + expected: false, + }, + { + name: "none policy", + cpuManagerPolicy: CPUManagerPolicyNone, + gateEnabled: true, + expected: true, + }, + { + name: "feature gate missing", + cpuManagerPolicy: CPUManagerPolicyStatic, + gateEnabled: false, // Effectively disabled if not found + expected: false, + }, + } + + feature := &guaranteedQoSPodCPUResizeFeature{} + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + mockFG := test.NewMockFeatureGate(t) + mockFG.EXPECT().Enabled(IPPRExclusiveCPUsFeatureGate).Return(tc.gateEnabled) + + config := &nodedeclaredfeatures.NodeConfiguration{ + FeatureGates: mockFG, + StaticConfig: nodedeclaredfeatures.StaticConfiguration{CPUManagerPolicy: tc.cpuManagerPolicy}, + } + enabled := feature.Discover(config) + assert.Equal(t, tc.expected, enabled) + }) + } +} + +func TestGuaranteedQoSPodCPUResizeFeature_InferForScheduling(t *testing.T) { + feature := &guaranteedQoSPodCPUResizeFeature{} + assert.False(t, feature.InferForScheduling(&nodedeclaredfeatures.PodInfo{Spec: &v1.PodSpec{}, Status: &v1.PodStatus{}}), "InferForScheduling should always be false") +} + +func TestGuaranteedQoSPodCPUResizeFeature_InferFromUpdate(t *testing.T) { + basePod := func() *v1.Pod { + return &v1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "test-pod"}, + Spec: v1.PodSpec{ + Containers: []v1.Container{ + { + Name: "c1", + Resources: v1.ResourceRequirements{ + Requests: v1.ResourceList{v1.ResourceCPU: resource.MustParse("1")}, + Limits: v1.ResourceList{v1.ResourceCPU: resource.MustParse("1")}, + }, + }, + }, + }, + Status: v1.PodStatus{ + QOSClass: v1.PodQOSGuaranteed, + }, + } + } + + testCases := []struct { + name string + oldPod *v1.Pod + newPod *v1.Pod + expected bool + }{ + { + name: "not guaranteed QOS", + oldPod: func() *v1.Pod { p := basePod(); p.Status.QOSClass = v1.PodQOSBurstable; return p }(), + newPod: func() *v1.Pod { p := basePod(); p.Status.QOSClass = v1.PodQOSBurstable; return p }(), + expected: false, + }, + { + name: "no CPU request change", + oldPod: basePod(), + newPod: basePod(), + expected: false, + }, + { + name: "CPU request changed", + oldPod: basePod(), + newPod: func() *v1.Pod { + p := basePod() + p.Spec.Containers[0].Resources.Requests[v1.ResourceCPU] = resource.MustParse("2") + return p + }(), + expected: true, + }, + { + name: "CPU limit changed, but not request", + oldPod: basePod(), + newPod: func() *v1.Pod { + p := basePod() + p.Spec.Containers[0].Resources.Limits[v1.ResourceCPU] = resource.MustParse("2") + return p + }(), + expected: false, // Only request changes matter for this feature + }, + } + + feature := &guaranteedQoSPodCPUResizeFeature{} + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + oldPodInfo := &nodedeclaredfeatures.PodInfo{Spec: &tc.oldPod.Spec, Status: &tc.oldPod.Status} + newPodInfo := &nodedeclaredfeatures.PodInfo{Spec: &tc.newPod.Spec, Status: &tc.newPod.Status} + assert.Equal(t, tc.expected, feature.InferForUpdate(oldPodInfo, newPodInfo)) + }) + } +} diff --git a/staging/src/k8s.io/component-helpers/nodedeclaredfeatures/features/registry.go b/staging/src/k8s.io/component-helpers/nodedeclaredfeatures/features/registry.go index ccdd95f8b4d..eeea59fd378 100644 --- a/staging/src/k8s.io/component-helpers/nodedeclaredfeatures/features/registry.go +++ b/staging/src/k8s.io/component-helpers/nodedeclaredfeatures/features/registry.go @@ -18,9 +18,12 @@ package features import ( "k8s.io/component-helpers/nodedeclaredfeatures" + "k8s.io/component-helpers/nodedeclaredfeatures/features/inplacepodresize" ) // AllFeatures is the central registry for all declared features. // New features are added to this list to be automatically included in both // discovery and inference logic. -var AllFeatures = []nodedeclaredfeatures.Feature{} +var AllFeatures = []nodedeclaredfeatures.Feature{ + inplacepodresize.Feature, +}