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
82 changes: 53 additions & 29 deletions pkg/controller/controlled-cloudflared-connector.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ package controller

import (
"context"
"encoding/json"
"os"
"reflect"
"slices"
"strconv"

cloudflarecontroller "github.com/STRRL/cloudflare-tunnel-ingress-controller/pkg/cloudflare-controller"
Expand All @@ -24,14 +27,15 @@ func CreateOrUpdateControlledCloudflared(
extraArgs []string,
) error {
logger := log.FromContext(ctx)
list := appsv1.DeploymentList{}
err := kubeClient.List(ctx, &list, &client.ListOptions{

// List existing deployments with the specific label
var list appsv1.DeploymentList
if err := kubeClient.List(ctx, &list, &client.ListOptions{
Namespace: namespace,
LabelSelector: labels.SelectorFromSet(labels.Set{
"strrl.dev/cloudflare-tunnel-ingress-controller": "controlled-cloudflared-connector",
}),
})
if err != nil {
}); err != nil {
return errors.Wrapf(err, "list controlled-cloudflared-connector in namespace %s", namespace)
}

Expand All @@ -44,7 +48,7 @@ func CreateOrUpdateControlledCloudflared(
}

needsUpdate := false
if *existingDeployment.Spec.Replicas != desiredReplicas {
if desiredReplicas >= 0 && existingDeployment.Spec.Replicas != nil && *existingDeployment.Spec.Replicas != desiredReplicas {
needsUpdate = true
}

Expand All @@ -55,27 +59,34 @@ func CreateOrUpdateControlledCloudflared(
}

if len(existingDeployment.Spec.Template.Spec.Containers) > 0 {
container := &existingDeployment.Spec.Template.Spec.Containers[0]
container := existingDeployment.Spec.Template.Spec.Containers[0]
if container.Image != os.Getenv("CLOUDFLARED_IMAGE") {
needsUpdate = true
}
if string(container.ImagePullPolicy) != os.Getenv("CLOUDFLARED_IMAGE_PULL_POLICY") {
needsUpdate = true
}

// Check if command arguments have changed
desiredCommand := buildCloudflaredCommand(protocol, token, extraArgs)
if !slicesEqual(container.Command, desiredCommand) {
desiredCommand := getCloudflaredCommand(protocol, token, extraArgs)
if !slices.Equal(container.Command, desiredCommand) {
needsUpdate = true
}

// Check if resource requirements have changed
desiredResources, err := getDesiredResources()
if err != nil {
return errors.Wrap(err, "get desired resources")
}
if os.Getenv("CLOUDFLARED_RESOURCES") != "" && !reflect.DeepEqual(container.Resources, desiredResources) {
needsUpdate = true
}
}

if needsUpdate {

updatedDeployment := cloudflaredConnectDeploymentTemplating(protocol, token, namespace, desiredReplicas, extraArgs)
existingDeployment.Spec = updatedDeployment.Spec
err = kubeClient.Update(ctx, existingDeployment)
if err != nil {
if err := kubeClient.Update(ctx, existingDeployment); err != nil {
return errors.Wrap(err, "update controlled-cloudflared-connector deployment")
}
logger.Info("Updated controlled-cloudflared-connector deployment", "namespace", namespace)
Expand All @@ -95,11 +106,11 @@ func CreateOrUpdateControlledCloudflared(
}

deployment := cloudflaredConnectDeploymentTemplating(protocol, token, namespace, replicas, extraArgs)
err = kubeClient.Create(ctx, deployment)
if err != nil {
if err := kubeClient.Create(ctx, deployment); err != nil {
return errors.Wrap(err, "create controlled-cloudflared-connector deployment")
}
logger.Info("Created controlled-cloudflared-connector deployment", "namespace", namespace)

return nil
}

Expand All @@ -117,7 +128,10 @@ func cloudflaredConnectDeploymentTemplating(protocol string, token string, names
pullPolicy = "IfNotPresent"
}

return &appsv1.Deployment{
// Ignore error, if any. If there's an error, resources will be empty and thus ignored by Kubernetes.
resources, _ := getDesiredResources()

deployment := &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{
Name: appName,
Namespace: namespace,
Expand Down Expand Up @@ -148,20 +162,27 @@ func cloudflaredConnectDeploymentTemplating(protocol string, token string, names
Name: appName,
Image: image,
ImagePullPolicy: v1.PullPolicy(pullPolicy),
Command: buildCloudflaredCommand(protocol, token, extraArgs),
Command: getCloudflaredCommand(protocol, token, extraArgs),
Resources: resources,
},
},
RestartPolicy: v1.RestartPolicyAlways,
},
},
},
}

if replicas < 0 {
deployment.Spec.Replicas = nil // Use Kubernetes default
}

return deployment
}

func getDesiredReplicas() (int32, error) {
replicaCount := os.Getenv("CLOUDFLARED_REPLICA_COUNT")
if replicaCount == "" {
return 1, nil
return -1, nil
}
replicas, err := strconv.ParseInt(replicaCount, 10, 32)
if err != nil {
Expand All @@ -170,34 +191,37 @@ func getDesiredReplicas() (int32, error) {
return int32(replicas), nil
}

func buildCloudflaredCommand(protocol string, token string, extraArgs []string) []string {
func getCloudflaredCommand(protocol string, token string, extraArgs []string) []string {
command := []string{
"cloudflared",
"--protocol",
protocol,
"--no-autoupdate",
"tunnel",
}

// Add all extra arguments between "tunnel" and "run"
if len(extraArgs) > 0 {
command = append(command, extraArgs...)
}

// Add metrics, run subcommand and token
command = append(command, "--metrics", "0.0.0.0:44483", "run", "--token", token)

return command
}

func slicesEqual(a, b []string) bool {
if len(a) != len(b) {
return false
func getDesiredResources() (v1.ResourceRequirements, error) {
var desiredresources v1.ResourceRequirements

resources := os.Getenv("CLOUDFLARED_RESOURCES")
if resources == "" {
return desiredresources, nil
}
for i, v := range a {
if v != b[i] {
return false
}

if err := json.Unmarshal([]byte(resources), &desiredresources); err != nil {
return desiredresources, errors.Wrap(err, "invalid resource requirements")
}
return true

return desiredresources, nil
}
57 changes: 2 additions & 55 deletions pkg/controller/controlled-cloudflared-connector_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import (
"github.com/stretchr/testify/assert"
)

func TestBuildCloudflaredCommand(t *testing.T) {
func TestGetCloudflaredCommand(t *testing.T) {
tests := []struct {
name string
protocol string
Expand Down Expand Up @@ -94,61 +94,8 @@ func TestBuildCloudflaredCommand(t *testing.T) {

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := buildCloudflaredCommand(tt.protocol, tt.token, tt.extraArgs)
result := getCloudflaredCommand(tt.protocol, tt.token, tt.extraArgs)
assert.Equal(t, tt.expected, result)
})
}
}

func TestSlicesEqual(t *testing.T) {
tests := []struct {
name string
a []string
b []string
expected bool
}{
{
name: "equal slices",
a: []string{"a", "b", "c"},
b: []string{"a", "b", "c"},
expected: true,
},
{
name: "different length",
a: []string{"a", "b"},
b: []string{"a", "b", "c"},
expected: false,
},
{
name: "different content",
a: []string{"a", "b", "c"},
b: []string{"a", "x", "c"},
expected: false,
},
{
name: "empty slices",
a: []string{},
b: []string{},
expected: true,
},
{
name: "nil slices",
a: nil,
b: nil,
expected: true,
},
{
name: "nil vs empty",
a: nil,
b: []string{},
expected: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := slicesEqual(tt.a, tt.b)
assert.Equal(t, tt.expected, result)
})
}
}
Loading