Skip to content

Commit 3db6a99

Browse files
Blokje5claudexavpaice
authored
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>
1 parent 3c5bf74 commit 3db6a99

2 files changed

Lines changed: 125 additions & 0 deletions

File tree

pkg/analyze/node_resources.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,8 @@ func compareNodeResourceConditionalToActual(conditional string, matchingNodes []
219219
switch function {
220220
case "count":
221221
actualValue = len(matchingNodes)
222+
case "countDistinct":
223+
actualValue = countDistinctLabelValues(matchingNodes, property)
222224
case "min":
223225
actualValue = findMin(matchingNodes, property, resourceName)
224226
case "max":
@@ -368,6 +370,19 @@ func getQuantity(node corev1.Node, property string, resourceName string) *resour
368370
return nil
369371
}
370372

373+
// countDistinctLabelValues returns the number of distinct values of labelKey
374+
// across the given nodes. Nodes missing the label are ignored, so an absent
375+
// label yields 0.
376+
func countDistinctLabelValues(nodes []corev1.Node, labelKey string) int {
377+
seen := map[string]struct{}{}
378+
for _, node := range nodes {
379+
if v, ok := node.Labels[labelKey]; ok {
380+
seen[v] = struct{}{}
381+
}
382+
}
383+
return len(seen)
384+
}
385+
371386
func findSum(nodes []corev1.Node, property string, resourceName string) *resource.Quantity {
372387
sum := resource.Quantity{}
373388

pkg/analyze/node_resources_test.go

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1666,6 +1666,116 @@ func Test_analyzeNodeResources(t *testing.T) {
16661666
IconURI: "https://troubleshoot.sh/images/analyzer-icons/node-resources.svg?w=16&h=18",
16671667
},
16681668
},
1669+
{
1670+
name: "countDistinct spans at least 3 instance types", // countDistinct pass path across all nodes
1671+
analyzer: &troubleshootv1beta2.NodeResources{
1672+
AnalyzeMeta: troubleshootv1beta2.AnalyzeMeta{
1673+
CheckName: "instance-type spread",
1674+
},
1675+
Outcomes: []*troubleshootv1beta2.Outcome{
1676+
{
1677+
Warn: &troubleshootv1beta2.SingleOutcome{
1678+
When: "countDistinct(node.kubernetes.io/instance-type) < 3",
1679+
Message: "Fewer than 3 distinct instance types.",
1680+
URI: "",
1681+
},
1682+
},
1683+
{
1684+
Pass: &troubleshootv1beta2.SingleOutcome{
1685+
Message: "At least 3 distinct instance types.",
1686+
URI: "",
1687+
},
1688+
},
1689+
},
1690+
},
1691+
want: &AnalyzeResult{
1692+
IsPass: true,
1693+
IsFail: false,
1694+
IsWarn: false,
1695+
Title: "instance-type spread",
1696+
Message: "At least 3 distinct instance types.",
1697+
URI: "",
1698+
IconKey: "kubernetes_node_resources",
1699+
IconURI: "https://troubleshoot.sh/images/analyzer-icons/node-resources.svg?w=16&h=18",
1700+
},
1701+
},
1702+
{
1703+
name: "countDistinct only counts filtered nodes", // filtering to one pool leaves a single distinct value
1704+
analyzer: &troubleshootv1beta2.NodeResources{
1705+
AnalyzeMeta: troubleshootv1beta2.AnalyzeMeta{
1706+
CheckName: "filtered instance-type spread",
1707+
},
1708+
Outcomes: []*troubleshootv1beta2.Outcome{
1709+
{
1710+
Warn: &troubleshootv1beta2.SingleOutcome{
1711+
When: "countDistinct(node.kubernetes.io/instance-type) < 3",
1712+
Message: "Fewer than 3 distinct instance types.",
1713+
URI: "",
1714+
},
1715+
},
1716+
{
1717+
Pass: &troubleshootv1beta2.SingleOutcome{
1718+
Message: "At least 3 distinct instance types.",
1719+
URI: "",
1720+
},
1721+
},
1722+
},
1723+
Filters: &troubleshootv1beta2.NodeResourceFilters{
1724+
Selector: &troubleshootv1beta2.NodeResourceSelectors{
1725+
MatchExpressions: []metav1.LabelSelectorRequirement{
1726+
{
1727+
Key: "node.kubernetes.io/instance-type",
1728+
Operator: metav1.LabelSelectorOpIn,
1729+
Values: []string{"s-2vcpu-4gb"},
1730+
},
1731+
},
1732+
},
1733+
},
1734+
},
1735+
want: &AnalyzeResult{
1736+
IsPass: false,
1737+
IsFail: false,
1738+
IsWarn: true,
1739+
Title: "filtered instance-type spread",
1740+
Message: "Fewer than 3 distinct instance types.",
1741+
URI: "",
1742+
IconKey: "kubernetes_node_resources",
1743+
IconURI: "https://troubleshoot.sh/images/analyzer-icons/node-resources.svg?w=16&h=18",
1744+
},
1745+
},
1746+
{
1747+
name: "countDistinct is 0 when the label is absent", // AIR-238 zone syntax; fixture nodes carry no zone label
1748+
analyzer: &troubleshootv1beta2.NodeResources{
1749+
AnalyzeMeta: troubleshootv1beta2.AnalyzeMeta{
1750+
CheckName: "zone spread",
1751+
},
1752+
Outcomes: []*troubleshootv1beta2.Outcome{
1753+
{
1754+
Warn: &troubleshootv1beta2.SingleOutcome{
1755+
When: "countDistinct(topology.kubernetes.io/zone) < 3",
1756+
Message: "Nodes span fewer than 3 availability zones.",
1757+
URI: "",
1758+
},
1759+
},
1760+
{
1761+
Pass: &troubleshootv1beta2.SingleOutcome{
1762+
Message: "Nodes span at least 3 availability zones.",
1763+
URI: "",
1764+
},
1765+
},
1766+
},
1767+
},
1768+
want: &AnalyzeResult{
1769+
IsPass: false,
1770+
IsFail: false,
1771+
IsWarn: true,
1772+
Title: "zone spread",
1773+
Message: "Nodes span fewer than 3 availability zones.",
1774+
URI: "",
1775+
IconKey: "kubernetes_node_resources",
1776+
IconURI: "https://troubleshoot.sh/images/analyzer-icons/node-resources.svg?w=16&h=18",
1777+
},
1778+
},
16691779
}
16701780

16711781
getExampleNodeContents := func(nodeName string) ([]byte, error) {

0 commit comments

Comments
 (0)