Repository: rancher/fleet

Issue:
Speed-up fleet-agent deployment in the local cluster once configuration is complete
### Is there an existing issue for this?

- [x] I have searched the existing issues

### Current Behavior

When installing Fleet from Rancher, it will use its `server-url` `Setting` to populate the field `apiServerURL` in Fleet's Configuration (which is used as a fallback to per-cluster configuration that can be specified on each "KubeconfigSecret").
Rancher allows starting without a "server URL" (Helm chart installation would usually pick a predefined value, but Docker installations may omit it), then configuring it on the first login to the UI.

Under this circumstances, it can take a considerable amount of time between the `server-url` is set and Fleet finally continues with the agent registration in the upstream cluster (as reported in https://github.com/rancher/dashboard/issues/14559).

### Expected Behavior

Once the Rancher `server-url` setting is configured, Fleet should deploy the `fleet-agent` bundle for the local cluster within a few seconds.

### Steps To Reproduce

1. Run latest Rancher 2.12-alpha version with docker:
```
docker run -d --name=rancher --restart=unless-stopped -p 9090:80 -p 9443:443 -p 6443:6443 --privileged rancher/rancher:v2.12.0-alpha12
```
2. **Do not** enter the Rancher UI yet. 
3. Get a kubeconfig to the embedded k3s running within the Rancher container:
```
docker exec -ti rancher-bad kubectl -n cattle-fleet-system config view --raw > kubeconfig-docker-rancher.yaml
export KUBECONFIG=kubeconfig-docker-rancher.yaml
```
3. Watch Fleet cluster status (it will stay in `WaitApplied` state):
```
kubectl get -A -w clusters.fleet
```
4. Watch Fleet's `agentmanagement` controller logs (it will complain about not being able to import the cluster, but will retry):
```
kubectl -n cattle-fleet-system logs deploy/fleet-controller -c fleet-agentmanagement -f
```
5. Finally enter the Rancher UI at `https://localhost:9443`. When asked for a server URL, use `https://host.docker.internal:9443`, or the IP of the Docker container. It needs to be reachable to the pods inside k3s in order to contact Rancher.
6. The `agentmanagement` logs will detect the config change and mention the cluster import will be scheduled, however it may take **several minutes** until it really happens.
7. After some minutes, a new `fleet-agent` Deployment will be created within the `cattle-fleet-local-system` will be creted, and the Cluster status should become `Ready`.


### Environment

```markdown
- Architecture: any
- Fleet Version: 0.13.0-alpha
- Cluster: n/a
```

### Logs

```markdown

```

### Anything else?

_No response_

Hints from issue comments:
@aruiz14: # QA Template

## Solution
Avoid retrying to import clusters if there is no valid apiServerURL available neither from the overall or per-cluster configuration.
This was done to continuously re-evaluate in case the KubeConfigSecret was changed.
Instead, I added a detection mechanism for referenced KubeConfigSecrets that will re-evaluate the inputs whenever they Secrets change.

## Testing
### Case 1 (already tested locally)
- Run Rancher with Docker, without setting the `CATTLE_SERVER_URL` env. variable.
- Wait for every component to be up and running, but do not access the UI.
- Watch logs from the `fleet-controller` pod, `agentmanagement` container.
  - A warning should appear there, but won't be reenqued:
```
level=warning msg="cannot import cluster 'fleet-local/local', missing apiServerURL in fleet config for cluster auto registration"
```
- Go to the Rancher UI and complete the first-login bootstrap, including setting a Server URL.
- The `agentmanagement` container logs should show this within a few seconds:
```
level=info msg="API server config changed, trigger cluster import for cluster fleet-local/local"
level=info msg="ClusterRegistrationToken SA does not exist import-token-local-4f2f6f65-0da0-42d2-8c82-cce2aaf501c5"
level=info msg="Waiting for service account token key to be populated for secret fleet-local/import-token-local-4f2f6f65-0da0-42d2-8c82-cce2aaf501c5-token"
```
- A `fleet-agent` Deployment is created in the `cattle-fleet-local-system` namespace.

### Case 2
- Ensure no regressions were added when importing downstream clusters.
@mmartin24: Verified in `v2.12-cba4929a3943a8d25749abb25e66479f25eb35ee-head` with Fleet `107.0.0+up0.13.0-beta.2` deployed via Docker.

---
- Verified again case 1 above-mentioned. One thing to mention is that I did not use `https://localhost:9443`  but `https://MY_IP:9443` Fleet is ready within 2-3 minutes. Adding video and logs of fleet- controller:

https://github.com/user-attachments/assets/96f8ad40-9811-4607-a0b0-414774a711ba

<details closed>
  <summary>Fleet Bundles Ready status</summary>

```  
kubectl get -A -w clusters.fleet
NAMESPACE     NAME    BUNDLES-READY   LAST-SEEN   STATUS
fleet-local   local                               
fleet-local   local                   <no value>   
fleet-local   local   0/0             <no value>   
fleet-local   local   0/0             <no value>   
fleet-local   local   0/0             <no value>   
fleet-local   local   0/0             <no value>   
fleet-local   local   0/0             <no value>   
fleet-local   local   0/0             <no value>   
fleet-local   local   0/0             <no value>   
fleet-local   local   0/0             <no value>   
fleet-local   local   0/0             <no value>   
fleet-local   local   0/0             <no value>   
fleet-local   local   0/0             <no value>   
fleet-local   local   0/0             <no value>   
fleet-local   local   0/1             <no value>   WaitApplied(1) [Bundle fleet-agent-local]
fleet-local   local   0/1             <no value>   WaitApplied(1) [Bundle fleet-agent-local]
fleet-local   local   0/1             <no value>   WaitApplied(1) [Bundle fleet-agent-local]
fleet-local   local   0/1             <no value>   WaitApplied(1) [Bundle fleet-agent-local]
fleet-local   local   0/1             <no value>   WaitApplied(1) [Bundle fleet-agent-local]
fleet-local   local   0/0             <no value>   
fleet-local   local   0/1             <no value>   WaitApplied(1) [Bundle fleet-agent-local]
fleet-local   local   1/1             <no value>   
fleet-local   local   1/1             2025-07-04T07:46:16Z   
fleet-local   local   1/1             2025-07-04T07:46:16Z   
```
</details>

<details closed>
  <summary>Fleet controller logs</summary>

```
kubectl -n cattle-fleet-system logs deploy/fleet-controller -c fleet-agentmanagement -f

I0704 07:43:54.366594       1 leaderelection.go:257] attempting to acquire leader lease cattle-fleet-system/fleet-agentmanagement-lock...
I0704 07:43:54.371083       1 leaderelection.go:271] successfully acquired lease cattle-fleet-system/fleet-agentmanagement-lock
time="2025-07-04T07:43:54Z" level=info msg="Starting fleet.cattle.io/v1alpha1, Kind=ClusterRegistrationToken controller"
time="2025-07-04T07:43:54Z" level=info msg="Starting fleet.cattle.io/v1alpha1, Kind=Cluster controller"
time="2025-07-04T07:43:54Z" level=info msg="Starting fleet.cattle.io/v1alpha1, Kind=ClusterGroup controller"
time="2025-07-04T07:43:54Z" level=info msg="Starting rbac.authorization.k8s.io/v1, Kind=ClusterRole controller"
time="2025-07-04T07:43:54Z" level=info msg="Starting fleet.cattle.io/v1alpha1, Kind=BundleDeployment controller"
time="2025-07-04T07:43:54Z" level=info msg="Starting rbac.authorization.k8s.io/v1, Kind=Role controller"
time="2025-07-04T07:43:54Z" level=info msg="Starting fleet.cattle.io/v1alpha1, Kind=ClusterRegistration controller"
time="2025-07-04T07:43:54Z" level=info msg="Starting /v1, Kind=Secret controller"
time="2025-07-04T07:43:54Z" level=warning msg="cluster fleet-local/local: could not check for config changes" error="missing apiServerURL in fleet config for cluster auto registration"
time="2025-07-04T07:43:54Z" level=info msg="Starting /v1, Kind=ConfigMap controller"
time="2025-07-04T07:43:54Z" level=warning msg="cluster fleet-local/local: could not check for config changes" error="missing apiServerURL in fleet config for cluster auto registration"
time="2025-07-04T07:43:54Z" level=info msg="Starting fleet.cattle.io/v1alpha1, Kind=Bundle controller"
time="2025-07-04T07:43:54Z" level=info msg="Starting fleet.cattle.io/v1alpha1, Kind=GitRepo controller"
time="2025-07-04T07:43:54Z" level=info msg="Starting rbac.authorization.k8s.io/v1, Kind=RoleBinding controller"
time="2025-07-04T07:43:54Z" level=info msg="Starting /v1, Kind=Namespace controller"
time="2025-07-04T07:43:54Z" level=info msg="Starting rbac.authorization.k8s.io/v1, Kind=ClusterRoleBinding controller"
time="2025-07-04T07:43:54Z" level=info msg="Starting /v1, Kind=ServiceAccount controller"
time="2025-07-04T07:43:54Z" level=info msg="Update agent bundle for cluster fleet-local/local"
time="2025-07-04T07:43:54Z" level=info msg="Update agent bundle for cluster fleet-local/local"
time="2025-07-04T07:43:54Z" level=warning msg="cannot import cluster 'fleet-local/local', missing apiServerURL in fleet config for cluster auto registration"
time="2025-07-04T07:43:54Z" level=info msg="Update agent bundle for cluster fleet-local/local"
time="2025-07-04T07:43:54Z" level=warning msg="cannot import cluster 'fleet-local/local', missing apiServerURL in fleet config for cluster auto registration"
time="2025-07-04T07:43:54Z" level=warning msg="cannot import cluster 'fleet-local/local', missing apiServerURL in fleet config for cluster auto registration"
time="2025-07-04T07:43:54Z" level=info msg="Update agent bundle for cluster fleet-local/local"
time="2025-07-04T07:43:54Z" level=warning msg="cannot import cluster 'fleet-local/local', missing apiServerURL in fleet config for cluster auto registration"
time="2025-07-04T07:43:54Z" level=warning msg="cannot import cluster 'fleet-local/local', missing apiServerURL in fleet config for cluster auto registration"
time="2025-07-04T07:43:54Z" level=info msg="Update agent bundle for cluster fleet-local/local"
time="2025-07-04T07:43:54Z" level=warning msg="cannot import cluster 'fleet-local/local', missing apiServerURL in fleet config for cluster auto registration"
time="2025-07-04T07:43:54Z" level=warning msg="cannot import cluster 'fleet-local/local', missing apiServerURL in fleet config for cluster auto registration"
time="2025-07-04T07:43:54Z" level=warning msg="cannot import cluster 'fleet-local/local', missing apiServerURL in fleet config for cluster auto registration"
time="2025-07-04T07:43:54Z" level=warning msg="cannot import cluster 'fleet-local/local', missing apiServerURL in fleet config for cluster auto registration"
time="2025-07-04T07:43:54Z" level=warning msg="cannot import cluster 'fleet-local/local', missing apiServerURL in fleet config for cluster auto registration"
time="2025-07-04T07:43:54Z" level=warning msg="cannot import cluster 'fleet-local/local', missing apiServerURL in fleet config for cluster auto registration"
time="2025-07-04T07:43:54Z" level=warning msg="cannot import cluster 'fleet-local/local', missing apiServerURL in fleet config for cluster auto registration"
time="2025-07-04T07:43:54Z" level=warning msg="cannot import cluster 'fleet-local/local', missing apiServerURL in fleet config for cluster auto registration"
time="2025-07-04T07:43:56Z" level=warning msg="cannot import cluster 'fleet-local/local', missing apiServerURL in fleet config for cluster auto registration"
time="2025-07-04T07:44:04Z" level=warning msg="cluster fleet-local/local: could not check for config changes" error="missing apiServerURL in fleet config for cluster auto registration"
time="2025-07-04T07:45:07Z" level=warning msg="cannot import cluster 'fleet-local/local', missing apiServerURL in fleet config for cluster auto registration"
time="2025-07-04T07:45:45Z" level=info msg="API server config changed, trigger cluster import for cluster fleet-local/local"
time="2025-07-04T07:45:45Z" level=info msg="ClusterRegistrationToken SA does not exist import-token-local-94f7b89f-78e9-4394-b888-275c85f203ef"
time="2025-07-04T07:45:45Z" level=info msg="Waiting for service account token key to be populated for secret fleet-local/import-token-local-94f7b89f-78e9-4394-b888-275c85f203ef-token"
time="2025-07-04T07:45:47Z" level=info msg="Update agent bundle for cluster fleet-local/local"
time="2025-07-04T07:45:47Z" level=info msg="Deleted old agent for cluster (fleet-local/local) in namespace cattle-fleet-local-system"
time="2025-07-04T07:45:48Z" level=info msg="Cluster import for 'fleet-local/local'. Deployed new agent"
time="2025-07-04T07:45:48Z" level=info msg="Update agent bundle for cluster fleet-local/local"
time="2025-07-04T07:45:48Z" level=info msg="Update agent bundle for cluster fleet-local/local"
time="2025-07-04T07:45:57Z" level=info msg="Waiting for service account token key to be populated for secret cluster-fleet-local-local-1a3d67d0a899/request-4zf7q-345c069f-1f90-467c-86b1-4e674a5b85d2-token"
time="2025-07-04T07:45:59Z" level=info msg="Cluster registration request 'fleet-local/request-4zf7q' granted, creating cluster, request service account, registration secret"
 
```
  </details>

- Verified import of generic downstream cluster can be deployed and gitrepo installed:

![Image](https://github.com/user-attachments/assets/7046d8de-9d9c-44f5-a2bd-5994e55bbe54)

Thanks @aruiz14 for the fix and guidance on this issue.


Solution patch:
diff --git a/internal/cmd/controller/agentmanagement/controllers/cluster/import.go b/internal/cmd/controller/agentmanagement/controllers/cluster/import.go
index e323ed47..aa23c02e 100644
--- a/internal/cmd/controller/agentmanagement/controllers/cluster/import.go
+++ b/internal/cmd/controller/agentmanagement/controllers/cluster/import.go
@@ -11,6 +11,7 @@ import (
 	"os"
 
 	"github.com/sirupsen/logrus"
+	"k8s.io/apimachinery/pkg/labels"
 
 	"github.com/rancher/fleet/internal/client"
 	"github.com/rancher/fleet/internal/cmd"
@@ -39,15 +40,23 @@ import (
 	"k8s.io/utils/ptr"
 )
 
+const (
+	// clusterForKubeconfigSecretIndexer indexes Clusters by the key of the kubeconfig secret they reference in their spec
+	clusterForKubeconfigSecretIndexer = "agentmanagement.fleet.cattle.io/cluster-for-kubeconfig"
+)
+
 var (
 	ImportTokenPrefix = "import-token-"
+
+	errUnavailableAPIServerURL = errors.New("missing apiServerURL in fleet config for cluster auto registration")
 )
 
 type importHandler struct {
 	ctx                 context.Context
 	systemNamespace     string
-	secrets             corecontrollers.SecretCache
 	clusters            fleetcontrollers.ClusterController
+	clustersCache       fleetcontrollers.ClusterCache
+	secretsCache        corecontrollers.SecretCache
 	tokens              fleetcontrollers.ClusterRegistrationTokenCache
 	tokenClient         fleetcontrollers.ClusterRegistrationTokenClient
 	bundleClient        fleetcontrollers.BundleClient
@@ -57,7 +66,7 @@ type importHandler struct {
 func RegisterImport(
 	ctx context.Context,
 	systemNamespace string,
-	secrets corecontrollers.SecretCache,
+	secrets corecontrollers.SecretController,
 	clusters fleetcontrollers.ClusterController,
 	tokens fleetcontrollers.ClusterRegistrationTokenController,
 	bundles fleetcontrollers.BundleClient,
@@ -66,8 +75,9 @@ func RegisterImport(
 	h := importHandler{
 		ctx:                 ctx,
 		systemNamespace:     systemNamespace,
-		secrets:             secrets,
 		clusters:            clusters,
+		clustersCache:       clusters.Cache(),
+		secretsCache:        secrets.Cache(),
 		tokens:              tokens.Cache(),
 		tokenClient:         tokens,
 		namespaceController: namespaceController,
@@ -77,6 +87,28 @@ func RegisterImport(
 	clusters.OnChange(ctx, "import-cluster", h.OnChange)
 	fleetcontrollers.RegisterClusterStatusHandler(ctx, clusters, "Imported", "import-cluster", h.importCluster)
 	config.OnChange(ctx, h.onConfig)
+
+	clustersCache := clusters.Cache()
+	clustersCache.AddIndexer(clusterForKubeconfigSecretIndexer, func(cluster *fleet.Cluster) ([]string, error) {
+		if cluster == nil || len(cluster.Spec.KubeConfigSecret) == 0 {
+			return []string{}, nil
+		}
+		secretKey := getKubeConfigSecretNS(cluster) + "/" + cluster.Spec.KubeConfigSecret
+		return []string{secretKey}, nil
+	})
+	secrets.OnChange(ctx, "kubeconfig-secrets-watch", func(key string, secret *corev1.Secret) (*corev1.Secret, error) {
+		clusters, err := clustersCache.GetByIndex(clusterForKubeconfigSecretIndexer, key)
+		if err != nil {
+			return nil, err
+		}
+		cfg := config.Get()
+		for _, cluster := range clusters {
+			if err := h.checkForConfigChange(cfg, cluster, secret); err != nil {
+				logrus.WithError(err).Errorf("cluster %s/%s: could not check for config changes", cluster.Namespace, cluster.Name)
+			}
+		}
+		return nil, nil
+	})
 }
 
 // onConfig triggers clusters which rely on the fallback config in the
@@ -84,40 +116,31 @@ func RegisterImport(
 // and apiServerCA, as they are needed e.g. to update the fleet-agent-bootstrap
 // secret.
 func (i *importHandler) onConfig(cfg *config.Config) error {
-	clusters, err := i.clusters.List("", metav1.ListOptions{})
+	if cfg == nil {
+		return errors.New("config is nil: this should never happen")
+	}
+
+	clusters, err := i.clustersCache.List(metav1.NamespaceAll, labels.Everything())
 	if err != nil {
 		return err
 	}
 
-	if len(clusters.Items) == 0 {
+	if len(clusters) == 0 {
 		return nil
 	}
 
-	if cfg == nil {
-		return errors.New("config is nil: this should never happen")
-	}
-
-	for _, cluster := range clusters.Items {
+	for _, cluster := range clusters {
 		if cluster.Spec.KubeConfigSecret == "" {
 			continue
 		}
 
-		hasConfigChanged, err := i.hasAPIServerConfigChanged(cfg, cluster)
+		secret, err := i.secretsCache.Get(getKubeConfigSecretNS(cluster), cluster.Spec.KubeConfigSecret)
 		if err != nil {
-			return fmt.Errorf("cluster %s/%s: could not check for config changes: %v", cluster.Namespace, cluster.Name, err)
+			return fmt.Errorf("cluster %s/%s: could not check for config changes: %w", 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)
-			c := cluster.DeepCopy()
-			c.Status.AgentConfigChanged = true
-			_, err := i.clusters.UpdateStatus(c)
-			if err != nil {
-				return err
-			}
+		if err := i.checkForConfigChange(cfg, cluster, secret); err != nil {
+			logrus.WithError(err).Warnf("cluster %s/%s: could not check for config changes", cluster.Namespace, cluster.Name)
+			continue
 		}
 	}
 	return nil
@@ -128,23 +151,17 @@ func (i *importHandler) onConfig(cfg *config.Config) error {
 // 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)
-
+func (i *importHandler) hasAPIServerConfigChanged(cfg *config.Config, secret *corev1.Secret, cluster *fleet.Cluster) (bool, error) {
 	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
-		}
-
+	if secret != nil {
 		secretAPIServerURL = secret.Data[config.APIServerURLKey]
 		secretAPIServerCA = secret.Data[config.APIServerCAKey]
 	}
 
+	if len(secretAPIServerURL) == 0 && len(cfg.APIServerURL) == 0 {
+		return false, errUnavailableAPIServerURL
+	}
+
 	// 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.
@@ -257,7 +274,7 @@ func (i *importHandler) deleteOldAgent(cluster *fleet.Cluster, kc kubernetes.Int
 
 // importCluster is triggered for manager initiated deployments and the local agent, It re-deploys the agent on the downstream cluster.
 // Since it re-creates the fleet-agent-bootstrap secret, it will also re-register the agent.
-func (i *importHandler) importCluster(cluster *fleet.Cluster, status fleet.ClusterStatus) (_ fleet.ClusterStatus, err error) {
+func (i *importHandler) importCluster(cluster *fleet.Cluster, status fleet.ClusterStatus) (fleet.ClusterStatus, error) {
 	if manageagent.SkipCluster(cluster) {
 		return status, nil
 	}
@@ -273,11 +290,11 @@ func (i *importHandler) importCluster(cluster *fleet.Cluster, status fleet.Clust
 		return status, nil
 	}
 
-	kubeConfigSecretNamespace := getKubeConfigSecretNS(*cluster)
+	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)
+	secret, err := i.secretsCache.Get(kubeConfigSecretNamespace, cluster.Spec.KubeConfigSecret)
 	if err != nil {
 		return status, err
 	}
@@ -291,7 +308,10 @@ func (i *importHandler) importCluster(cluster *fleet.Cluster, status fleet.Clust
 
 	if apiServerURL == "" {
 		if len(cfg.APIServerURL) == 0 {
-			return status, fmt.Errorf("missing apiServerURL in fleet config for cluster auto registration")
+			// Current config cannot be deployed, so remove the "config changed" mark
+			logrus.Warnf("cannot import cluster '%s/%s', missing apiServerURL in fleet config for cluster auto registration", cluster.Namespace, cluster.Name)
+			status.AgentConfigChanged = false
+			return status, nil
 		}
 		logrus.Debugf("Cluster import for '%s/%s'. Using apiServerURL from fleet-controller config", cluster.Namespace, cluster.Name)
 		apiServerURL = cfg.APIServerURL
@@ -491,7 +511,37 @@ func (i *importHandler) restConfigFromKubeConfig(data []byte, agentTLSMode strin
 	return clientcmd.NewDefaultClientConfig(raw, &clientcmd.ConfigOverrides{}).ClientConfig()
 }
 
-func getKubeConfigSecretNS(cluster fleet.Cluster) string {
+func (i *importHandler) checkForConfigChange(cfg *config.Config, cluster *fleet.Cluster, secret *corev1.Secret) error {
+	// Already marked for attempting to import
+	if cluster.Status.AgentConfigChanged {
+		return nil
+	}
+
+	apiServerConfigChanged, err := i.hasAPIServerConfigChanged(cfg, secret, cluster)
+	if err != nil {
+		if errors.Is(err, errUnavailableAPIServerURL) {
+			// skip the rest of checks
+			logrus.WithError(err).Warnf("cluster %s/%s: could not check for config changes", cluster.Namespace, cluster.Name)
+			return nil
+		}
+		return err
+	}
+	hasConfigChanged := apiServerConfigChanged ||
+		cfg.AgentTLSMode != cluster.Status.AgentTLSMode ||
+		hasGarbageCollectionIntervalChanged(cfg, cluster)
+
+	if !hasConfigChanged {
+		return nil
+	}
+
+	logrus.Infof("API server config changed, trigger cluster import for cluster %s/%s", cluster.Namespace, cluster.Name)
+	c := cluster.DeepCopy()
+	c.Status.AgentConfigChanged = true
+	_, err = i.clusters.UpdateStatus(c)
+	return err
+}
+
+func getKubeConfigSecretNS(cluster *fleet.Cluster) string {
 	if cluster.Spec.KubeConfigSecretNamespace == "" {
 		return cluster.Namespace
 	}
@@ -499,7 +549,7 @@ func getKubeConfigSecretNS(cluster fleet.Cluster) string {
 	return cluster.Spec.KubeConfigSecretNamespace
 }
 
-func hasGarbageCollectionIntervalChanged(config *config.Config, cluster fleet.Cluster) bool {
+func hasGarbageCollectionIntervalChanged(config *config.Config, cluster *fleet.Cluster) bool {
 	return (config.GarbageCollectionInterval.Duration != 0 && cluster.Status.GarbageCollectionInterval == nil) ||
 		(cluster.Status.GarbageCollectionInterval != nil &&
 			config.GarbageCollectionInterval.Duration != cluster.Status.GarbageCollectionInterval.Duration)
diff --git a/internal/cmd/controller/agentmanagement/controllers/controllers.go b/internal/cmd/controller/agentmanagement/controllers/controllers.go
index 18faf200..b930ab57 100644
--- a/internal/cmd/controller/agentmanagement/controllers/controllers.go
+++ b/internal/cmd/controller/agentmanagement/controllers/controllers.go
@@ -102,7 +102,7 @@ func Register(ctx context.Context, appCtx *AppContext, systemNamespace string, d
 
 	cluster.RegisterImport(ctx,
 		systemNamespace,
-		appCtx.Core.Secret().Cache(),
+		appCtx.Core.Secret(),
 		appCtx.Cluster(),
 		appCtx.ClusterRegistrationToken(),
 		appCtx.Bundle(),



Test patch:
diff --git a/internal/cmd/controller/agentmanagement/controllers/cluster/import_test.go b/internal/cmd/controller/agentmanagement/controllers/cluster/import_test.go
index f119757d..82c18aea 100644
--- a/internal/cmd/controller/agentmanagement/controllers/cluster/import_test.go
+++ b/internal/cmd/controller/agentmanagement/controllers/cluster/import_test.go
@@ -6,6 +6,7 @@ import (
 
 	"github.com/rancher/wrangler/v3/pkg/generic/fake"
 	"go.uber.org/mock/gomock"
+	"k8s.io/apimachinery/pkg/labels"
 
 	corev1 "k8s.io/api/core/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -17,7 +18,7 @@ import (
 func TestOnConfig(t *testing.T) {
 	cases := map[string]struct {
 		cfg              config.Config
-		handlerWithMocks func() importHandler
+		handlerWithMocks func(t *testing.T) importHandler
 	}{
 		"no clusters, no import": {
 			cfg: config.Config{
@@ -25,17 +26,17 @@ func TestOnConfig(t *testing.T) {
 				APIServerURL: "https://hello.world",
 				AgentTLSMode: "system-store",
 			},
-			handlerWithMocks: func() importHandler {
+			handlerWithMocks: func(t *testing.T) 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)
+				clustersCache := fake.NewMockCacheInterface[*fleet.Cluster](ctrl)
+				clustersCache.EXPECT().List("", gomock.Eq(labels.Everything())).Return(nil, nil)
 
 				return importHandler{
-					clusters: clustersController,
-					secrets:  secretsCache,
+					clustersCache: clustersCache,
+					secretsCache:  secretsCache,
 				}
 			},
 		},
@@ -46,39 +47,38 @@ func TestOnConfig(t *testing.T) {
 				AgentTLSMode:              "system-store",
 				GarbageCollectionInterval: metav1.Duration{Duration: 10 * time.Minute},
 			},
-			handlerWithMocks: func() importHandler {
+			handlerWithMocks: func(t *testing.T) 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},
-								},
+				clustersCache := fake.NewMockCacheInterface[*fleet.Cluster](ctrl)
+				clustersCache.EXPECT().List("", gomock.Eq(labels.Everything())).
+					Return([]*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 := fake.NewMockControllerInterface[*fleet.Cluster, *fleet.ClusterList](ctrl)
 				clustersController.EXPECT().UpdateStatus(gomock.Any()) // import triggered
 
 				return importHandler{
-					clusters: clustersController,
-					secrets:  secretsCache,
+					clusters:      clustersController,
+					clustersCache: clustersCache,
+					secretsCache:  secretsCache,
 				}
 			},
 		},
@@ -89,39 +89,79 @@ func TestOnConfig(t *testing.T) {
 				AgentTLSMode:              "system-store",
 				GarbageCollectionInterval: metav1.Duration{Duration: 10 * time.Minute},
 			},
-			handlerWithMocks: func() importHandler {
+			handlerWithMocks: func(t *testing.T) 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},
-								},
+				clustersCache := fake.NewMockCacheInterface[*fleet.Cluster](ctrl)
+				clustersCache.EXPECT().List("", gomock.Eq(labels.Everything())).
+					Return([]*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 := fake.NewMockControllerInterface[*fleet.Cluster, *fleet.ClusterList](ctrl)
 				clustersController.EXPECT().UpdateStatus(gomock.Any()) // import triggered
 
 				return importHandler{
-					clusters: clustersController,
-					secrets:  secretsCache,
+					clusters:      clustersController,
+					clustersCache: clustersCache,
+					secretsCache:  secretsCache,
+				}
+			},
+		},
+		"non-ready config and no URL or CA in secret, do not trigger import when CA changes": {
+			cfg: config.Config{
+				APIServerURL:              "",
+				AgentTLSMode:              "strict",
+				GarbageCollectionInterval: metav1.Duration{Duration: 10 * time.Minute},
+			},
+			handlerWithMocks: func(t *testing.T) importHandler {
+				ctrl := gomock.NewController(t)
+
+				secretsCache := fake.NewMockCacheInterface[*corev1.Secret](ctrl)
+				secretsCache.EXPECT().Get(gomock.Any(), "my-kubeconfig-secret").Return(&corev1.Secret{}, nil)
+
+				clustersCache := fake.NewMockCacheInterface[*fleet.Cluster](ctrl)
+				clustersCache.EXPECT().List("", gomock.Eq(labels.Everything())).
+					Return([]*fleet.Cluster{
+						{
+							ObjectMeta: metav1.ObjectMeta{
+								Name:      "cluster",
+								Namespace: "fleet-default",
+							},
+							Spec: fleet.ClusterSpec{
+								KubeConfigSecret: "my-kubeconfig-secret",
+							},
+							Status: fleet.ClusterStatus{
+								APIServerURL:              "",
+								APIServerCAHash:           "",
+								AgentTLSMode:              "system-store",
+								GarbageCollectionInterval: &metav1.Duration{Duration: 10 * time.Minute},
+							},
+						},
+					}, nil)
+				clustersController := fake.NewMockControllerInterface[*fleet.Cluster, *fleet.ClusterList](ctrl)
+				clustersController.EXPECT().UpdateStatus(gomock.Any()).Times(0) // import not triggered
+
+				return importHandler{
+					clusters:      clustersController,
+					clustersCache: clustersCache,
+					secretsCache:  secretsCache,
 				}
 			},
 		},
@@ -132,39 +172,38 @@ func TestOnConfig(t *testing.T) {
 				AgentTLSMode:              "strict",
 				GarbageCollectionInterval: metav1.Duration{Duration: 10 * time.Minute},
 			},
-			handlerWithMocks: func() importHandler {
+			handlerWithMocks: func(t *testing.T) 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},
-								},
+				clustersCache := fake.NewMockCacheInterface[*fleet.Cluster](ctrl)
+				clustersCache.EXPECT().List("", gomock.Eq(labels.Everything())).
+					Return([]*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 := fake.NewMockControllerInterface[*fleet.Cluster, *fleet.ClusterList](ctrl)
 				clustersController.EXPECT().UpdateStatus(gomock.Any()) // import triggered
 
 				return importHandler{
-					clusters: clustersController,
-					secrets:  secretsCache,
+					clusters:      clustersController,
+					clustersCache: clustersCache,
+					secretsCache:  secretsCache,
 				}
 			},
 		},
@@ -175,39 +214,38 @@ func TestOnConfig(t *testing.T) {
 				AgentTLSMode:              "system-store",
 				GarbageCollectionInterval: metav1.Duration{Duration: 5 * time.Minute},
 			},
-			handlerWithMocks: func() importHandler {
+			handlerWithMocks: func(t *testing.T) 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},
-								},
+				clustersCache := fake.NewMockCacheInterface[*fleet.Cluster](ctrl)
+				clustersCache.EXPECT().List("", gomock.Eq(labels.Everything())).
+					Return([]*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 := fake.NewMockControllerInterface[*fleet.Cluster, *fleet.ClusterList](ctrl)
 				clustersController.EXPECT().UpdateStatus(gomock.Any()) // import triggered
 
 				return importHandler{
-					clusters: clustersController,
-					secrets:  secretsCache,
+					clusters:      clustersController,
+					clustersCache: clustersCache,
+					secretsCache:  secretsCache,
 				}
 			},
 		},
@@ -218,31 +256,28 @@ func TestOnConfig(t *testing.T) {
 				AgentTLSMode:              "system-store",
 				GarbageCollectionInterval: metav1.Duration{Duration: 10 * time.Minute},
 			},
-			handlerWithMocks: func() importHandler {
+			handlerWithMocks: func(t *testing.T) 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},
-								},
+				clustersCache := fake.NewMockCacheInterface[*fleet.Cluster](ctrl)
+				clustersCache.EXPECT().List("", gomock.Eq(labels.Everything())).
+					Return([]*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{
@@ -254,8 +289,8 @@ func TestOnConfig(t *testing.T) {
 				// No UpdateStatus expected
 
 				return importHandler{
-					clusters: clustersController,
-					secrets:  secretsCache,
+					clustersCache: clustersCache,
+					secretsCache:  secretsCache,
 				}
 			},
 		},
@@ -266,31 +301,28 @@ func TestOnConfig(t *testing.T) {
 				AgentTLSMode:              "strict",
 				GarbageCollectionInterval: metav1.Duration{Duration: 10 * time.Minute},
 			},
-			handlerWithMocks: func() importHandler {
+			handlerWithMocks: func(t *testing.T) 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},
-								},
+				clustersCache := fake.NewMockCacheInterface[*fleet.Cluster](ctrl)
+				clustersCache.EXPECT().List("", gomock.Eq(labels.Everything())).
+					Return([]*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{
@@ -299,11 +331,13 @@ func TestOnConfig(t *testing.T) {
 					},
 				}, nil)
 
+				clustersController := fake.NewMockControllerInterface[*fleet.Cluster, *fleet.ClusterList](ctrl)
 				clustersController.EXPECT().UpdateStatus(gomock.Any()) // import triggered
 
 				return importHandler{
-					clusters: clustersController,
-					secrets:  secretsCache,
+					clusters:      clustersController,
+					clustersCache: clustersCache,
+					secretsCache:  secretsCache,
 				}
 			},
 		},
@@ -311,7 +345,7 @@ func TestOnConfig(t *testing.T) {
 
 	for name, c := range cases {
 		t.Run(name, func(t *testing.T) {
-			ih := c.handlerWithMocks()
+			ih := c.handlerWithMocks(t)
 
 			err := ih.onConfig(&c.cfg)
 			if err != nil {