From 3db6a99d3a359b732fb794a5a4c3b39324f6a192 Mon Sep 17 00:00:00 2001 From: Lennard Eijsackers Date: Mon, 10 Aug 2026 02:54:33 +0200 Subject: [PATCH] feat(analyze): add countDistinct() aggregate to nodeResources (#2079) Add a countDistinct() aggregate to the nodeResources analyzer "when" expression language. It counts distinct values of a node label across the filtered nodes and returns an int the existing comparison operators evaluate. Enables the AIR-238 3-AZ preflight: warn when Keeper-eligible nodes do not span 3 availability zones, e.g. "countDistinct(topology.kubernetes.io/zone) < 3". Co-authored-by: Claude Opus 4.8 Co-authored-by: Xav Paice --- pkg/analyze/node_resources.go | 15 ++++ pkg/analyze/node_resources_test.go | 110 +++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+) diff --git a/pkg/analyze/node_resources.go b/pkg/analyze/node_resources.go index e549d2df..4e0ee0b3 100644 --- a/pkg/analyze/node_resources.go +++ b/pkg/analyze/node_resources.go @@ -219,6 +219,8 @@ func compareNodeResourceConditionalToActual(conditional string, matchingNodes [] switch function { case "count": actualValue = len(matchingNodes) + case "countDistinct": + actualValue = countDistinctLabelValues(matchingNodes, property) case "min": actualValue = findMin(matchingNodes, property, resourceName) case "max": @@ -368,6 +370,19 @@ func getQuantity(node corev1.Node, property string, resourceName string) *resour return nil } +// countDistinctLabelValues returns the number of distinct values of labelKey +// across the given nodes. Nodes missing the label are ignored, so an absent +// label yields 0. +func countDistinctLabelValues(nodes []corev1.Node, labelKey string) int { + seen := map[string]struct{}{} + for _, node := range nodes { + if v, ok := node.Labels[labelKey]; ok { + seen[v] = struct{}{} + } + } + return len(seen) +} + func findSum(nodes []corev1.Node, property string, resourceName string) *resource.Quantity { sum := resource.Quantity{} diff --git a/pkg/analyze/node_resources_test.go b/pkg/analyze/node_resources_test.go index cae6c892..4b6a36fc 100644 --- a/pkg/analyze/node_resources_test.go +++ b/pkg/analyze/node_resources_test.go @@ -1666,6 +1666,116 @@ func Test_analyzeNodeResources(t *testing.T) { IconURI: "https://troubleshoot.sh/images/analyzer-icons/node-resources.svg?w=16&h=18", }, }, + { + name: "countDistinct spans at least 3 instance types", // countDistinct pass path across all nodes + analyzer: &troubleshootv1beta2.NodeResources{ + AnalyzeMeta: troubleshootv1beta2.AnalyzeMeta{ + CheckName: "instance-type spread", + }, + Outcomes: []*troubleshootv1beta2.Outcome{ + { + Warn: &troubleshootv1beta2.SingleOutcome{ + When: "countDistinct(node.kubernetes.io/instance-type) < 3", + Message: "Fewer than 3 distinct instance types.", + URI: "", + }, + }, + { + Pass: &troubleshootv1beta2.SingleOutcome{ + Message: "At least 3 distinct instance types.", + URI: "", + }, + }, + }, + }, + want: &AnalyzeResult{ + IsPass: true, + IsFail: false, + IsWarn: false, + Title: "instance-type spread", + Message: "At least 3 distinct instance types.", + URI: "", + IconKey: "kubernetes_node_resources", + IconURI: "https://troubleshoot.sh/images/analyzer-icons/node-resources.svg?w=16&h=18", + }, + }, + { + name: "countDistinct only counts filtered nodes", // filtering to one pool leaves a single distinct value + analyzer: &troubleshootv1beta2.NodeResources{ + AnalyzeMeta: troubleshootv1beta2.AnalyzeMeta{ + CheckName: "filtered instance-type spread", + }, + Outcomes: []*troubleshootv1beta2.Outcome{ + { + Warn: &troubleshootv1beta2.SingleOutcome{ + When: "countDistinct(node.kubernetes.io/instance-type) < 3", + Message: "Fewer than 3 distinct instance types.", + URI: "", + }, + }, + { + Pass: &troubleshootv1beta2.SingleOutcome{ + Message: "At least 3 distinct instance types.", + URI: "", + }, + }, + }, + Filters: &troubleshootv1beta2.NodeResourceFilters{ + Selector: &troubleshootv1beta2.NodeResourceSelectors{ + MatchExpressions: []metav1.LabelSelectorRequirement{ + { + Key: "node.kubernetes.io/instance-type", + Operator: metav1.LabelSelectorOpIn, + Values: []string{"s-2vcpu-4gb"}, + }, + }, + }, + }, + }, + want: &AnalyzeResult{ + IsPass: false, + IsFail: false, + IsWarn: true, + Title: "filtered instance-type spread", + Message: "Fewer than 3 distinct instance types.", + URI: "", + IconKey: "kubernetes_node_resources", + IconURI: "https://troubleshoot.sh/images/analyzer-icons/node-resources.svg?w=16&h=18", + }, + }, + { + name: "countDistinct is 0 when the label is absent", // AIR-238 zone syntax; fixture nodes carry no zone label + analyzer: &troubleshootv1beta2.NodeResources{ + AnalyzeMeta: troubleshootv1beta2.AnalyzeMeta{ + CheckName: "zone spread", + }, + Outcomes: []*troubleshootv1beta2.Outcome{ + { + Warn: &troubleshootv1beta2.SingleOutcome{ + When: "countDistinct(topology.kubernetes.io/zone) < 3", + Message: "Nodes span fewer than 3 availability zones.", + URI: "", + }, + }, + { + Pass: &troubleshootv1beta2.SingleOutcome{ + Message: "Nodes span at least 3 availability zones.", + URI: "", + }, + }, + }, + }, + want: &AnalyzeResult{ + IsPass: false, + IsFail: false, + IsWarn: true, + Title: "zone spread", + Message: "Nodes span fewer than 3 availability zones.", + URI: "", + IconKey: "kubernetes_node_resources", + IconURI: "https://troubleshoot.sh/images/analyzer-icons/node-resources.svg?w=16&h=18", + }, + }, } getExampleNodeContents := func(nodeName string) ([]byte, error) {