Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 41 additions & 1 deletion pkg/controller/controlled-cloudflared-connector.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package controller
import (
"context"
"os"
"reflect"
"slices"
"strconv"

Expand Down Expand Up @@ -71,6 +72,12 @@ func CreateOrUpdateControlledCloudflared(
}
}

// Check if anti-affinity needs to be added or removed
desiredAffinity := buildPodAntiAffinity("controlled-cloudflared-connector", desiredReplicas)
if !affinityEqual(existingDeployment.Spec.Template.Spec.Affinity, desiredAffinity) {
needsUpdate = true
}

if needsUpdate {

updatedDeployment := cloudflaredConnectDeploymentTemplating(protocol, token, namespace, desiredReplicas, extraArgs)
Expand Down Expand Up @@ -144,6 +151,7 @@ func cloudflaredConnectDeploymentTemplating(protocol string, token string, names
},
},
Spec: v1.PodSpec{
Affinity: buildPodAntiAffinity(appName, replicas),
Containers: []v1.Container{
{
Name: appName,
Expand All @@ -159,6 +167,39 @@ func cloudflaredConnectDeploymentTemplating(protocol string, token string, names
}
}

// buildPodAntiAffinity returns a pod anti-affinity that spreads pods across nodes.
// Returns nil when replicas <= 1 (no point scheduling constraints for a single pod).
func buildPodAntiAffinity(appName string, replicas int32) *v1.Affinity {
if replicas <= 1 {
return nil
}
return &v1.Affinity{
PodAntiAffinity: &v1.PodAntiAffinity{
RequiredDuringSchedulingIgnoredDuringExecution: []v1.PodAffinityTerm{
{
LabelSelector: &metav1.LabelSelector{
MatchLabels: map[string]string{
"app": appName,
},
},
TopologyKey: "kubernetes.io/hostname",
},
},
},
}
}

// affinityEqual compares two Affinity pointers for equality.
func affinityEqual(a, b *v1.Affinity) bool {
if a == nil && b == nil {
return true
}
if a == nil || b == nil {
return false
}
return reflect.DeepEqual(a, b)
}

func getDesiredReplicas() (int32, error) {
replicaCount := os.Getenv("CLOUDFLARED_REPLICA_COUNT")
if replicaCount == "" {
Expand Down Expand Up @@ -190,4 +231,3 @@ func buildCloudflaredCommand(protocol string, token string, extraArgs []string)

return command
}

101 changes: 101 additions & 0 deletions pkg/controller/controlled-cloudflared-connector_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ package controller
import (
"testing"

v1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestBuildCloudflaredCommand(t *testing.T) {
Expand Down Expand Up @@ -100,3 +104,100 @@ func TestBuildCloudflaredCommand(t *testing.T) {
}
}

func TestCloudflaredConnectDeploymentTemplating(t *testing.T) {
t.Run("single replica has no anti-affinity", func(t *testing.T) {
dep := cloudflaredConnectDeploymentTemplating("quic", "tok", "ns", 1, nil)

assert.Equal(t, "controlled-cloudflared-connector", dep.Name)
assert.Equal(t, "ns", dep.Namespace)
assert.Equal(t, int32(1), *dep.Spec.Replicas)
assert.Nil(t, dep.Spec.Template.Spec.Affinity, "single replica should have no affinity")
})

t.Run("multiple replicas have anti-affinity", func(t *testing.T) {
dep := cloudflaredConnectDeploymentTemplating("quic", "tok", "ns", 3, nil)

assert.Equal(t, int32(3), *dep.Spec.Replicas)
require.NotNil(t, dep.Spec.Template.Spec.Affinity)
require.NotNil(t, dep.Spec.Template.Spec.Affinity.PodAntiAffinity)

terms := dep.Spec.Template.Spec.Affinity.PodAntiAffinity.RequiredDuringSchedulingIgnoredDuringExecution
require.Len(t, terms, 1)
assert.Equal(t, "kubernetes.io/hostname", terms[0].TopologyKey)
assert.Equal(t, map[string]string{"app": "controlled-cloudflared-connector"}, terms[0].LabelSelector.MatchLabels)
})

t.Run("labels are consistent across object meta, selector, and template", func(t *testing.T) {
dep := cloudflaredConnectDeploymentTemplating("quic", "tok", "ns", 2, nil)

expectedLabels := map[string]string{
"app": "controlled-cloudflared-connector",
"strrl.dev/cloudflare-tunnel-ingress-controller": "controlled-cloudflared-connector",
}
assert.Equal(t, expectedLabels, dep.Labels)
assert.Equal(t, expectedLabels, dep.Spec.Selector.MatchLabels)
assert.Equal(t, expectedLabels, dep.Spec.Template.Labels)
})

t.Run("container uses provided protocol and token", func(t *testing.T) {
dep := cloudflaredConnectDeploymentTemplating("http2", "my-token", "default", 1, []string{"--post-quantum"})

require.Len(t, dep.Spec.Template.Spec.Containers, 1)
c := dep.Spec.Template.Spec.Containers[0]
assert.Equal(t, "controlled-cloudflared-connector", c.Name)
assert.Contains(t, c.Command, "http2")
assert.Contains(t, c.Command, "my-token")
assert.Contains(t, c.Command, "--post-quantum")
})
}

func TestBuildPodAntiAffinity(t *testing.T) {
t.Run("nil for single replica", func(t *testing.T) {
assert.Nil(t, buildPodAntiAffinity("app", 1))
})

t.Run("nil for zero replicas", func(t *testing.T) {
assert.Nil(t, buildPodAntiAffinity("app", 0))
})

t.Run("set for multiple replicas", func(t *testing.T) {
aff := buildPodAntiAffinity("my-app", 3)
require.NotNil(t, aff)
terms := aff.PodAntiAffinity.RequiredDuringSchedulingIgnoredDuringExecution
require.Len(t, terms, 1)
assert.Equal(t, "kubernetes.io/hostname", terms[0].TopologyKey)
assert.Equal(t, map[string]string{"app": "my-app"}, terms[0].LabelSelector.MatchLabels)
})
}

func TestAffinityEqual(t *testing.T) {
aff1 := &v1.Affinity{
PodAntiAffinity: &v1.PodAntiAffinity{
RequiredDuringSchedulingIgnoredDuringExecution: []v1.PodAffinityTerm{
{
LabelSelector: &metav1.LabelSelector{
MatchLabels: map[string]string{"app": "test"},
},
TopologyKey: "kubernetes.io/hostname",
},
},
},
}
aff2 := &v1.Affinity{
PodAntiAffinity: &v1.PodAntiAffinity{
RequiredDuringSchedulingIgnoredDuringExecution: []v1.PodAffinityTerm{
{
LabelSelector: &metav1.LabelSelector{
MatchLabels: map[string]string{"app": "test"},
},
TopologyKey: "kubernetes.io/hostname",
},
},
},
}

assert.True(t, affinityEqual(nil, nil))
assert.False(t, affinityEqual(aff1, nil))
assert.False(t, affinityEqual(nil, aff1))
assert.True(t, affinityEqual(aff1, aff2))
}
Loading