From 5fb59119ce2d7149a66b1f4d0e9e9f36533368ba Mon Sep 17 00:00:00 2001 From: sarcode Date: Mon, 27 Oct 2025 14:39:11 +0100 Subject: [PATCH 1/3] feat: Add per-plugin nodeSelector support Adds nodeSelector field to HighNodeUtilizationArgs and LowNodeUtilizationArgs to allow filtering nodes per plugin. This enables different plugins in the same profile to target different sets of nodes based on labels, solving the limitation where only global nodeSelector was available. Use case: Run HighNodeUtilization only on nodes blocked by cluster-autoscaler while other plugins run on all nodes. Changes: - Add NodeSelector field to both plugin Args structs - Implement node filtering logic in Balance() methods - Add nodeMatchesSelector helper function - Generate deepcopy code for new fields - Add comprehensive tests for nodeSelector logic Addresses: #1486 --- .../nodeutilization/highnodeutilization.go | 34 +++++ .../nodeutilization/lownodeutilization.go | 24 ++++ .../nodeutilization/nodeselector_test.go | 132 ++++++++++++++++++ .../plugins/nodeutilization/types.go | 10 ++ .../nodeutilization/zz_generated.deepcopy.go | 14 ++ 5 files changed, 214 insertions(+) create mode 100644 pkg/framework/plugins/nodeutilization/nodeselector_test.go diff --git a/pkg/framework/plugins/nodeutilization/highnodeutilization.go b/pkg/framework/plugins/nodeutilization/highnodeutilization.go index 73633a52da..cff90739be 100644 --- a/pkg/framework/plugins/nodeutilization/highnodeutilization.go +++ b/pkg/framework/plugins/nodeutilization/highnodeutilization.go @@ -52,6 +52,7 @@ type HighNodeUtilization struct { resourceNames []v1.ResourceName highThresholds api.ResourceThresholds usageClient usageClient + nodeSelector map[string]string } // NewHighNodeUtilization builds plugin from its arguments while passing a handle. @@ -126,6 +127,7 @@ func NewHighNodeUtilization( resourceNames, handle.GetPodsAssignedToNodeFunc(), ), + nodeSelector: args.NodeSelector, }, nil } @@ -140,6 +142,28 @@ func (h *HighNodeUtilization) Name() string { func (h *HighNodeUtilization) Balance(ctx context.Context, nodes []*v1.Node) *frameworktypes.Status { logger := klog.FromContext(klog.NewContext(ctx, h.logger)).WithValues("ExtensionPoint", frameworktypes.BalanceExtensionPoint) + // Filter nodes by nodeSelector if specified + if len(h.nodeSelector) > 0 { + filteredNodes := []*v1.Node{} + for _, node := range nodes { + if nodeMatchesSelector(node, h.nodeSelector) { + filteredNodes = append(filteredNodes, node) + } + } + + if len(filteredNodes) == 0 { + logger.V(1).Info("No nodes match the nodeSelector, skipping") + return nil + } + + logger.V(1).Info( + "Filtered nodes by nodeSelector", + "totalNodes", len(nodes), + "matchedNodes", len(filteredNodes), + ) + nodes = filteredNodes + } + if err := h.usageClient.sync(ctx, nodes); err != nil { return &frameworktypes.Status{ Err: fmt.Errorf("error getting node usage: %v", err), @@ -273,3 +297,13 @@ func (h *HighNodeUtilization) Balance(ctx context.Context, nodes []*v1.Node) *fr return nil } + +// nodeMatchesSelector checks if a node matches all labels in the selector +func nodeMatchesSelector(node *v1.Node, selector map[string]string) bool { + for key, value := range selector { + if nodeValue, exists := node.Labels[key]; !exists || nodeValue != value { + return false + } + } + return true +} diff --git a/pkg/framework/plugins/nodeutilization/lownodeutilization.go b/pkg/framework/plugins/nodeutilization/lownodeutilization.go index 5748e73778..319be52678 100644 --- a/pkg/framework/plugins/nodeutilization/lownodeutilization.go +++ b/pkg/framework/plugins/nodeutilization/lownodeutilization.go @@ -52,6 +52,7 @@ type LowNodeUtilization struct { resourceNames []v1.ResourceName extendedResourceNames []v1.ResourceName usageClient usageClient + nodeSelector map[string]string } // NewLowNodeUtilization builds plugin from its arguments while passing a @@ -126,6 +127,7 @@ func NewLowNodeUtilization( extendedResourceNames: extendedResourceNames, podFilter: podFilter, usageClient: usageClient, + nodeSelector: args.NodeSelector, }, nil } @@ -140,6 +142,28 @@ func (l *LowNodeUtilization) Name() string { func (l *LowNodeUtilization) Balance(ctx context.Context, nodes []*v1.Node) *frameworktypes.Status { logger := klog.FromContext(klog.NewContext(ctx, l.logger)).WithValues("ExtensionPoint", frameworktypes.BalanceExtensionPoint) + // Filter nodes by nodeSelector if specified + if len(l.nodeSelector) > 0 { + filteredNodes := []*v1.Node{} + for _, node := range nodes { + if nodeMatchesSelector(node, l.nodeSelector) { + filteredNodes = append(filteredNodes, node) + } + } + + if len(filteredNodes) == 0 { + logger.V(1).Info("No nodes match the nodeSelector, skipping") + return nil + } + + logger.V(1).Info( + "Filtered nodes by nodeSelector", + "totalNodes", len(nodes), + "matchedNodes", len(filteredNodes), + ) + nodes = filteredNodes + } + if err := l.usageClient.sync(ctx, nodes); err != nil { return &frameworktypes.Status{ Err: fmt.Errorf("error getting node usage: %v", err), diff --git a/pkg/framework/plugins/nodeutilization/nodeselector_test.go b/pkg/framework/plugins/nodeutilization/nodeselector_test.go new file mode 100644 index 0000000000..7dbb99b9fb --- /dev/null +++ b/pkg/framework/plugins/nodeutilization/nodeselector_test.go @@ -0,0 +1,132 @@ +package nodeutilization + +import ( + "testing" + + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestNodeMatchesSelector(t *testing.T) { + tests := []struct { + name string + node *v1.Node + selector map[string]string + expected bool + }{ + { + name: "node matches single label", + node: &v1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-node", + Labels: map[string]string{ + "env": "prod", + }, + }, + }, + selector: map[string]string{ + "env": "prod", + }, + expected: true, + }, + { + name: "node matches multiple labels", + node: &v1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-node", + Labels: map[string]string{ + "env": "prod", + "zone": "us-west", + "type": "worker", + }, + }, + }, + selector: map[string]string{ + "env": "prod", + "zone": "us-west", + }, + expected: true, + }, + { + name: "node does not match label value", + node: &v1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-node", + Labels: map[string]string{ + "env": "dev", + }, + }, + }, + selector: map[string]string{ + "env": "prod", + }, + expected: false, + }, + { + name: "node missing required label", + node: &v1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-node", + Labels: map[string]string{ + "zone": "us-west", + }, + }, + }, + selector: map[string]string{ + "env": "prod", + }, + expected: false, + }, + { + name: "empty selector matches all nodes", + node: &v1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-node", + Labels: map[string]string{ + "env": "prod", + }, + }, + }, + selector: map[string]string{}, + expected: true, + }, + { + name: "node with no labels and non-empty selector", + node: &v1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-node", + Labels: map[string]string{}, + }, + }, + selector: map[string]string{ + "env": "prod", + }, + expected: false, + }, + { + name: "cluster-autoscaler scale-down disabled label", + node: &v1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-node", + Labels: map[string]string{ + "cluster-autoscaler.kubernetes.io/scale-down-disabled": "true", + "node.kubernetes.io/instance-type": "m5.large", + }, + }, + }, + selector: map[string]string{ + "cluster-autoscaler.kubernetes.io/scale-down-disabled": "true", + }, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := nodeMatchesSelector(tt.node, tt.selector) + if result != tt.expected { + t.Errorf("nodeMatchesSelector() = %v, want %v", result, tt.expected) + } + }) + } +} \ No newline at end of file diff --git a/pkg/framework/plugins/nodeutilization/types.go b/pkg/framework/plugins/nodeutilization/types.go index 5f6e947998..2ed4ac7529 100644 --- a/pkg/framework/plugins/nodeutilization/types.go +++ b/pkg/framework/plugins/nodeutilization/types.go @@ -49,6 +49,11 @@ type LowNodeUtilizationArgs struct { // evictionLimits limits the number of evictions per domain. E.g. node, namespace, total. EvictionLimits *api.EvictionLimits `json:"evictionLimits,omitempty"` + + // NodeSelector filters which nodes are considered for eviction. + // Only nodes matching all specified labels will be processed by this plugin. + // If not set, all nodes are considered. + NodeSelector map[string]string `json:"nodeSelector,omitempty"` } // +k8s:deepcopy-gen=true @@ -71,6 +76,11 @@ type HighNodeUtilizationArgs struct { // considered while considering resources used by pods // but then filtered out before eviction EvictableNamespaces *api.Namespaces `json:"evictableNamespaces,omitempty"` + + // NodeSelector filters which nodes are considered for eviction. + // Only nodes matching all specified labels will be processed by this plugin. + // If not set, all nodes are considered. + NodeSelector map[string]string `json:"nodeSelector,omitempty"` } // MetricsUtilization allow to consume actual resource utilization from metrics diff --git a/pkg/framework/plugins/nodeutilization/zz_generated.deepcopy.go b/pkg/framework/plugins/nodeutilization/zz_generated.deepcopy.go index 7f84492b98..2a31f1eb4f 100644 --- a/pkg/framework/plugins/nodeutilization/zz_generated.deepcopy.go +++ b/pkg/framework/plugins/nodeutilization/zz_generated.deepcopy.go @@ -47,6 +47,13 @@ func (in *HighNodeUtilizationArgs) DeepCopyInto(out *HighNodeUtilizationArgs) { *out = new(api.Namespaces) (*in).DeepCopyInto(*out) } + if in.NodeSelector != nil { + in, out := &in.NodeSelector, &out.NodeSelector + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } return } @@ -101,6 +108,13 @@ func (in *LowNodeUtilizationArgs) DeepCopyInto(out *LowNodeUtilizationArgs) { *out = new(api.EvictionLimits) (*in).DeepCopyInto(*out) } + if in.NodeSelector != nil { + in, out := &in.NodeSelector, &out.NodeSelector + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } return } From fc9d6ed919c3483cc877c6ba0cb16339f1dfac85 Mon Sep 17 00:00:00 2001 From: sarcode Date: Wed, 29 Oct 2025 10:22:54 +0100 Subject: [PATCH 2/3] fix: Update test file formatting for CI compatibility --- pkg/framework/plugins/nodeutilization/nodeselector_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/framework/plugins/nodeutilization/nodeselector_test.go b/pkg/framework/plugins/nodeutilization/nodeselector_test.go index 7dbb99b9fb..3785ea3a20 100644 --- a/pkg/framework/plugins/nodeutilization/nodeselector_test.go +++ b/pkg/framework/plugins/nodeutilization/nodeselector_test.go @@ -110,7 +110,7 @@ func TestNodeMatchesSelector(t *testing.T) { Name: "test-node", Labels: map[string]string{ "cluster-autoscaler.kubernetes.io/scale-down-disabled": "true", - "node.kubernetes.io/instance-type": "m5.large", + "node.kubernetes.io/instance-type": "m5.large", }, }, }, From 3a24013acb21055a3dc09f2d168cab8411400078 Mon Sep 17 00:00:00 2001 From: sarcode Date: Wed, 29 Oct 2025 10:40:27 +0100 Subject: [PATCH 3/3] fix formatting --- pkg/framework/plugins/nodeutilization/nodeselector_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/framework/plugins/nodeutilization/nodeselector_test.go b/pkg/framework/plugins/nodeutilization/nodeselector_test.go index 3785ea3a20..2ed10dcca0 100644 --- a/pkg/framework/plugins/nodeutilization/nodeselector_test.go +++ b/pkg/framework/plugins/nodeutilization/nodeselector_test.go @@ -129,4 +129,4 @@ func TestNodeMatchesSelector(t *testing.T) { } }) } -} \ No newline at end of file +}