diff --git a/pkg/controller/controlled-cloudflared-connector.go b/pkg/controller/controlled-cloudflared-connector.go index b372156..da591f6 100644 --- a/pkg/controller/controlled-cloudflared-connector.go +++ b/pkg/controller/controlled-cloudflared-connector.go @@ -3,6 +3,7 @@ package controller import ( "context" "os" + "reflect" "slices" "strconv" @@ -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) @@ -144,6 +151,7 @@ func cloudflaredConnectDeploymentTemplating(protocol string, token string, names }, }, Spec: v1.PodSpec{ + Affinity: buildPodAntiAffinity(appName, replicas), Containers: []v1.Container{ { Name: appName, @@ -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 == "" { @@ -190,4 +231,3 @@ func buildCloudflaredCommand(protocol string, token string, extraArgs []string) return command } - diff --git a/pkg/controller/controlled-cloudflared-connector_test.go b/pkg/controller/controlled-cloudflared-connector_test.go index 9ec3d33..4fd0058 100644 --- a/pkg/controller/controlled-cloudflared-connector_test.go +++ b/pkg/controller/controlled-cloudflared-connector_test.go @@ -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) { @@ -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)) +}