feat(analyze): add countDistinct() aggregate to nodeResources (#2079)

Add a countDistinct(<labelKey>) 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 <noreply@anthropic.com>
Co-authored-by: Xav Paice <xavpaice@users.noreply.github.com>
This commit is contained in:
Lennard Eijsackers
2026-08-10 12:54:33 +12:00
committed by GitHub
co-authored by Claude Opus 4.8 Xav Paice
parent 3c5bf74750
commit 3db6a99d3a
2 changed files with 125 additions and 0 deletions
+15
View File
@@ -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{}
+110
View File
@@ -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) {