From 20f4ba9dc35652d67a8a7ccaa2aec7b04387ebd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Wo=C5=BAniak?= Date: Thu, 9 Jul 2026 14:03:13 +0200 Subject: [PATCH 1/2] fix(no-anti-affinity): support topology spread constraints --- docs/generated/checks.md | 4 +- pkg/builtinchecks/yamls/no-anti-affinity.yaml | 9 +- pkg/templates/antiaffinity/template.go | 18 ++++ pkg/templates/antiaffinity/template_test.go | 83 +++++++++++++++++++ 4 files changed, 109 insertions(+), 5 deletions(-) diff --git a/docs/generated/checks.md b/docs/generated/checks.md index b95c3b5a2..62f6d7142 100644 --- a/docs/generated/checks.md +++ b/docs/generated/checks.md @@ -357,9 +357,9 @@ minReplicas: 3 **Enabled by default**: Yes -**Description**: Indicates when deployments with multiple replicas fail to specify inter-pod anti-affinity, to ensure that the orchestrator attempts to schedule replicas on different nodes. +**Description**: Indicates when deployments with multiple replicas fail to specify inter-pod anti-affinity or topology spread constraints, to ensure that the orchestrator attempts to schedule replicas on different nodes. -**Remediation**: Specify anti-affinity in your pod specification to ensure that the orchestrator attempts to schedule replicas on different nodes. Using podAntiAffinity, specify a labelSelector that matches pods for the deployment, and set the topologyKey to kubernetes.io/hostname. Refer to https://kubernetes.io/docs/concepts/scheduling-eviction/assign-pod-node/#inter-pod-affinity-and-anti-affinity for details. +**Remediation**: Specify anti-affinity or topology spread constraints in your pod specification to ensure that the orchestrator attempts to schedule replicas on different nodes. Using podAntiAffinity, specify a labelSelector that matches pods for the deployment, and set the topologyKey to kubernetes.io/hostname. Using topologySpreadConstraints, specify a labelSelector that matches pods for the deployment, and set the topologyKey to kubernetes.io/hostname. Refer to https://kubernetes.io/docs/concepts/scheduling-eviction/assign-pod-node/#inter-pod-affinity-and-anti-affinity and https://kubernetes.io/docs/concepts/scheduling-eviction/topology-spread-constraints/ for details. **Template**: [anti-affinity](templates.md#anti-affinity-not-specified) diff --git a/pkg/builtinchecks/yamls/no-anti-affinity.yaml b/pkg/builtinchecks/yamls/no-anti-affinity.yaml index 2ed78fd9c..f7216a3c4 100644 --- a/pkg/builtinchecks/yamls/no-anti-affinity.yaml +++ b/pkg/builtinchecks/yamls/no-anti-affinity.yaml @@ -1,10 +1,13 @@ name: "no-anti-affinity" -description: "Indicates when deployments with multiple replicas fail to specify inter-pod anti-affinity, to ensure that the orchestrator attempts to schedule replicas on different nodes." +description: "Indicates when deployments with multiple replicas fail to specify inter-pod anti-affinity or topology spread constraints, to ensure that the orchestrator attempts to schedule replicas on different nodes." remediation: >- - Specify anti-affinity in your pod specification to ensure that the orchestrator attempts to schedule replicas on different nodes. + Specify anti-affinity or topology spread constraints in your pod specification to ensure that the orchestrator attempts to schedule replicas on different nodes. Using podAntiAffinity, specify a labelSelector that matches pods for the deployment, and set the topologyKey to kubernetes.io/hostname. - Refer to https://kubernetes.io/docs/concepts/scheduling-eviction/assign-pod-node/#inter-pod-affinity-and-anti-affinity for details. + Using topologySpreadConstraints, specify a labelSelector that matches pods for the deployment, + and set the topologyKey to kubernetes.io/hostname. + Refer to https://kubernetes.io/docs/concepts/scheduling-eviction/assign-pod-node/#inter-pod-affinity-and-anti-affinity + and https://kubernetes.io/docs/concepts/scheduling-eviction/topology-spread-constraints/ for details. scope: objectKinds: - DeploymentLike diff --git a/pkg/templates/antiaffinity/template.go b/pkg/templates/antiaffinity/template.go index 0b9508846..b2a5254a8 100644 --- a/pkg/templates/antiaffinity/template.go +++ b/pkg/templates/antiaffinity/template.go @@ -62,6 +62,10 @@ func init() { return nil } namespace := object.K8sObject.GetNamespace() + if topologySpreadConstraintsMatchAgainstNodes(podTemplateSpec.Spec.TopologySpreadConstraints, + podTemplateSpec.Labels, topologyKeyMatcher) { + return nil + } affinity := podTemplateSpec.Spec.Affinity // Short-circuit if no affinity rule is specified within the pod spec. if affinity == nil || affinity.PodAntiAffinity == nil { @@ -108,6 +112,20 @@ func init() { }) } +func topologySpreadConstraintsMatchAgainstNodes(topologySpreadConstraints []coreV1.TopologySpreadConstraint, + podLabels map[string]string, topologyKeyMatcher func(string) bool) bool { + for _, constraint := range topologySpreadConstraints { + labelSelector, err := metaV1.LabelSelectorAsSelector(constraint.LabelSelector) + if err != nil { + continue + } + if topologyKeyMatcher(constraint.TopologyKey) && labelSelector.Matches(labels.Set(podLabels)) { + return true + } + } + return false +} + func validateAffinityTermMatchesAgainstNodes(affinityTerm coreV1.PodAffinityTerm, podNamespace string, podLabels map[string]string, topologyKeyMatcher func(string) bool) error { // If namespaces is not specified in the affinity term, that means the affinity term implicitly applies to diff --git a/pkg/templates/antiaffinity/template_test.go b/pkg/templates/antiaffinity/template_test.go index 549decc6c..ce3c2dc48 100644 --- a/pkg/templates/antiaffinity/template_test.go +++ b/pkg/templates/antiaffinity/template_test.go @@ -132,6 +132,24 @@ func (s *AntiAffinityTestSuite) addDeploymentWithAntiAffinity(name string, repli }) } +func (s *AntiAffinityTestSuite) addDeploymentWithTopologySpreadConstraint(name string, replicas int32, topologyKey string, + labelName string) { + s.addDeploymentWithReplicas(name, replicas) + s.ctx.ModifyDeployment(s.T(), name, func(deployment *appsV1.Deployment) { + deployment.Spec.Template.Labels = map[string]string{"app": name} + deployment.Spec.Template.Spec.TopologySpreadConstraints = []v1.TopologySpreadConstraint{ + { + MaxSkew: 1, + TopologyKey: topologyKey, + WhenUnsatisfiable: v1.DoNotSchedule, + LabelSelector: &metaV1.LabelSelector{ + MatchLabels: map[string]string{"app": labelName}, + }, + }, + } + }) +} + func (s *AntiAffinityTestSuite) addDeploymentWithEmptyAntiAffinity(name string, replicas int32) { s.addDeploymentWithReplicas(name, replicas) s.ctx.ModifyDeployment(s.T(), name, func(deployment *appsV1.Deployment) { @@ -223,3 +241,68 @@ func (s *AntiAffinityTestSuite) TestWithAntiAffinity() { }, }) } + +func (s *AntiAffinityTestSuite) TestWithTopologySpreadConstraints() { + const ( + kubernetesIOHostnameDepName = "topology-spread-kubernetes-io-hostname" + otherValidKeyDepName = "topology-spread-other-valid-key" + weirdKeyDepName = "topology-spread-weird-key" + nonMatchingLabelSelectors = "topology-spread-non-matching-label-selector" + ) + s.addDeploymentWithTopologySpreadConstraint(kubernetesIOHostnameDepName, 2, "kubernetes.io/hostname", + kubernetesIOHostnameDepName) + s.addDeploymentWithTopologySpreadConstraint(otherValidKeyDepName, 3, "other.valid/key", otherValidKeyDepName) + s.addDeploymentWithTopologySpreadConstraint(weirdKeyDepName, 4, "weird/key", weirdKeyDepName) + s.addDeploymentWithTopologySpreadConstraint(nonMatchingLabelSelectors, 4, "kubernetes.io/hostname", + "non-matching") + + s.Validate(s.ctx, []templates.TestCase{ + { + Param: params.Params{ + MinReplicas: 2, + }, + Diagnostics: map[string][]diagnostic.Diagnostic{ + otherValidKeyDepName: { + {Message: "object has 3 replicas but does not specify inter pod anti-affinity"}, + }, + weirdKeyDepName: { + {Message: "object has 4 replicas but does not specify inter pod anti-affinity"}, + }, + nonMatchingLabelSelectors: { + {Message: "object has 4 replicas but does not specify inter pod anti-affinity"}, + }, + }, + ExpectInstantiationError: false, + }, + { + Param: params.Params{ + MinReplicas: 2, + TopologyKey: "other.valid/key", + }, + Diagnostics: map[string][]diagnostic.Diagnostic{ + kubernetesIOHostnameDepName: { + {Message: "object has 2 replicas but does not specify inter pod anti-affinity"}, + }, + weirdKeyDepName: { + {Message: "object has 4 replicas but does not specify inter pod anti-affinity"}, + }, + nonMatchingLabelSelectors: { + {Message: "object has 4 replicas but does not specify inter pod anti-affinity"}, + }, + }, + ExpectInstantiationError: false, + }, + { + Param: params.Params{ + MinReplicas: 2, + TopologyKey: ".+", + }, + Diagnostics: map[string][]diagnostic.Diagnostic{ + nonMatchingLabelSelectors: { + {Message: "object has 4 replicas but does not specify inter pod anti-affinity"}, + }, + }, + ExpectInstantiationError: false, + }, + }) +} From f63de09a9c9ea2ac642249a33b45f8363e9b4fae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Wo=C5=BAniak?= Date: Thu, 9 Jul 2026 14:35:13 +0200 Subject: [PATCH 2/2] test(no-anti-affinity): cover topology spread edge cases --- pkg/templates/antiaffinity/template_test.go | 42 ++++++++++++++++++--- 1 file changed, 36 insertions(+), 6 deletions(-) diff --git a/pkg/templates/antiaffinity/template_test.go b/pkg/templates/antiaffinity/template_test.go index ce3c2dc48..4ebe4bc00 100644 --- a/pkg/templates/antiaffinity/template_test.go +++ b/pkg/templates/antiaffinity/template_test.go @@ -133,7 +133,7 @@ func (s *AntiAffinityTestSuite) addDeploymentWithAntiAffinity(name string, repli } func (s *AntiAffinityTestSuite) addDeploymentWithTopologySpreadConstraint(name string, replicas int32, topologyKey string, - labelName string) { + labelName string, whenUnsatisfiable v1.UnsatisfiableConstraintAction) { s.addDeploymentWithReplicas(name, replicas) s.ctx.ModifyDeployment(s.T(), name, func(deployment *appsV1.Deployment) { deployment.Spec.Template.Labels = map[string]string{"app": name} @@ -141,7 +141,7 @@ func (s *AntiAffinityTestSuite) addDeploymentWithTopologySpreadConstraint(name s { MaxSkew: 1, TopologyKey: topologyKey, - WhenUnsatisfiable: v1.DoNotSchedule, + WhenUnsatisfiable: whenUnsatisfiable, LabelSelector: &metaV1.LabelSelector{ MatchLabels: map[string]string{"app": labelName}, }, @@ -248,13 +248,31 @@ func (s *AntiAffinityTestSuite) TestWithTopologySpreadConstraints() { otherValidKeyDepName = "topology-spread-other-valid-key" weirdKeyDepName = "topology-spread-weird-key" nonMatchingLabelSelectors = "topology-spread-non-matching-label-selector" + scheduleAnywayDepName = "topology-spread-schedule-anyway" + invalidLabelSelectorDepName = "topology-spread-invalid-label-selector" ) s.addDeploymentWithTopologySpreadConstraint(kubernetesIOHostnameDepName, 2, "kubernetes.io/hostname", - kubernetesIOHostnameDepName) - s.addDeploymentWithTopologySpreadConstraint(otherValidKeyDepName, 3, "other.valid/key", otherValidKeyDepName) - s.addDeploymentWithTopologySpreadConstraint(weirdKeyDepName, 4, "weird/key", weirdKeyDepName) + kubernetesIOHostnameDepName, v1.DoNotSchedule) + s.addDeploymentWithTopologySpreadConstraint(otherValidKeyDepName, 3, "other.valid/key", otherValidKeyDepName, + v1.DoNotSchedule) + s.addDeploymentWithTopologySpreadConstraint(weirdKeyDepName, 4, "weird/key", weirdKeyDepName, v1.DoNotSchedule) s.addDeploymentWithTopologySpreadConstraint(nonMatchingLabelSelectors, 4, "kubernetes.io/hostname", - "non-matching") + "non-matching", v1.DoNotSchedule) + s.addDeploymentWithTopologySpreadConstraint(scheduleAnywayDepName, 4, "kubernetes.io/hostname", scheduleAnywayDepName, + v1.ScheduleAnyway) + s.addDeploymentWithTopologySpreadConstraint(invalidLabelSelectorDepName, 4, "kubernetes.io/hostname", + invalidLabelSelectorDepName, v1.DoNotSchedule) + s.ctx.ModifyDeployment(s.T(), invalidLabelSelectorDepName, func(deployment *appsV1.Deployment) { + deployment.Spec.Template.Spec.TopologySpreadConstraints[0].LabelSelector = &metaV1.LabelSelector{ + MatchExpressions: []metaV1.LabelSelectorRequirement{ + { + Key: "app", + Operator: metaV1.LabelSelectorOperator("InvalidOperator"), + Values: []string{invalidLabelSelectorDepName}, + }, + }, + } + }) s.Validate(s.ctx, []templates.TestCase{ { @@ -271,6 +289,9 @@ func (s *AntiAffinityTestSuite) TestWithTopologySpreadConstraints() { nonMatchingLabelSelectors: { {Message: "object has 4 replicas but does not specify inter pod anti-affinity"}, }, + invalidLabelSelectorDepName: { + {Message: "object has 4 replicas but does not specify inter pod anti-affinity"}, + }, }, ExpectInstantiationError: false, }, @@ -289,6 +310,12 @@ func (s *AntiAffinityTestSuite) TestWithTopologySpreadConstraints() { nonMatchingLabelSelectors: { {Message: "object has 4 replicas but does not specify inter pod anti-affinity"}, }, + scheduleAnywayDepName: { + {Message: "object has 4 replicas but does not specify inter pod anti-affinity"}, + }, + invalidLabelSelectorDepName: { + {Message: "object has 4 replicas but does not specify inter pod anti-affinity"}, + }, }, ExpectInstantiationError: false, }, @@ -301,6 +328,9 @@ func (s *AntiAffinityTestSuite) TestWithTopologySpreadConstraints() { nonMatchingLabelSelectors: { {Message: "object has 4 replicas but does not specify inter pod anti-affinity"}, }, + invalidLabelSelectorDepName: { + {Message: "object has 4 replicas but does not specify inter pod anti-affinity"}, + }, }, ExpectInstantiationError: false, },