Repository: rancher/fleet
Pull request #3499 (2025-04-02 17:06:19)

Clean up dev and CI scripts
Refers to #3038 

0xavi0: Other possible scripts to check if they are necessary or not:

* dev/airgap-rancher.sh 
* dev/delve-debug 
* dev/run-integration-tests.sh --> seems to do the same than the already used in CI .github/scripts/run-integration-tests.sh
* dev/setup-fleet-downstream 

Patch:
diff --git a/.github/scripts/setup-rancher.sh b/.github/scripts/setup-rancher.sh
index b1cb901c..76447399 100755
--- a/.github/scripts/setup-rancher.sh
+++ b/.github/scripts/setup-rancher.sh
@@ -5,18 +5,20 @@ set -euxo pipefail
 public_hostname="${public_hostname-172.18.0.1.sslip.io}"
 fleetns="${fleetns-cattle-fleet-system}"
 upstream_ctx="${FLEET_E2E_CLUSTER-k3d-upstream}"
-version="${1-v2.7.0}"
-
-helm repo add rancher-latest https://releases.rancher.com/server-charts/latest
-helm repo update rancher-latest
+version="${1-}"
 
 kubectl config use-context "$upstream_ctx"
 
 kubectl apply -f https://github.com/jetstack/cert-manager/releases/download/v1.5.4/cert-manager.yaml
 kubectl wait --for=condition=Available deployment --timeout=2m -n cert-manager --all
 
+helm repo add rancher-latest https://releases.rancher.com/server-charts/latest
 # set CATTLE_SERVER_URL and CATTLE_BOOTSTRAP_PASSWORD to get rancher out of "bootstrap" mode
-helm upgrade rancher rancher-latest/rancher --version "$version" \
+args=""
+if [[ -n "$version" ]]; then
+  args="--version=$version"
+fi
+helm upgrade rancher rancher-latest/rancher $args \
   --devel \
   --install --wait \
   --create-namespace \
@@ -35,4 +37,11 @@ kubectl -n cattle-system rollout status deploy/rancher
 # wait for rancher to create fleet namespace, deployment and controller
 { grep -q -m 1 "fleet"; kill $!; } < <(kubectl get deployments -n "$fleetns" -w)
 kubectl -n "$fleetns" rollout status deploy/fleet-controller
-{ grep -E -q -m 1 "fleet-agent-local.*1/1"; kill $!; } < <(kubectl get bundles -n fleet-local -w)
+until kubectl get bundles -n fleet-local | grep -q fleet-agent-local; do
+  kubectl get bundles -n fleet-local || true
+  sleep 3
+done
+until kubectl get bundles -n fleet-local fleet-agent-local -o=jsonpath='{.status.conditions[?(@.type=="Ready")].status}' | grep -q "True"; do
+  kubectl logs -l app=fleet-agent -n cattle-fleet-local-system || true
+  sleep 3
+done
diff --git a/charts/fleet-agent/templates/deployment.yaml b/charts/fleet-agent/templates/deployment.yaml
index 6092eecb..d8469c56 100644
--- a/charts/fleet-agent/templates/deployment.yaml
+++ b/charts/fleet-agent/templates/deployment.yaml
@@ -3,6 +3,7 @@ kind: Deployment
 metadata:
   name: fleet-agent
 spec:
+  replicas: {{ .Values.agent.replicas }}
   selector:
     matchLabels:
       app: fleet-agent
diff --git a/charts/fleet-agent/values.yaml b/charts/fleet-agent/values.yaml
index 7c80ea58..15f51478 100644
--- a/charts/fleet-agent/values.yaml
+++ b/charts/fleet-agent/values.yaml
@@ -76,6 +76,7 @@ disableSecurityContext: false
 
 ## Fleet agent configuration
 agent:
+  replicas: 1
   reconciler:
     # The number of workers that are allowed for each type of reconciler
     workers:
diff --git a/charts/fleet/templates/deployment.yaml b/charts/fleet/templates/deployment.yaml
index cef1dfd7..df6f8c22 100644
--- a/charts/fleet/templates/deployment.yaml
+++ b/charts/fleet/templates/deployment.yaml
@@ -15,6 +15,7 @@ kind: Deployment
 metadata:
   name: "fleet-controller{{if $shard.id }}-shard-{{ $shard.id }}{{end}}"
 spec:
+  replicas: {{ $.Values.controller.replicas }}
   selector:
     matchLabels:
       app: fleet-controller
@@ -192,6 +193,10 @@ spec:
         - name: CATTLE_ELECTION_RENEW_DEADLINE
           value: {{$.Values.leaderElection.renewDeadline}}
         {{- end }}
+        {{- if $.Values.agent.replicas }}
+        - name: FLEET_AGENT_REPLICA_COUNT
+          value: "{{ $.Values.agent.replicas }}"
+        {{- end }}
         image: '{{ template "system_default_registry" $ }}{{ $.Values.image.repository }}:{{ $.Values.image.tag }}'
         name: fleet-agentmanagement
         imagePullPolicy: "{{ $.Values.image.imagePullPolicy }}"
diff --git a/charts/fleet/templates/deployment_gitjob.yaml b/charts/fleet/templates/deployment_gitjob.yaml
index 8302f277..ebb83409 100644
--- a/charts/fleet/templates/deployment_gitjob.yaml
+++ b/charts/fleet/templates/deployment_gitjob.yaml
@@ -16,6 +16,7 @@ kind: Deployment
 metadata:
   name: "gitjob{{if $shard.id }}-shard-{{ $shard.id }}{{end}}"
 spec:
+  replicas: {{ $.Values.gitjob.replicas }}
   selector:
     matchLabels:
       app: "gitjob"
diff --git a/charts/fleet/templates/deployment_helmops.yaml b/charts/fleet/templates/deployment_helmops.yaml
index 2ea710f0..b5efcbf6 100644
--- a/charts/fleet/templates/deployment_helmops.yaml
+++ b/charts/fleet/templates/deployment_helmops.yaml
@@ -16,6 +16,7 @@ kind: Deployment
 metadata:
   name: "helmops{{if $shard.id }}-shard-{{ $shard.id }}{{end}}"
 spec:
+  replicas: {{ $.Values.helmops.replicas }}
   selector:
     matchLabels:
       app: "helmops"
diff --git a/charts/fleet/values.yaml b/charts/fleet/values.yaml
index 944228d3..ae5c1877 100644
--- a/charts/fleet/values.yaml
+++ b/charts/fleet/values.yaml
@@ -105,6 +105,7 @@ leaderElection:
 
 ## Fleet controller configuration
 controller:
+  replicas: 1
   reconciler:
     # The number of workers that are allowed to each type of reconciler
     workers:
@@ -115,8 +116,15 @@ controller:
       clustergroup: "50"
       imagescan: "50"
 
+gitjob:
+  replicas: 1
+
+helmops:
+  replicas: 1
+
 ## Fleet agent configuration
 agent:
+  replicas: 1
   reconciler:
     # The number of workers that are allowed for each type of reconciler
     workers:
diff --git a/dev/README.md b/dev/README.md
index 7bbfb329..c056a569 100644
--- a/dev/README.md
+++ b/dev/README.md
@@ -41,7 +41,7 @@ Optional flags to `ginkgo` for reporting on long-running tests:
 
 For multi-cluster tests we need to configure two clusters. You also need to make
 the upstream clusters API accessible to the downstream cluster. The default URL
-in `dev/setup-fleet-downstream` should work with most systems.
+in `dev/setup-fleet-managed-downstream` should work with most systems.
 
 ```bash
 source dev/setup-multi-cluster
diff --git a/dev/airgap-rancher.sh b/dev/airgap-rancher.sh
deleted file mode 100755
index 8f50653d..00000000
--- a/dev/airgap-rancher.sh
+++ /dev/null
@@ -1,56 +0,0 @@
-#!/bin/bash
-set -e
-
-# doesn't support windows
-
-reg=${1-$priv}
-ver=${2-2.7.0-rc8}
-
-kubectl get pod -A  -o jsonpath='{range .items[*].spec.containers[*]}{.image}{"\n"}{end}' | sort -u | grep -v windows > short-list.txt
-
-wget "https://github.com/rancher/rancher/releases/download/v$ver/rancher-images.txt" -O rancher-images.txt
-
-essentials="rancher/kubectl
-rancher/machine
-rancher/pause
-rancher/system-upgrade-controller
-rancher/thanosio-thanos
-rancher/webhook-receiver
-rancher/rancher-webhook"
-
-grep -f <(echo $essentials) rancher-images.txt | grep -v windows >> short-list.txt
-
-function pullpush() {
-  reg="$1"
-  i="$2"
-
-  grep "$i" rancher-images.txt
-
-  if docker pull "$i" > /dev/null 2>&1; then
-    echo "Image pull success: $i"
-    pulled="$pulled $i"
-  else
-    if docker inspect "$i" > /dev/null 2>&1; then
-      echo "Image exists: $i"
-      pulled="$pulled $i"
-    else
-      echo "Image pull failed: $i"
-    fi
-  fi
-
-  case $i in
-    */*)
-      image_name="$reg/$i"
-      ;;
-    *)
-      image_name="$reg/rancher/$i"
-      ;;
-  esac
-
-  docker tag "$i" "$image_name"
-  docker push "$image_name"
-}
-
-while read -r i; do
-  pullpush "$reg" "$i"
-done < short-list.txt
diff --git a/dev/delve-debug b/dev/delve-debug
deleted file mode 100755
index 9b597d2c..00000000
--- a/dev/delve-debug
+++ /dev/null
@@ -1,27 +0,0 @@
-#!/bin/bash
-# Usage: delve-debug fleet-controller
-# see https://github.com/moio/delve-debugger for full version
-
-set -ex
-
-ns=cattle-fleet-system
-app="$1"
-
-if [ -z "$app" ]; then
-  echo "Usage: $0 <app>" >&2
-  exit 1
-fi
-
-procname=${app/-}
-
-#kubectl patch deployments -n cattle-fleet-system fleet-agent --type=merge -p '{"spec":{"replicas":1}}'
-
-pod=$(kubectl get pod -n "$ns" -l "app=$app"  -o='jsonpath={.items[0].metadata.name}')
-
-kubectl --namespace "$ns" port-forward "pod/$pod" 4000 &
-trap "kill $!" EXIT
-
-echo `tput bold`
-echo 'Please wait for the line "debug layer=debugger continuing" to appear...'
-echo `tput sgr0`
-kubectl --namespace "$ns" debug -it "pod/$pod" --image=ghcr.io/rancherlabs/delve-debugger:latest --target="$app" --env="[EXECUTABLE=$procname]"
diff --git a/dev/setup-fleet-downstream b/dev/setup-fleet-downstream
deleted file mode 100755
index 128999a7..00000000
--- a/dev/setup-fleet-downstream
+++ /dev/null
@@ -1,75 +0,0 @@
-#!/bin/bash
-# Description: setup fleet in the downstream cluster and register it (agent initiated registration)
-
-set -euxo pipefail
-
-if [ ! -d ./charts/fleet ]; then
-  echo "please change the current directory to the fleet repo checkout"
-  exit 1
-fi
-
-upstream_ctx="${FLEET_E2E_CLUSTER-k3d-upstream}"
-downstream_ctx="${FLEET_E2E_CLUSTER_DOWNSTREAM-k3d-downstream1}"
-ns="${FLEET_E2E_NS_DOWNSTREAM-fleet-local}"
-
-kubectl create ns "$ns"|| true
-
-# predefined cluster
-# kubectl apply -f - <<EOF
-# apiVersion: "fleet.cattle.io/v1alpha1"
-# kind: Cluster
-# metadata:
-#   name: second
-#   namespace: "$ns"
-# spec:
-#   clientID: "fake-random"
-# EOF
-
-kubectl config use-context "$upstream_ctx"
-
-kubectl apply -f - <<EOF
-apiVersion: "fleet.cattle.io/v1alpha1"
-kind: ClusterRegistrationToken
-metadata:
-  name: second-token
-  namespace: "$ns"
-spec:
-  ttl: 12h
-EOF
-{ grep -q -m 1 "second-token"; kill $!; } < <(kubectl get clusterregistrationtoken -n "$ns" -w)
-while ! kubectl get clusterregistrationtoken -n "$ns" -o jsonpath='{.status}' second-token | grep -q secretName; do
-  sleep 1
-done
-kubectl wait clusterregistrationtoken -n "$ns" --for=jsonpath='{.status.secretName}'=second-token second-token
-
-token=$(kubectl get secret -n "$ns" second-token -o go-template='{{index .data "values" | base64decode}}' | yq .token -)
-
-ca=""
-serverver=$(kubectl version -ojson 2> /dev/null | jq '.serverVersion.minor' | sed 's/"//g')
-if [ "$serverver" -gt 23 ]; then
-  ca=$(kubectl get secret -n cattle-fleet-system fleet-controller-bootstrap-token -o go-template='{{index .data "ca.crt" | base64decode}}')
-else
-  name=$(kubectl get -n default sa default -o=jsonpath='{.secrets[0].name}')
-  ca=$(kubectl get -n default secret "$name" -o go-template='{{index .data "ca.crt" | base64decode}}')
-fi
-
-# docker network inspect bridge -f '{{(index .IPAM.Config 0).Gateway}}'
-# public_hostname="${public_hostname-172.17.0.1.sslip.io}"
-
-# works due to same network of k3d clustres and patched SAN cert
-public_hostname="${public_hostname-k3d-upstream-server-0}"
-
-kubectl config use-context "$downstream_ctx"
-helm -n cattle-fleet-system upgrade --install --create-namespace --wait fleet-agent charts/fleet-agent \
-  --set-string labels.env=test \
-  --set apiServerCA="$ca" \
-  --set apiServerURL="https://$public_hostname:6443" \
-  --set clusterNamespace="$ns" \
-  --set token="$token"
-  #--set systemRegistrationNamespace="fleet-clusters-system" \
-  #--set clientID="fake-random" \
-  # --set global.cattle.systemDefaultRegistry=public.ecr.aws/b3e3i8k2 \
-  # --set image.tag=v0.3.9  \
-  # --set agentImage.tag=v0.3.9 \
-
-kubectl config use-context "$upstream_ctx"
diff --git a/dev/setup-rancher-with-dev-fleet b/dev/setup-rancher-with-dev-fleet
deleted file mode 100755
index 4cfaefdb..00000000
--- a/dev/setup-rancher-with-dev-fleet
+++ /dev/null
@@ -1,69 +0,0 @@
-#!/bin/bash
-
-# Prerequisite: a running cluster!
-
-set -euxo
-
-branch=${CHARTS_BRANCH-""}
-
-# Build Rancher
-# This is not needed if all you want is to test Fleet against an existing Rancher release
-#pushd ../rancher
-#make quick
-#popd
-
-until kubectl get service -n kube-system traefik -o jsonpath='{.status.loadBalancer.ingress[0].ip}'; do sleep 3; done
-ip=$(kubectl get service -n kube-system traefik -o jsonpath='{.status.loadBalancer.ingress[0].ip}')
-
-#if [ -z $branch ]; then
-#   echo "TODO run test charts release workflow:"
-#   echo "https://github.com/rancher/fleet/actions/workflows/release-against-test-charts.yml"
-#   exit 1
-#fi
-
-helm repo update
-helm upgrade --install cert-manager jetstack/cert-manager \
-    --namespace cert-manager \
-    --create-namespace \
-    --set crds.enabled=true \
-    --wait \
-    --wait-for-jobs
-
-helm upgrade --install rancher rancher-latest/rancher \
-    --devel \
-    --namespace cattle-system \
-    --create-namespace \
-    --set hostname=$ip.sslip.io \
-    --set bootstrapPassword=admin \
-    --set agentTLSMode=system-store \
-    --set "extraEnv[0].name=CATTLE_SERVER_URL" \
-    --set "extraEnv[0].value=https://$ip.sslip.io" \
-    --set replicas=1 \
-#    --set rancherImageTag=$tag \
-#    --set extraEnv[0].name=CATTLE_FLEET_VERSION \
-#    --set extraEnv[0].value=999.9.9+up9.9.9 \
-#    --set extraEnv[1].name=CATTLE_CHART_DEFAULT_URL \
-#    --set extraEnv[1].value=https://github.com/fleetrepoci/charts \
-#    --set extraEnv[2].name=CATTLE_CHART_DEFAULT_BRANCH \
-#    --set extraEnv[2].value=$branch \
-
-     # only needed for 2.7 which doesn't support CATTLE_FLEET_VERSION (nor version pinning btw, needs additional cherry
-     # picks; hopefully we soon won't have to deal with that branch anymore)
-    #--set extraEnv[0].name=CATTLE_FLEET_MIN_VERSION \
-
-# wait for deployment of rancher
-kubectl -n cattle-system rollout status deploy/rancher
-
-# wait for rancher to create fleet namespace, deployment and controller
-until kubectl get deployments -n cattle-fleet-system | grep -q "fleet"; do sleep 3; done
-kubectl -n cattle-fleet-system rollout status deploy/fleet-controller
-
-until kubectl get bundles -n fleet-local | grep -q "fleet-agent-local.*1/1"; do sleep 3; done
-
-helm list -A
-
-export public_hostname=$ip.sslip.io
-export cluster_downstream="k3d-downstream1"
-
-./.github/scripts/wait-for-loadbalancer.sh
-./.github/scripts/register-downstream-clusters.sh
diff --git a/internal/cmd/controller/agentmanagement/agent/agent.go b/internal/cmd/controller/agentmanagement/agent/agent.go
index 5f16a242..a6a1bb20 100644
--- a/internal/cmd/controller/agentmanagement/agent/agent.go
+++ b/internal/cmd/controller/agentmanagement/agent/agent.go
@@ -8,6 +8,7 @@ import (
 	"time"
 
 	"github.com/rancher/fleet/internal/client"
+	"github.com/rancher/fleet/internal/cmd"
 	fleetns "github.com/rancher/fleet/internal/cmd/controller/namespace"
 	"github.com/rancher/fleet/internal/config"
 	fleet "github.com/rancher/fleet/pkg/apis/fleet.cattle.io/v1alpha1"
@@ -77,6 +78,8 @@ func AgentWithConfig(ctx context.Context, agentNamespace, controllerNamespace, a
 	mo.BundleDeploymentWorkers = cfg.AgentWorkers.BundleDeployment
 	mo.DriftWorkers = cfg.AgentWorkers.Drift
 
+	mo.AgentReplicas = cmd.ParseEnvAgentReplicaCount()
+
 	objs = append(objs, Manifest(agentNamespace, agentScope, mo)...)
 
 	return objs, err
diff --git a/internal/cmd/controller/agentmanagement/agent/manifest.go b/internal/cmd/controller/agentmanagement/agent/manifest.go
index b0d75da5..6ad46410 100644
--- a/internal/cmd/controller/agentmanagement/agent/manifest.go
+++ b/internal/cmd/controller/agentmanagement/agent/manifest.go
@@ -36,6 +36,7 @@ type ManifestOptions struct {
 	AgentImage              string // DefaultAgentImage = "rancher/fleet-agent" + ":" + version.Version
 	AgentImagePullPolicy    string
 	AgentTolerations        []corev1.Toleration
+	AgentReplicas           int32
 	CheckinInterval         string
 	PrivateRepoURL          string // PrivateRepoURL = registry.yourdomain.com:5000
 	SystemDefaultRegistry   string
@@ -144,6 +145,7 @@ func agentApp(namespace string, agentScope string, opts ManifestOptions) *appsv1
 	name := DefaultName
 	serviceAccount := DefaultName
 	image := Resolve(opts.SystemDefaultRegistry, opts.PrivateRepoURL, opts.AgentImage)
+	replicas := opts.AgentReplicas
 
 	app := &appsv1.Deployment{
 		ObjectMeta: metav1.ObjectMeta{
@@ -151,6 +153,7 @@ func agentApp(namespace string, agentScope string, opts ManifestOptions) *appsv1
 			Name:      name,
 		},
 		Spec: appsv1.DeploymentSpec{
+			Replicas: &replicas,
 			Selector: &metav1.LabelSelector{
 				MatchLabels: map[string]string{
 					"app": name,
diff --git a/internal/cmd/controller/agentmanagement/controllers/cluster/import.go b/internal/cmd/controller/agentmanagement/controllers/cluster/import.go
index 939fd043..476f1fd6 100644
--- a/internal/cmd/controller/agentmanagement/controllers/cluster/import.go
+++ b/internal/cmd/controller/agentmanagement/controllers/cluster/import.go
@@ -5,6 +5,7 @@ import (
 	"context"
 	"crypto/sha256"
 	"encoding/json"
+	"errors"
 	"fmt"
 	"net/http"
 	"os"
@@ -12,6 +13,7 @@ import (
 	"github.com/sirupsen/logrus"
 
 	"github.com/rancher/fleet/internal/client"
+	"github.com/rancher/fleet/internal/cmd"
 	"github.com/rancher/fleet/internal/cmd/agent/deployer/desiredset"
 	"github.com/rancher/fleet/internal/cmd/controller/agentmanagement/agent"
 	"github.com/rancher/fleet/internal/cmd/controller/agentmanagement/connection"
@@ -28,6 +30,7 @@ import (
 	"github.com/rancher/wrangler/v3/pkg/randomtoken"
 	"github.com/rancher/wrangler/v3/pkg/yaml"
 
+	corev1 "k8s.io/api/core/v1"
 	apierrors "k8s.io/apimachinery/pkg/api/errors"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/client-go/kubernetes"
@@ -80,7 +83,7 @@ func RegisterImport(
 // fleet-controller config map. This is important for changes to apiServerURL
 // and apiServerCA, as they are needed e.g. to update the fleet-agent-bootstrap
 // secret.
-func (i *importHandler) onConfig(config *config.Config) error {
+func (i *importHandler) onConfig(cfg *config.Config) error {
 	clusters, err := i.clusters.List("", metav1.ListOptions{})
 	if err != nil {
 		return err
@@ -90,15 +93,22 @@ func (i *importHandler) onConfig(config *config.Config) error {
 		return nil
 	}
 
+	if cfg == nil {
+		return errors.New("config is nil: this should never happen")
+	}
+
 	for _, cluster := range clusters.Items {
 		if cluster.Spec.KubeConfigSecret == "" {
 			continue
 		}
 
-		hasConfigChanged := config.APIServerURL != cluster.Status.APIServerURL ||
-			hashStatusField(config.APIServerCA) != cluster.Status.APIServerCAHash ||
-			config.AgentTLSMode != cluster.Status.AgentTLSMode ||
-			hasGarbageCollectionIntervalChanged(config, cluster)
+		hasConfigChanged, err := i.hasAPIServerConfigChanged(cfg, cluster)
+		if err != nil {
+			return fmt.Errorf("cluster %s/%s: could not check for config changes: %v", cluster.Namespace, cluster.Name, err)
+		}
+
+		hasConfigChanged = hasConfigChanged || cfg.AgentTLSMode != cluster.Status.AgentTLSMode ||
+			hasGarbageCollectionIntervalChanged(cfg, cluster)
 
 		if hasConfigChanged {
 			logrus.Infof("API server config changed, trigger cluster import for cluster %s/%s", cluster.Namespace, cluster.Name)
@@ -113,6 +123,37 @@ func (i *importHandler) onConfig(config *config.Config) error {
 	return nil
 }
 
+// hasAPIServerConfigChanged checks for changes in API server URL or CA configuration, comparing the current state of
+// the cluster with cfg. However, if the cluster references a secret through its `KubeConfigSecret` field, then API
+// server URL and CA are understood to be sourced from there, hence config changes for those fields will be skipped.
+// Returns a boolean indicating whether URL or CA config has changed, and any error that may have occurred (such as the
+// referenced secret not being found).
+func (i *importHandler) hasAPIServerConfigChanged(cfg *config.Config, cluster fleet.Cluster) (bool, error) {
+	kubeConfigSecretNamespace := getKubeConfigSecretNS(cluster)
+
+	var secretAPIServerCA, secretAPIServerURL []byte
+	var secret *corev1.Secret
+
+	if cluster.Spec.KubeConfigSecret != "" {
+		var err error
+		secret, err = i.secrets.Get(kubeConfigSecretNamespace, cluster.Spec.KubeConfigSecret)
+		if err != nil {
+			return false, err
+		}
+
+		secretAPIServerURL = secret.Data[config.APIServerURLKey]
+		secretAPIServerCA = secret.Data[config.APIServerCAKey]
+	}
+
+	// if the API server URL is non-empty in the secret, then it is sourced from there; config changes for that field
+	// are irrelevant.
+	// The same applies to the CA.
+	hasURLChanged := len(secretAPIServerURL) == 0 && cfg.APIServerURL != cluster.Status.APIServerURL
+	hasCAChanged := len(secretAPIServerCA) == 0 && hashStatusField(cfg.APIServerCA) != cluster.Status.APIServerCAHash
+
+	return hasURLChanged || hasCAChanged, nil
+}
+
 func hashStatusField(field any) string {
 	hasher := sha256.New224()
 	b, err := json.Marshal(field)
@@ -222,10 +263,8 @@ func (i *importHandler) importCluster(cluster *fleet.Cluster, status fleet.Clust
 		return status, nil
 	}
 
-	kubeConfigSecretNamespace := cluster.Namespace
-	if cluster.Spec.KubeConfigSecretNamespace != "" {
-		kubeConfigSecretNamespace = cluster.Spec.KubeConfigSecretNamespace
-	}
+	kubeConfigSecretNamespace := getKubeConfigSecretNS(*cluster)
+
 	logrus.Debugf("Cluster import for '%s/%s'. Getting kubeconfig from secret in namespace %s", cluster.Namespace, cluster.Name, kubeConfigSecretNamespace)
 
 	secret, err := i.secrets.Get(kubeConfigSecretNamespace, cluster.Spec.KubeConfigSecret)
@@ -307,6 +346,7 @@ func (i *importHandler) importCluster(cluster *fleet.Cluster, status fleet.Clust
 	}
 
 	clusterLabels := yaml.CleanAnnotationsForExport(cluster.Labels)
+	agentReplicas := cmd.ParseEnvAgentReplicaCount()
 
 	// Notice we only set the agentScope when it's a non-default agentNamespace. This is for backwards compatibility
 	// for when we didn't have agent scope before
@@ -332,6 +372,7 @@ func (i *importHandler) importCluster(cluster *fleet.Cluster, status fleet.Clust
 				AgentAffinity:    cluster.Spec.AgentAffinity,
 				AgentResources:   cluster.Spec.AgentResources,
 				HostNetwork:      *cmp.Or(cluster.Spec.HostNetwork, ptr.To(false)),
+				AgentReplicas:    agentReplicas,
 			},
 		})
 	if err != nil {
@@ -440,6 +481,14 @@ func (i *importHandler) restConfigFromKubeConfig(data []byte, agentTLSMode strin
 	return clientcmd.NewDefaultClientConfig(raw, &clientcmd.ConfigOverrides{}).ClientConfig()
 }
 
+func getKubeConfigSecretNS(cluster fleet.Cluster) string {
+	if cluster.Spec.KubeConfigSecretNamespace == "" {
+		return cluster.Namespace
+	}
+
+	return cluster.Spec.KubeConfigSecretNamespace
+}
+
 func hasGarbageCollectionIntervalChanged(config *config.Config, cluster fleet.Cluster) bool {
 	return (config.GarbageCollectionInterval.Duration != 0 && cluster.Status.GarbageCollectionInterval == nil) ||
 		(cluster.Status.GarbageCollectionInterval != nil &&
diff --git a/internal/cmd/controller/agentmanagement/controllers/manageagent/manageagent.go b/internal/cmd/controller/agentmanagement/controllers/manageagent/manageagent.go
index ef3e4070..e091f7a1 100644
--- a/internal/cmd/controller/agentmanagement/controllers/manageagent/manageagent.go
+++ b/internal/cmd/controller/agentmanagement/controllers/manageagent/manageagent.go
@@ -14,6 +14,7 @@ import (
 
 	"github.com/sirupsen/logrus"
 
+	"github.com/rancher/fleet/internal/cmd"
 	"github.com/rancher/fleet/internal/cmd/controller/agentmanagement/agent"
 	"github.com/rancher/fleet/internal/config"
 	"github.com/rancher/fleet/internal/names"
@@ -256,6 +257,8 @@ func (h *handler) newAgentBundle(ns string, cluster *fleet.Cluster) (runtime.Obj
 		agentNamespace = cluster.Spec.AgentNamespace
 	}
 
+	agentReplicas := cmd.ParseEnvAgentReplicaCount()
+
 	// Notice we only set the agentScope when it's a non-default agentNamespace. This is for backwards compatibility
 	// for when we didn't have agent scope before
 	objs := agent.Manifest(
@@ -276,6 +279,7 @@ func (h *handler) newAgentBundle(ns string, cluster *fleet.Cluster) (runtime.Obj
 			SystemDefaultRegistry:   cfg.SystemDefaultRegistry,
 			BundleDeploymentWorkers: cfg.AgentWorkers.BundleDeployment,
 			DriftWorkers:            cfg.AgentWorkers.Drift,
+			AgentReplicas:           agentReplicas,
 		},
 	)
 	agentYAML, err := yaml.Export(objs...)
diff --git a/internal/cmd/controller/gitops/reconciler/gitjob_controller.go b/internal/cmd/controller/gitops/reconciler/gitjob_controller.go
index 12241e8d..f1bbfb1f 100644
--- a/internal/cmd/controller/gitops/reconciler/gitjob_controller.go
+++ b/internal/cmd/controller/gitops/reconciler/gitjob_controller.go
@@ -725,6 +725,7 @@ func (r *GitJobReconciler) newJobSpec(ctx context.Context, gitrepo *v1alpha1.Git
 	}
 
 	drivenScanSeparator := ""
+	var err error
 	if len(gitrepo.Spec.Bundles) > 0 {
 		paths = []string{}
 		// use driven scan instead
@@ -735,7 +736,10 @@ func (r *GitJobReconciler) newJobSpec(ctx context.Context, gitrepo *v1alpha1.Git
 		// directories for the classic fleet scan, but since we need to
 		// pass 2 strings, we will separate them with
 		// the calculated separator.
-		drivenScanSeparator = getDrivenScanSeparator(*gitrepo)
+		drivenScanSeparator, err = getDrivenScanSeparator(*gitrepo)
+		if err != nil {
+			return nil, err
+		}
 		for _, b := range gitrepo.Spec.Bundles {
 			path := b.Base
 			if b.Options != "" {
@@ -1601,50 +1605,30 @@ func gitSSHCommandEnvVar(strictChecks bool) corev1.EnvVar {
 // definitions in the given GitRepo.
 // Since we cannot disregard the possibility that a user might have an
 // unavoidable need to use the character ":" (or another character typically not
-// used in directory or file paths), we need to find a separator that works for any
-// possible combination.
-// The function will first search for simple characters from those in
-// bundleOptionsSeparatorChars, and if none of them can be used, it will create
-// separators consisting of random strings with incremental length until it finds one
-// that meets our requirements.
-func getDrivenScanSeparator(gitrepo v1alpha1.GitRepo) string {
+// used in directory or file paths), we need to find possible alternatives.
+// The function will search for simple characters from those in
+// bundleOptionsSeparatorChars, and if none of them can be used, it will return an error.
+func getDrivenScanSeparator(gitrepo v1alpha1.GitRepo) (string, error) {
 	for _, sep := range bundleOptionsSeparatorChars {
-		if !separatorInBundleDefinitions(gitrepo, string(sep)) {
+		if !separatorInBundleDefinitions(gitrepo, sep) {
 			// We can safely use this separator
-			return string(sep)
+			return string(sep), nil
 		}
 	}
-	// we need to create a string separator because all the bytes in bundleOptionsSeparatorChars are
-	// used in the bundle definitions paths.
-	// Get a random combination of increasing length until it is not found in the bundles definitions.
-	length := 2
-	sep := getRandomStringSeparator(length)
-	for separatorInBundleDefinitions(gitrepo, sep) {
-		length++
-		sep = getRandomStringSeparator(length)
-	}
 
-	return sep
+	return "", fmt.Errorf("bundle base and/or options paths contain all possible characters from %q, please update those paths to remedy this", bundleOptionsSeparatorChars)
 }
 
-func separatorInBundleDefinitions(gitrepo v1alpha1.GitRepo, sep string) bool {
+func separatorInBundleDefinitions(gitrepo v1alpha1.GitRepo, sep rune) bool {
 	for _, b := range gitrepo.Spec.Bundles {
-		if strings.Contains(b.Options, sep) {
+		if strings.ContainsRune(b.Options, sep) {
 			return true
 		}
 
-		if strings.Contains(b.Base, sep) {
+		if strings.ContainsRune(b.Base, sep) {
 			return true
 		}
 	}
 
 	return false
 }
-
-func getRandomStringSeparator(length int) string {
-	b := make([]byte, length)
-	for i := range b {
-		b[i] = bundleOptionsSeparatorChars[rand.IntN(len(bundleOptionsSeparatorChars))]
-	}
-	return string(b)
-}
diff --git a/internal/cmd/options.go b/internal/cmd/options.go
index 3b94169f..01f5d78c 100644
--- a/internal/cmd/options.go
+++ b/internal/cmd/options.go
@@ -3,7 +3,10 @@ package cmd
 import (
 	"fmt"
 	"os"
+	"strconv"
 	"time"
+
+	"github.com/sirupsen/logrus"
 )
 
 type LeaderElectionOptions struct {
@@ -48,3 +51,27 @@ func NewLeaderElectionOptions() (LeaderElectionOptions, error) {
 	}
 	return leaderOpts, nil
 }
+
+// ParseEnvAgentReplicaCount parses the environment variable FLEET_AGENT_REPLICA_COUNT. If the
+// environment variable is not set or the value cannot be parsed, it will return 1.
+func ParseEnvAgentReplicaCount() int32 {
+	replicas, err := parseEnvInt32("FLEET_AGENT_REPLICA_COUNT")
+	if err != nil {
+		logrus.Warn("FLEET_AGENT_REPLICA_COUNT not set, defaulting to 1")
+		return 1
+	}
+	return replicas
+}
+
+// parseEnvInt32 parses an environment variable. It returns an error if the environment variable is
+// not set or if it cannot be parsed as an int32.
+func parseEnvInt32(envVar string) (int32, error) {
+	if d, ok := os.LookupEnv(envVar); ok {
+		v, err := strconv.ParseInt(d, 10, 32)
+		if err != nil {
+			return 0, fmt.Errorf("failed to parse %s with int32 %s: %w", envVar, d, err)
+		}
+		return int32(v), nil
+	}
+	return 0, fmt.Errorf("environment variable %s not set", envVar)
+}


Test patch:
diff --git a/.github/scripts/setup-latest-rancher.sh b/.github/scripts/setup-latest-rancher.sh
deleted file mode 100755
index 4169fbce..00000000
--- a/.github/scripts/setup-latest-rancher.sh
+++ /dev/null
@@ -1,41 +0,0 @@
-#!/bin/bash
-
-set -euxo pipefail
-
-public_hostname="${public_hostname-172.18.0.1.sslip.io}"
-cluster="${cluster-k3d-upstream}"
-
-kubectl config use-context "$cluster"
-kubectl apply -f https://github.com/jetstack/cert-manager/releases/download/v1.5.4/cert-manager.yaml
-kubectl wait --for=condition=Available deployment --timeout=2m -n cert-manager --all
-
-helm repo add rancher-latest https://releases.rancher.com/server-charts/latest
-# set CATTLE_SERVER_URL and CATTLE_BOOTSTRAP_PASSWORD to get rancher out of "bootstrap" mode
-helm upgrade rancher rancher-latest/rancher \
-  --install --wait \
-  --create-namespace \
-  --namespace cattle-system \
-  --set replicas=1 \
-  --set hostname="$public_hostname" \
-  --set bootstrapPassword=admin \
-  --set "extraEnv[0].name=CATTLE_SERVER_URL" \
-  --set "extraEnv[0].value=https://$public_hostname" \
-  --set "extraEnv[1].name=CATTLE_BOOTSTRAP_PASSWORD" \
-  --set "extraEnv[1].value=rancherpassword"
-
-# wait for deployment of rancher
-kubectl -n cattle-system rollout status deploy/rancher
-
-# wait for rancher to create fleet namespace, deployment and controller
-{ grep -q -m 1 "fleet"; kill $!; } < <(kubectl get deployments -n cattle-fleet-system -w)
-kubectl -n cattle-fleet-system rollout status deploy/fleet-controller
-until kubectl get bundles -n fleet-local | grep -q fleet-agent-local; do
-  kubectl get bundles -n fleet-local || true
-  sleep 3
-done
-until kubectl get bundles -n fleet-local fleet-agent-local -o=jsonpath='{.status.conditions[?(@.type=="Ready")].status}' | grep -q "True"; do
-  kubectl logs -l app=fleet-agent -n cattle-fleet-local-system || true
-  sleep 3
-done
-
-helm list -A
diff --git a/.github/workflows/e2e-rancher-upgrade-fleet-to-head-ci.yml b/.github/workflows/e2e-rancher-upgrade-fleet-to-head-ci.yml
index 9ee99778..7ebf9fb9 100644
--- a/.github/workflows/e2e-rancher-upgrade-fleet-to-head-ci.yml
+++ b/.github/workflows/e2e-rancher-upgrade-fleet-to-head-ci.yml
@@ -134,22 +134,14 @@ jobs:
             --network "nw01" \
             --image docker.io/rancher/k3s:${{ env.SETUP_K3S_VERSION }}
       -
-        name: Set up latest Rancher
-        env:
-          public_hostname: "172.18.0.1.sslip.io"
-        run: |
-          ./.github/scripts/setup-latest-rancher.sh
-      -
-        name: Register Rancher's downstream clusters
+        name: Set up Rancher
         env:
           public_hostname: "172.18.0.1.sslip.io"
         run: |
+          ./.github/scripts/setup-rancher.sh
           ./.github/scripts/wait-for-loadbalancer.sh
           ./.github/scripts/register-downstream-clusters.sh
-
-          # wait for cluster to settle
           sleep 30
-
           ./.github/scripts/label-downstream-cluster.sh
       -
         name: Create example workload
diff --git a/.github/workflows/e2e-rancher-upgrade-fleet.yml b/.github/workflows/e2e-rancher-upgrade-fleet.yml
index 179e2803..7646c92d 100644
--- a/.github/workflows/e2e-rancher-upgrade-fleet.yml
+++ b/.github/workflows/e2e-rancher-upgrade-fleet.yml
@@ -107,11 +107,11 @@ jobs:
         name: Set up Rancher
         env:
           public_hostname: "172.18.0.1.sslip.io"
-          fleetns: "cattle-fleet-system"
         run: |
           ./.github/scripts/setup-rancher.sh "${{github.event.inputs.rancher_version}}"
           ./.github/scripts/wait-for-loadbalancer.sh
           ./.github/scripts/register-downstream-clusters.sh
+          sleep 30
           ./.github/scripts/label-downstream-cluster.sh
       -
         name: Create example workload
diff --git a/charts/fleet/tests/agent_replica_count_test.yaml b/charts/fleet/tests/agent_replica_count_test.yaml
new file mode 100644
index 00000000..ffbfcfc8
--- /dev/null
+++ b/charts/fleet/tests/agent_replica_count_test.yaml
@@ -0,0 +1,13 @@
+suite: replica values test for fleet agent
+templates:
+  - deployment.yaml
+tests:
+  - it: should set the environment variable to the number of replicas
+    set:
+      agent.replicas: 3
+    asserts:
+      - isKind:
+          of: Deployment
+      - equal:
+          path: spec.template.spec.containers[?(@.name == 'fleet-agentmanagement')].env[?(@.name == 'FLEET_AGENT_REPLICA_COUNT')].value
+          value: "3"
diff --git a/charts/fleet/tests/fleet_controller_replica_count_test.yaml b/charts/fleet/tests/fleet_controller_replica_count_test.yaml
new file mode 100644
index 00000000..7e359845
--- /dev/null
+++ b/charts/fleet/tests/fleet_controller_replica_count_test.yaml
@@ -0,0 +1,13 @@
+suite: replica values test for fleet controller
+templates:
+  - deployment.yaml
+tests:
+  - it: should set spec.replicas to the number of replicas specified in the controller's replicas field
+    set:
+      controller.replicas: 3
+    asserts:
+      - isKind:
+          of: Deployment
+      - equal:
+          path: spec.replicas
+          value: 3
diff --git a/charts/fleet/tests/gitjob_controller_replica_count_test.yaml b/charts/fleet/tests/gitjob_controller_replica_count_test.yaml
new file mode 100644
index 00000000..99cde8e8
--- /dev/null
+++ b/charts/fleet/tests/gitjob_controller_replica_count_test.yaml
@@ -0,0 +1,13 @@
+suite: replica values test for fleet controller
+templates:
+  - deployment_gitjob.yaml
+tests:
+  - it: should set spec.replicas to the number of replicas specified in the controller's replicas field
+    set:
+      gitjob.replicas: 3
+    asserts:
+      - isKind:
+          of: Deployment
+      - equal:
+          path: spec.replicas
+          value: 3
diff --git a/charts/fleet/tests/helmops_controller_replica_count_test.yaml b/charts/fleet/tests/helmops_controller_replica_count_test.yaml
new file mode 100644
index 00000000..8ecb95ac
--- /dev/null
+++ b/charts/fleet/tests/helmops_controller_replica_count_test.yaml
@@ -0,0 +1,26 @@
+suite: replica values test for fleet controller
+templates:
+  - deployment_helmops.yaml
+tests:
+  - it: should set spec.replicas to the number of replicas specified in the controller's replicas field
+    set:
+      helmops.replicas: 3
+      extraEnv:
+        - name: 'EXPERIMENTAL_HELM_OPS'
+          value: 'true'
+    asserts:
+      - isKind:
+          of: Deployment
+      - equal:
+          path: spec.replicas
+          value: 3
+
+  - it: should not render a document at all when 'EXPERIMENTAL_HELM_OPS' is false
+    set:
+      helmops.replicas: 3
+      extraEnv:
+        - name: 'EXPERIMENTAL_HELM_OPS'
+          value: 'false'
+    asserts:
+      - hasDocuments:
+          count: 0
diff --git a/dev/run-integration-tests.sh b/dev/run-integration-tests.sh
deleted file mode 100755
index 7f4f1eba..00000000
--- a/dev/run-integration-tests.sh
+++ /dev/null
@@ -1,17 +0,0 @@
-#!/bin/bash
-
-set -euxo pipefail
-
-SETUP_ENVTEST_VER=${SETUP_ENVTEST_VER-v0.0.0-20240115093953-9e6e3b144a69}
-ENVTEST_K8S_VERSION=${ENVTEST_K8S_VERSION-1.28}
-
-# install and prepare setup-envtest
-if ! command -v setup-envtest &> /dev/null
-then
-    go install sigs.k8s.io/controller-runtime/tools/setup-envtest@"$SETUP_ENVTEST_VER"
-fi
-KUBEBUILDER_ASSETS=$(setup-envtest use --use-env -p path "$ENVTEST_K8S_VERSION")
-export KUBEBUILDER_ASSETS
-
-# run integration tests
-go test ./integrationtests/...
diff --git a/internal/cmd/controller/agentmanagement/controllers/cluster/import_test.go b/internal/cmd/controller/agentmanagement/controllers/cluster/import_test.go
new file mode 100644
index 00000000..f119757d
--- /dev/null
+++ b/internal/cmd/controller/agentmanagement/controllers/cluster/import_test.go
@@ -0,0 +1,323 @@
+package cluster
+
+import (
+	"testing"
+	"time"
+
+	"github.com/rancher/wrangler/v3/pkg/generic/fake"
+	"go.uber.org/mock/gomock"
+
+	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+	"github.com/rancher/fleet/internal/config"
+	fleet "github.com/rancher/fleet/pkg/apis/fleet.cattle.io/v1alpha1"
+)
+
+func TestOnConfig(t *testing.T) {
+	cases := map[string]struct {
+		cfg              config.Config
+		handlerWithMocks func() importHandler
+	}{
+		"no clusters, no import": {
+			cfg: config.Config{
+				APIServerCA:  []byte("foo"),
+				APIServerURL: "https://hello.world",
+				AgentTLSMode: "system-store",
+			},
+			handlerWithMocks: func() importHandler {
+				ctrl := gomock.NewController(t)
+
+				secretsCache := fake.NewMockCacheInterface[*corev1.Secret](ctrl)
+
+				clustersController := fake.NewMockControllerInterface[*fleet.Cluster, *fleet.ClusterList](ctrl)
+				clustersController.EXPECT().List("", metav1.ListOptions{}).Return(&fleet.ClusterList{}, nil)
+
+				return importHandler{
+					clusters: clustersController,
+					secrets:  secretsCache,
+				}
+			},
+		},
+		"no URL or CA in secret, do not trigger import when URL changes": {
+			cfg: config.Config{
+				APIServerCA:               []byte("foo"),
+				APIServerURL:              "https://hello.new.world",
+				AgentTLSMode:              "system-store",
+				GarbageCollectionInterval: metav1.Duration{Duration: 10 * time.Minute},
+			},
+			handlerWithMocks: func() importHandler {
+				ctrl := gomock.NewController(t)
+
+				secretsCache := fake.NewMockCacheInterface[*corev1.Secret](ctrl)
+				secretsCache.EXPECT().Get(gomock.Any(), "my-kubeconfig-secret").Return(&corev1.Secret{}, nil)
+
+				clustersController := fake.NewMockControllerInterface[*fleet.Cluster, *fleet.ClusterList](ctrl)
+				clustersController.EXPECT().List("", metav1.ListOptions{}).
+					Return(&fleet.ClusterList{
+						Items: []fleet.Cluster{
+							{
+								ObjectMeta: metav1.ObjectMeta{
+									Name:      "cluster",
+									Namespace: "fleet-default",
+								},
+								Spec: fleet.ClusterSpec{
+									KubeConfigSecret: "my-kubeconfig-secret",
+								},
+								Status: fleet.ClusterStatus{
+									APIServerURL:              "https://hello.world",
+									APIServerCAHash:           hashStatusField("foo"),
+									AgentTLSMode:              "system-store",
+									GarbageCollectionInterval: &metav1.Duration{Duration: 10 * time.Minute},
+								},
+							},
+						},
+					}, nil)
+
+				clustersController.EXPECT().UpdateStatus(gomock.Any()) // import triggered
+
+				return importHandler{
+					clusters: clustersController,
+					secrets:  secretsCache,
+				}
+			},
+		},
+		"no URL or CA in secret, do not trigger import when CA changes": {
+			cfg: config.Config{
+				APIServerCA:               []byte("new-foo"),
+				APIServerURL:              "https://hello.world",
+				AgentTLSMode:              "system-store",
+				GarbageCollectionInterval: metav1.Duration{Duration: 10 * time.Minute},
+			},
+			handlerWithMocks: func() importHandler {
+				ctrl := gomock.NewController(t)
+
+				secretsCache := fake.NewMockCacheInterface[*corev1.Secret](ctrl)
+				secretsCache.EXPECT().Get(gomock.Any(), "my-kubeconfig-secret").Return(&corev1.Secret{}, nil)
+
+				clustersController := fake.NewMockControllerInterface[*fleet.Cluster, *fleet.ClusterList](ctrl)
+				clustersController.EXPECT().List("", metav1.ListOptions{}).
+					Return(&fleet.ClusterList{
+						Items: []fleet.Cluster{
+							{
+								ObjectMeta: metav1.ObjectMeta{
+									Name:      "cluster",
+									Namespace: "fleet-default",
+								},
+								Spec: fleet.ClusterSpec{
+									KubeConfigSecret: "my-kubeconfig-secret",
+								},
+								Status: fleet.ClusterStatus{
+									APIServerURL:              "https://hello.world",
+									APIServerCAHash:           hashStatusField("foo"),
+									AgentTLSMode:              "system-store",
+									GarbageCollectionInterval: &metav1.Duration{Duration: 10 * time.Minute},
+								},
+							},
+						},
+					}, nil)
+
+				clustersController.EXPECT().UpdateStatus(gomock.Any()) // import triggered
+
+				return importHandler{
+					clusters: clustersController,
+					secrets:  secretsCache,
+				}
+			},
+		},
+		"no URL or CA in secret, trigger import when agent TLS mode changes": {
+			cfg: config.Config{
+				APIServerCA:               []byte("foo"),
+				APIServerURL:              "https://hello.world",
+				AgentTLSMode:              "strict",
+				GarbageCollectionInterval: metav1.Duration{Duration: 10 * time.Minute},
+			},
+			handlerWithMocks: func() importHandler {
+				ctrl := gomock.NewController(t)
+
+				secretsCache := fake.NewMockCacheInterface[*corev1.Secret](ctrl)
+				secretsCache.EXPECT().Get(gomock.Any(), "my-kubeconfig-secret").Return(&corev1.Secret{}, nil)
+
+				clustersController := fake.NewMockControllerInterface[*fleet.Cluster, *fleet.ClusterList](ctrl)
+				clustersController.EXPECT().List("", metav1.ListOptions{}).
+					Return(&fleet.ClusterList{
+						Items: []fleet.Cluster{
+							{
+								ObjectMeta: metav1.ObjectMeta{
+									Name:      "cluster",
+									Namespace: "fleet-default",
+								},
+								Spec: fleet.ClusterSpec{
+									KubeConfigSecret: "my-kubeconfig-secret",
+								},
+								Status: fleet.ClusterStatus{
+									APIServerURL:              "https://hello.world",
+									APIServerCAHash:           hashStatusField("foo"),
+									AgentTLSMode:              "system-store",
+									GarbageCollectionInterval: &metav1.Duration{Duration: 10 * time.Minute},
+								},
+							},
+						},
+					}, nil)
+
+				clustersController.EXPECT().UpdateStatus(gomock.Any()) // import triggered
+
+				return importHandler{
+					clusters: clustersController,
+					secrets:  secretsCache,
+				}
+			},
+		},
+		"no URL or CA in secret, trigger import when agent garbage collection interval changes": {
+			cfg: config.Config{
+				APIServerCA:               []byte("foo"),
+				APIServerURL:              "https://hello.world",
+				AgentTLSMode:              "system-store",
+				GarbageCollectionInterval: metav1.Duration{Duration: 5 * time.Minute},
+			},
+			handlerWithMocks: func() importHandler {
+				ctrl := gomock.NewController(t)
+
+				secretsCache := fake.NewMockCacheInterface[*corev1.Secret](ctrl)
+				secretsCache.EXPECT().Get(gomock.Any(), "my-kubeconfig-secret").Return(&corev1.Secret{}, nil)
+
+				clustersController := fake.NewMockControllerInterface[*fleet.Cluster, *fleet.ClusterList](ctrl)
+				clustersController.EXPECT().List("", metav1.ListOptions{}).
+					Return(&fleet.ClusterList{
+						Items: []fleet.Cluster{
+							{
+								ObjectMeta: metav1.ObjectMeta{
+									Name:      "cluster",
+									Namespace: "fleet-default",
+								},
+								Spec: fleet.ClusterSpec{
+									KubeConfigSecret: "my-kubeconfig-secret",
+								},
+								Status: fleet.ClusterStatus{
+									APIServerURL:              "https://hello.world",
+									APIServerCAHash:           hashStatusField("foo"),
+									AgentTLSMode:              "system-store",
+									GarbageCollectionInterval: &metav1.Duration{Duration: 10 * time.Minute},
+								},
+							},
+						},
+					}, nil)
+
+				clustersController.EXPECT().UpdateStatus(gomock.Any()) // import triggered
+
+				return importHandler{
+					clusters: clustersController,
+					secrets:  secretsCache,
+				}
+			},
+		},
+		"URL and CA in secret, do not trigger import when only URL or CA changes": {
+			cfg: config.Config{
+				APIServerCA:               []byte("new-foo"),
+				APIServerURL:              "https://hello.new.world",
+				AgentTLSMode:              "system-store",
+				GarbageCollectionInterval: metav1.Duration{Duration: 10 * time.Minute},
+			},
+			handlerWithMocks: func() importHandler {
+				ctrl := gomock.NewController(t)
+
+				clustersController := fake.NewMockControllerInterface[*fleet.Cluster, *fleet.ClusterList](ctrl)
+				clustersController.EXPECT().List("", metav1.ListOptions{}).
+					Return(&fleet.ClusterList{
+						Items: []fleet.Cluster{
+							{
+								ObjectMeta: metav1.ObjectMeta{
+									Name:      "cluster",
+									Namespace: "fleet-default",
+								},
+								Spec: fleet.ClusterSpec{
+									KubeConfigSecret: "my-kubeconfig-secret",
+								},
+								Status: fleet.ClusterStatus{
+									APIServerURL:              "https://hello.secret.world",
+									APIServerCAHash:           hashStatusField("secret-foo"),
+									AgentTLSMode:              "system-store",
+									GarbageCollectionInterval: &metav1.Duration{Duration: 10 * time.Minute},
+								},
+							},
+						},
+					}, nil)
+
+				secretsCache := fake.NewMockCacheInterface[*corev1.Secret](ctrl)
+				secretsCache.EXPECT().Get(gomock.Any(), "my-kubeconfig-secret").Return(&corev1.Secret{
+					Data: map[string][]byte{
+						"apiServerURL": []byte("https://hello.secret.world"),
+						"apiServerCA":  []byte(hashStatusField("secret-foo")),
+					},
+				}, nil)
+
+				// No UpdateStatus expected
+
+				return importHandler{
+					clusters: clustersController,
+					secrets:  secretsCache,
+				}
+			},
+		},
+		"URL and CA in secret, trigger import when agent TLS mode changes": {
+			cfg: config.Config{
+				APIServerCA:               []byte("foo"),
+				APIServerURL:              "https://hello.world",
+				AgentTLSMode:              "strict",
+				GarbageCollectionInterval: metav1.Duration{Duration: 10 * time.Minute},
+			},
+			handlerWithMocks: func() importHandler {
+				ctrl := gomock.NewController(t)
+
+				clustersController := fake.NewMockControllerInterface[*fleet.Cluster, *fleet.ClusterList](ctrl)
+				clustersController.EXPECT().List("", metav1.ListOptions{}).
+					Return(&fleet.ClusterList{
+						Items: []fleet.Cluster{
+							{
+								ObjectMeta: metav1.ObjectMeta{
+									Name:      "cluster",
+									Namespace: "fleet-default",
+								},
+								Spec: fleet.ClusterSpec{
+									KubeConfigSecret: "my-kubeconfig-secret",
+								},
+								Status: fleet.ClusterStatus{
+									APIServerURL:              "https://hello.secret.world",
+									APIServerCAHash:           hashStatusField("secret-foo"),
+									AgentTLSMode:              "system-store",
+									GarbageCollectionInterval: &metav1.Duration{Duration: 10 * time.Minute},
+								},
+							},
+						},
+					}, nil)
+
+				secretsCache := fake.NewMockCacheInterface[*corev1.Secret](ctrl)
+				secretsCache.EXPECT().Get(gomock.Any(), "my-kubeconfig-secret").Return(&corev1.Secret{
+					Data: map[string][]byte{
+						"apiServerURL": []byte("https://hello.secret.world"),
+						"apiServerCA":  []byte(hashStatusField("secret-foo")),
+					},
+				}, nil)
+
+				clustersController.EXPECT().UpdateStatus(gomock.Any()) // import triggered
+
+				return importHandler{
+					clusters: clustersController,
+					secrets:  secretsCache,
+				}
+			},
+		},
+	}
+
+	for name, c := range cases {
+		t.Run(name, func(t *testing.T) {
+			ih := c.handlerWithMocks()
+
+			err := ih.onConfig(&c.cfg)
+			if err != nil {
+				t.Errorf("unexpected error: expected nil, got %v", err)
+			}
+
+		})
+	}
+}
diff --git a/internal/cmd/controller/gitops/reconciler/gitjob_test.go b/internal/cmd/controller/gitops/reconciler/gitjob_test.go
index 1253933c..40c5b2a2 100644
--- a/internal/cmd/controller/gitops/reconciler/gitjob_test.go
+++ b/internal/cmd/controller/gitops/reconciler/gitjob_test.go
@@ -8,7 +8,6 @@ import (
 	"fmt"
 	"os"
 	"slices"
-	"strings"
 	"testing"
 	"time"
 
@@ -2314,10 +2313,9 @@ func TestGitClonerSSH(t *testing.T) {
 
 func TestDrivenScanSeparator(t *testing.T) {
 	tests := map[string]struct {
-		bundles                    []fleetv1.BundlePath
-		expectedRandomResult       bool
-		expectedResult             string
-		expectedResultRandomLength int
+		bundles        []fleetv1.BundlePath
+		expectError    bool
+		expectedResult string
 	}{
 		"Bundle definitions have no separator character": {
 			bundles: []fleetv1.BundlePath{
@@ -2330,8 +2328,8 @@ func TestDrivenScanSeparator(t *testing.T) {
 					Options: "options2.yaml",
 				},
 			},
-			expectedRandomResult: false,
-			expectedResult:       ":",
+			expectError:    false,
+			expectedResult: ":",
 		},
 		"Bundle definitions have : separator character": {
 			bundles: []fleetv1.BundlePath{
@@ -2344,8 +2342,8 @@ func TestDrivenScanSeparator(t *testing.T) {
 					Options: "options2.yaml",
 				},
 			},
-			expectedRandomResult: false,
-			expectedResult:       ",",
+			expectError:    false,
+			expectedResult: ",",
 		},
 		"Bundle definitions have : and , separator characters": {
 			bundles: []fleetv1.BundlePath{
@@ -2358,8 +2356,8 @@ func TestDrivenScanSeparator(t *testing.T) {
 					Options: "options2.yaml",
 				},
 			},
-			expectedRandomResult: false,
-			expectedResult:       "|",
+			expectError:    false,
+			expectedResult: "|",
 		},
 		"Bundle definitions have : , and | separator characters": {
 			bundles: []fleetv1.BundlePath{
@@ -2372,8 +2370,8 @@ func TestDrivenScanSeparator(t *testing.T) {
 					Options: "options2|.yaml",
 				},
 			},
-			expectedRandomResult: false,
-			expectedResult:       "?",
+			expectError:    false,
+			expectedResult: "?",
 		},
 		"Bundle definitions have : ,  | and ? separator characters": {
 			bundles: []fleetv1.BundlePath{
@@ -2386,8 +2384,8 @@ func TestDrivenScanSeparator(t *testing.T) {
 					Options: "options2|.yaml",
 				},
 			},
-			expectedRandomResult: false,
-			expectedResult:       "<",
+			expectError:    false,
+			expectedResult: "<",
 		},
 		"Bundle definitions have : ,  |  ? and < separator characters": {
 			bundles: []fleetv1.BundlePath{
@@ -2400,10 +2398,10 @@ func TestDrivenScanSeparator(t *testing.T) {
 					Options: "options2|.yaml",
 				},
 			},
-			expectedRandomResult: false,
-			expectedResult:       ">",
+			expectError:    false,
+			expectedResult: ">",
 		},
-		"Bundle definitions all separator characters": {
+		"Bundle definitions have all separator characters": {
 			bundles: []fleetv1.BundlePath{
 				{
 					Base:    "test?one:two",
@@ -2414,9 +2412,8 @@ func TestDrivenScanSeparator(t *testing.T) {
 					Options: "options2|.yaml",
 				},
 			},
-			expectedRandomResult:       true,
-			expectedResult:             "",
-			expectedResultRandomLength: 2,
+			expectError:    true,
+			expectedResult: "",
 		},
 	}
 	for name, test := range tests {
@@ -2426,61 +2423,21 @@ func TestDrivenScanSeparator(t *testing.T) {
 					Bundles: test.bundles,
 				},
 			}
-			separator := getDrivenScanSeparator(*gitrepo)
-			if !test.expectedRandomResult {
+			separator, err := getDrivenScanSeparator(*gitrepo)
+			if !test.expectError {
+				if err != nil {
+					t.Errorf("unexpected error: %v", err)
+				}
 				if test.expectedResult != separator {
 					t.Errorf("expecting separator to be: %q, got: %q", test.expectedResult, separator)
 				}
 			} else {
-				// Verify that the string has the desired length and
-				// is composed only of the specified characters.
-				if len(separator) != test.expectedResultRandomLength {
-					t.Errorf("expecting separator length to be: %d, got: %d", test.expectedResultRandomLength, len(separator))
-				}
-				for _, char := range separator {
-					if !strings.ContainsRune(bundleOptionsSeparatorChars, char) {
-						t.Errorf("expecting separator to only contain characters from the set: %q, got: %q", bundleOptionsSeparatorChars, separator)
-					}
-				}
-				for _, b := range gitrepo.Spec.Bundles {
-					// use the separator and verify we get the expected items
-					concatStr := b.Base + separator + b.Options
-					items := strings.Split(concatStr, separator)
-					if len(items) != 2 {
-						t.Errorf("expecting to have exactly 2 items, got: %d", len(items))
-					}
-					if items[0] != b.Base {
-						t.Errorf("expecting first item to be: %q, got: %q", b.Base, items[0])
-					}
-					if items[1] != b.Options {
-						t.Errorf("expecting second item to be: %q, got: %q", b.Options, items[1])
-					}
-				}
-				// Take it a step further and use the random separator in the bundle definitions.
-				// This will verify that the function returns a longer separator.
-				gitrepo = &fleetv1.GitRepo{
-					Spec: fleetv1.GitRepoSpec{
-						Bundles: []fleetv1.BundlePath{
-							{
-								Base:    fmt.Sprintf("test?one:%stwo", separator),
-								Options: "options.yaml",
-							},
-							{
-								Base:    "test,one<>",
-								Options: "options2|.yaml",
-							},
-						},
-					},
+				expectedErrorMessage := fmt.Sprintf("bundle base and/or options paths contain all possible characters from %q, please update those paths to remedy this", bundleOptionsSeparatorChars)
+				if err == nil {
+					t.Errorf("expecting error, got none")
 				}
-				// Finally, verify that the new separator is also valid.
-				newSeparator := getDrivenScanSeparator(*gitrepo)
-				if len(newSeparator) < len(separator) {
-					t.Errorf("expecting separator length to be greater or equal to %d, got: %d", len(separator), len(newSeparator))
-				}
-				for _, char := range newSeparator {
-					if !strings.ContainsRune(bundleOptionsSeparatorChars, char) {
-						t.Errorf("expecting separator to only contain characters from the set: %q, got: %q", bundleOptionsSeparatorChars, separator)
-					}
+				if err.Error() != expectedErrorMessage {
+					t.Errorf("expecting error %q, got %q", expectedErrorMessage, err.Error())
 				}
 			}
 		})