Skip to content

Commit ab05121

Browse files
conradoclaude
andcommitted
IN-103: add pod anti-affinity to cloudflared connector deployment
Spread connector pods across nodes when replicas > 1 using requiredDuringSchedulingIgnoredDuringExecution anti-affinity. Adds needsUpdate check so existing deployments pick up the affinity on the next reconcile cycle. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 279e68d commit ab05121

2 files changed

Lines changed: 142 additions & 1 deletion

File tree

pkg/controller/controlled-cloudflared-connector.go

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package controller
33
import (
44
"context"
55
"os"
6+
"reflect"
67
"slices"
78
"strconv"
89

@@ -71,6 +72,12 @@ func CreateOrUpdateControlledCloudflared(
7172
}
7273
}
7374

75+
// Check if anti-affinity needs to be added or removed
76+
desiredAffinity := buildPodAntiAffinity("controlled-cloudflared-connector", desiredReplicas)
77+
if !affinityEqual(existingDeployment.Spec.Template.Spec.Affinity, desiredAffinity) {
78+
needsUpdate = true
79+
}
80+
7481
if needsUpdate {
7582

7683
updatedDeployment := cloudflaredConnectDeploymentTemplating(protocol, token, namespace, desiredReplicas, extraArgs)
@@ -144,6 +151,7 @@ func cloudflaredConnectDeploymentTemplating(protocol string, token string, names
144151
},
145152
},
146153
Spec: v1.PodSpec{
154+
Affinity: buildPodAntiAffinity(appName, replicas),
147155
Containers: []v1.Container{
148156
{
149157
Name: appName,
@@ -159,6 +167,39 @@ func cloudflaredConnectDeploymentTemplating(protocol string, token string, names
159167
}
160168
}
161169

170+
// buildPodAntiAffinity returns a pod anti-affinity that spreads pods across nodes.
171+
// Returns nil when replicas <= 1 (no point scheduling constraints for a single pod).
172+
func buildPodAntiAffinity(appName string, replicas int32) *v1.Affinity {
173+
if replicas <= 1 {
174+
return nil
175+
}
176+
return &v1.Affinity{
177+
PodAntiAffinity: &v1.PodAntiAffinity{
178+
RequiredDuringSchedulingIgnoredDuringExecution: []v1.PodAffinityTerm{
179+
{
180+
LabelSelector: &metav1.LabelSelector{
181+
MatchLabels: map[string]string{
182+
"app": appName,
183+
},
184+
},
185+
TopologyKey: "kubernetes.io/hostname",
186+
},
187+
},
188+
},
189+
}
190+
}
191+
192+
// affinityEqual compares two Affinity pointers for equality.
193+
func affinityEqual(a, b *v1.Affinity) bool {
194+
if a == nil && b == nil {
195+
return true
196+
}
197+
if a == nil || b == nil {
198+
return false
199+
}
200+
return reflect.DeepEqual(a, b)
201+
}
202+
162203
func getDesiredReplicas() (int32, error) {
163204
replicaCount := os.Getenv("CLOUDFLARED_REPLICA_COUNT")
164205
if replicaCount == "" {
@@ -190,4 +231,3 @@ func buildCloudflaredCommand(protocol string, token string, extraArgs []string)
190231

191232
return command
192233
}
193-

pkg/controller/controlled-cloudflared-connector_test.go

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@ package controller
33
import (
44
"testing"
55

6+
v1 "k8s.io/api/core/v1"
7+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
8+
69
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
711
)
812

913
func TestBuildCloudflaredCommand(t *testing.T) {
@@ -100,3 +104,100 @@ func TestBuildCloudflaredCommand(t *testing.T) {
100104
}
101105
}
102106

107+
func TestCloudflaredConnectDeploymentTemplating(t *testing.T) {
108+
t.Run("single replica has no anti-affinity", func(t *testing.T) {
109+
dep := cloudflaredConnectDeploymentTemplating("quic", "tok", "ns", 1, nil)
110+
111+
assert.Equal(t, "controlled-cloudflared-connector", dep.Name)
112+
assert.Equal(t, "ns", dep.Namespace)
113+
assert.Equal(t, int32(1), *dep.Spec.Replicas)
114+
assert.Nil(t, dep.Spec.Template.Spec.Affinity, "single replica should have no affinity")
115+
})
116+
117+
t.Run("multiple replicas have anti-affinity", func(t *testing.T) {
118+
dep := cloudflaredConnectDeploymentTemplating("quic", "tok", "ns", 3, nil)
119+
120+
assert.Equal(t, int32(3), *dep.Spec.Replicas)
121+
require.NotNil(t, dep.Spec.Template.Spec.Affinity)
122+
require.NotNil(t, dep.Spec.Template.Spec.Affinity.PodAntiAffinity)
123+
124+
terms := dep.Spec.Template.Spec.Affinity.PodAntiAffinity.RequiredDuringSchedulingIgnoredDuringExecution
125+
require.Len(t, terms, 1)
126+
assert.Equal(t, "kubernetes.io/hostname", terms[0].TopologyKey)
127+
assert.Equal(t, map[string]string{"app": "controlled-cloudflared-connector"}, terms[0].LabelSelector.MatchLabels)
128+
})
129+
130+
t.Run("labels are consistent across object meta, selector, and template", func(t *testing.T) {
131+
dep := cloudflaredConnectDeploymentTemplating("quic", "tok", "ns", 2, nil)
132+
133+
expectedLabels := map[string]string{
134+
"app": "controlled-cloudflared-connector",
135+
"strrl.dev/cloudflare-tunnel-ingress-controller": "controlled-cloudflared-connector",
136+
}
137+
assert.Equal(t, expectedLabels, dep.Labels)
138+
assert.Equal(t, expectedLabels, dep.Spec.Selector.MatchLabels)
139+
assert.Equal(t, expectedLabels, dep.Spec.Template.Labels)
140+
})
141+
142+
t.Run("container uses provided protocol and token", func(t *testing.T) {
143+
dep := cloudflaredConnectDeploymentTemplating("http2", "my-token", "default", 1, []string{"--post-quantum"})
144+
145+
require.Len(t, dep.Spec.Template.Spec.Containers, 1)
146+
c := dep.Spec.Template.Spec.Containers[0]
147+
assert.Equal(t, "controlled-cloudflared-connector", c.Name)
148+
assert.Contains(t, c.Command, "http2")
149+
assert.Contains(t, c.Command, "my-token")
150+
assert.Contains(t, c.Command, "--post-quantum")
151+
})
152+
}
153+
154+
func TestBuildPodAntiAffinity(t *testing.T) {
155+
t.Run("nil for single replica", func(t *testing.T) {
156+
assert.Nil(t, buildPodAntiAffinity("app", 1))
157+
})
158+
159+
t.Run("nil for zero replicas", func(t *testing.T) {
160+
assert.Nil(t, buildPodAntiAffinity("app", 0))
161+
})
162+
163+
t.Run("set for multiple replicas", func(t *testing.T) {
164+
aff := buildPodAntiAffinity("my-app", 3)
165+
require.NotNil(t, aff)
166+
terms := aff.PodAntiAffinity.RequiredDuringSchedulingIgnoredDuringExecution
167+
require.Len(t, terms, 1)
168+
assert.Equal(t, "kubernetes.io/hostname", terms[0].TopologyKey)
169+
assert.Equal(t, map[string]string{"app": "my-app"}, terms[0].LabelSelector.MatchLabels)
170+
})
171+
}
172+
173+
func TestAffinityEqual(t *testing.T) {
174+
aff1 := &v1.Affinity{
175+
PodAntiAffinity: &v1.PodAntiAffinity{
176+
RequiredDuringSchedulingIgnoredDuringExecution: []v1.PodAffinityTerm{
177+
{
178+
LabelSelector: &metav1.LabelSelector{
179+
MatchLabels: map[string]string{"app": "test"},
180+
},
181+
TopologyKey: "kubernetes.io/hostname",
182+
},
183+
},
184+
},
185+
}
186+
aff2 := &v1.Affinity{
187+
PodAntiAffinity: &v1.PodAntiAffinity{
188+
RequiredDuringSchedulingIgnoredDuringExecution: []v1.PodAffinityTerm{
189+
{
190+
LabelSelector: &metav1.LabelSelector{
191+
MatchLabels: map[string]string{"app": "test"},
192+
},
193+
TopologyKey: "kubernetes.io/hostname",
194+
},
195+
},
196+
},
197+
}
198+
199+
assert.True(t, affinityEqual(nil, nil))
200+
assert.False(t, affinityEqual(aff1, nil))
201+
assert.False(t, affinityEqual(nil, aff1))
202+
assert.True(t, affinityEqual(aff1, aff2))
203+
}

0 commit comments

Comments
 (0)