Skip to content

Commit 65eef0b

Browse files
author
sarthaga
committed
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
1 parent e3503d2 commit 65eef0b

File tree

5 files changed

+214
-0
lines changed

5 files changed

+214
-0
lines changed

pkg/framework/plugins/nodeutilization/highnodeutilization.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ type HighNodeUtilization struct {
5252
resourceNames []v1.ResourceName
5353
highThresholds api.ResourceThresholds
5454
usageClient usageClient
55+
nodeSelector map[string]string
5556
}
5657

5758
// NewHighNodeUtilization builds plugin from its arguments while passing a handle.
@@ -126,6 +127,7 @@ func NewHighNodeUtilization(
126127
resourceNames,
127128
handle.GetPodsAssignedToNodeFunc(),
128129
),
130+
nodeSelector: args.NodeSelector,
129131
}, nil
130132
}
131133

@@ -140,6 +142,28 @@ func (h *HighNodeUtilization) Name() string {
140142
func (h *HighNodeUtilization) Balance(ctx context.Context, nodes []*v1.Node) *frameworktypes.Status {
141143
logger := klog.FromContext(klog.NewContext(ctx, h.logger)).WithValues("ExtensionPoint", frameworktypes.BalanceExtensionPoint)
142144

145+
// Filter nodes by nodeSelector if specified
146+
if len(h.nodeSelector) > 0 {
147+
filteredNodes := []*v1.Node{}
148+
for _, node := range nodes {
149+
if nodeMatchesSelector(node, h.nodeSelector) {
150+
filteredNodes = append(filteredNodes, node)
151+
}
152+
}
153+
154+
if len(filteredNodes) == 0 {
155+
logger.V(1).Info("No nodes match the nodeSelector, skipping")
156+
return nil
157+
}
158+
159+
logger.V(1).Info(
160+
"Filtered nodes by nodeSelector",
161+
"totalNodes", len(nodes),
162+
"matchedNodes", len(filteredNodes),
163+
)
164+
nodes = filteredNodes
165+
}
166+
143167
if err := h.usageClient.sync(ctx, nodes); err != nil {
144168
return &frameworktypes.Status{
145169
Err: fmt.Errorf("error getting node usage: %v", err),
@@ -273,3 +297,13 @@ func (h *HighNodeUtilization) Balance(ctx context.Context, nodes []*v1.Node) *fr
273297

274298
return nil
275299
}
300+
301+
// nodeMatchesSelector checks if a node matches all labels in the selector
302+
func nodeMatchesSelector(node *v1.Node, selector map[string]string) bool {
303+
for key, value := range selector {
304+
if nodeValue, exists := node.Labels[key]; !exists || nodeValue != value {
305+
return false
306+
}
307+
}
308+
return true
309+
}

pkg/framework/plugins/nodeutilization/lownodeutilization.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ type LowNodeUtilization struct {
5252
resourceNames []v1.ResourceName
5353
extendedResourceNames []v1.ResourceName
5454
usageClient usageClient
55+
nodeSelector map[string]string
5556
}
5657

5758
// NewLowNodeUtilization builds plugin from its arguments while passing a
@@ -126,6 +127,7 @@ func NewLowNodeUtilization(
126127
extendedResourceNames: extendedResourceNames,
127128
podFilter: podFilter,
128129
usageClient: usageClient,
130+
nodeSelector: args.NodeSelector,
129131
}, nil
130132
}
131133

@@ -140,6 +142,28 @@ func (l *LowNodeUtilization) Name() string {
140142
func (l *LowNodeUtilization) Balance(ctx context.Context, nodes []*v1.Node) *frameworktypes.Status {
141143
logger := klog.FromContext(klog.NewContext(ctx, l.logger)).WithValues("ExtensionPoint", frameworktypes.BalanceExtensionPoint)
142144

145+
// Filter nodes by nodeSelector if specified
146+
if len(l.nodeSelector) > 0 {
147+
filteredNodes := []*v1.Node{}
148+
for _, node := range nodes {
149+
if nodeMatchesSelector(node, l.nodeSelector) {
150+
filteredNodes = append(filteredNodes, node)
151+
}
152+
}
153+
154+
if len(filteredNodes) == 0 {
155+
logger.V(1).Info("No nodes match the nodeSelector, skipping")
156+
return nil
157+
}
158+
159+
logger.V(1).Info(
160+
"Filtered nodes by nodeSelector",
161+
"totalNodes", len(nodes),
162+
"matchedNodes", len(filteredNodes),
163+
)
164+
nodes = filteredNodes
165+
}
166+
143167
if err := l.usageClient.sync(ctx, nodes); err != nil {
144168
return &frameworktypes.Status{
145169
Err: fmt.Errorf("error getting node usage: %v", err),
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
package nodeutilization
2+
3+
import (
4+
"testing"
5+
6+
v1 "k8s.io/api/core/v1"
7+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
8+
)
9+
10+
func TestNodeMatchesSelector(t *testing.T) {
11+
tests := []struct {
12+
name string
13+
node *v1.Node
14+
selector map[string]string
15+
expected bool
16+
}{
17+
{
18+
name: "node matches single label",
19+
node: &v1.Node{
20+
ObjectMeta: metav1.ObjectMeta{
21+
Name: "test-node",
22+
Labels: map[string]string{
23+
"env": "prod",
24+
},
25+
},
26+
},
27+
selector: map[string]string{
28+
"env": "prod",
29+
},
30+
expected: true,
31+
},
32+
{
33+
name: "node matches multiple labels",
34+
node: &v1.Node{
35+
ObjectMeta: metav1.ObjectMeta{
36+
Name: "test-node",
37+
Labels: map[string]string{
38+
"env": "prod",
39+
"zone": "us-west",
40+
"type": "worker",
41+
},
42+
},
43+
},
44+
selector: map[string]string{
45+
"env": "prod",
46+
"zone": "us-west",
47+
},
48+
expected: true,
49+
},
50+
{
51+
name: "node does not match label value",
52+
node: &v1.Node{
53+
ObjectMeta: metav1.ObjectMeta{
54+
Name: "test-node",
55+
Labels: map[string]string{
56+
"env": "dev",
57+
},
58+
},
59+
},
60+
selector: map[string]string{
61+
"env": "prod",
62+
},
63+
expected: false,
64+
},
65+
{
66+
name: "node missing required label",
67+
node: &v1.Node{
68+
ObjectMeta: metav1.ObjectMeta{
69+
Name: "test-node",
70+
Labels: map[string]string{
71+
"zone": "us-west",
72+
},
73+
},
74+
},
75+
selector: map[string]string{
76+
"env": "prod",
77+
},
78+
expected: false,
79+
},
80+
{
81+
name: "empty selector matches all nodes",
82+
node: &v1.Node{
83+
ObjectMeta: metav1.ObjectMeta{
84+
Name: "test-node",
85+
Labels: map[string]string{
86+
"env": "prod",
87+
},
88+
},
89+
},
90+
selector: map[string]string{},
91+
expected: true,
92+
},
93+
{
94+
name: "node with no labels and non-empty selector",
95+
node: &v1.Node{
96+
ObjectMeta: metav1.ObjectMeta{
97+
Name: "test-node",
98+
Labels: map[string]string{},
99+
},
100+
},
101+
selector: map[string]string{
102+
"env": "prod",
103+
},
104+
expected: false,
105+
},
106+
{
107+
name: "cluster-autoscaler scale-down disabled label",
108+
node: &v1.Node{
109+
ObjectMeta: metav1.ObjectMeta{
110+
Name: "test-node",
111+
Labels: map[string]string{
112+
"cluster-autoscaler.kubernetes.io/scale-down-disabled": "true",
113+
"node.kubernetes.io/instance-type": "m5.large",
114+
},
115+
},
116+
},
117+
selector: map[string]string{
118+
"cluster-autoscaler.kubernetes.io/scale-down-disabled": "true",
119+
},
120+
expected: true,
121+
},
122+
}
123+
124+
for _, tt := range tests {
125+
t.Run(tt.name, func(t *testing.T) {
126+
result := nodeMatchesSelector(tt.node, tt.selector)
127+
if result != tt.expected {
128+
t.Errorf("nodeMatchesSelector() = %v, want %v", result, tt.expected)
129+
}
130+
})
131+
}
132+
}

pkg/framework/plugins/nodeutilization/types.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,11 @@ type LowNodeUtilizationArgs struct {
4949

5050
// evictionLimits limits the number of evictions per domain. E.g. node, namespace, total.
5151
EvictionLimits *api.EvictionLimits `json:"evictionLimits,omitempty"`
52+
53+
// NodeSelector filters which nodes are considered for eviction.
54+
// Only nodes matching all specified labels will be processed by this plugin.
55+
// If not set, all nodes are considered.
56+
NodeSelector map[string]string `json:"nodeSelector,omitempty"`
5257
}
5358

5459
// +k8s:deepcopy-gen=true
@@ -71,6 +76,11 @@ type HighNodeUtilizationArgs struct {
7176
// considered while considering resources used by pods
7277
// but then filtered out before eviction
7378
EvictableNamespaces *api.Namespaces `json:"evictableNamespaces,omitempty"`
79+
80+
// NodeSelector filters which nodes are considered for eviction.
81+
// Only nodes matching all specified labels will be processed by this plugin.
82+
// If not set, all nodes are considered.
83+
NodeSelector map[string]string `json:"nodeSelector,omitempty"`
7484
}
7585

7686
// MetricsUtilization allow to consume actual resource utilization from metrics

pkg/framework/plugins/nodeutilization/zz_generated.deepcopy.go

Lines changed: 14 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)