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..2ed10dcca0 --- /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) + } + }) + } +} 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 }