Repository: rancher/fleet

Issue:
Document and test basic rollout use cases
The goal here is to be able to answer the following questions, with tests and documentation, about basic aspects of rollout:
- [x] How does auto-partitioning work? Does e.g. 10% of 4 clusters result in 1 partition?
- [x] Example use case from https://github.com/rancher/fleet/issues/2891
- [x] [rollout.go](https://github.com/rancher/fleet/blob/main/internal/cmd/controller/target/rollout.go,): does manual partitioning really distribute the remaining partitions as stated in the [docs](https://fleet.rancher.io/ref-fleet-yaml)?

Documentation must include user-facing [docs](https://fleet.rancher.io/ref-fleet-yaml), and optionally code comments/refactoring.

Discussion:
@p-se: > How does auto-partitioning work? Does e.g. 10% of 4 clusters result in 1 partition?

No. We have an undocumented immutable limit of 200 clusters, which disables auto-partitioning if less than 200 clusters are selected or available for targeting. However, if that limitation was removed or made configurable and then be disabled, the `limit` function in `rollout.go` would make sure to return at least `1`, no matter how small the given percentage.

> [rollout.go](https://github.com/rancher/fleet/blob/main/internal/cmd/controller/target/rollout.go?rgh-link-date=2025-04-25T12%3A54%3A39.000Z): does manual partitioning really distribute the remaining partitions as stated in the [docs](https://fleet.rancher.io/ref-fleet-yaml)?

No, every target that is not successfully selected using manual partitioning is not being added to any partition.

Solution patch:
diff --git a/.github/scripts/deploy-fleet.sh b/.github/scripts/deploy-fleet.sh
index 436213fa..f11a27c9 100755
--- a/.github/scripts/deploy-fleet.sh
+++ b/.github/scripts/deploy-fleet.sh
@@ -64,10 +64,6 @@ eventually helm upgrade --install fleet charts/fleet \
   --set apiServerCA="$ca" \
   --set apiServerURL="$server" \
   $shards_settings \
-  --set-string extraEnv[0].name=EXPERIMENTAL_OCI_STORAGE \
-  --set-string extraEnv[0].value=true \
-  --set-string extraEnv[1].name=EXPERIMENTAL_HELM_OPS \
-  --set-string extraEnv[1].value=true \
   --set garbageCollectionInterval=1s \
   --set insecureSkipHostKeyChecks=false \
   --set debug=true --set debugLevel=1
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 2f3ce6e0..eeb62755 100644
--- a/.github/workflows/e2e-rancher-upgrade-fleet-to-head-ci.yml
+++ b/.github/workflows/e2e-rancher-upgrade-fleet-to-head-ci.yml
@@ -103,7 +103,7 @@ jobs:
         uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3
       -
         name: Set up Docker Buildx
-        uses: docker/setup-buildx-action@18ce135bb5112fa8ce4ed6c17ab05699d7f3a5e0 # v3
+        uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3
       -
         name: Get uuid
         id: uuid
diff --git a/.github/workflows/release-against-rancher.yml b/.github/workflows/release-against-rancher.yml
index eb36f847..f1f4e57c 100644
--- a/.github/workflows/release-against-rancher.yml
+++ b/.github/workflows/release-against-rancher.yml
@@ -3,15 +3,15 @@ on:
   workflow_dispatch:
     inputs:
       rancher_ref:
-        description: "Submit PR against the following rancher/rancher branch (e.g. release/v2.7)"
+        description: "Submit PR against the following rancher/rancher branch (e.g. release/v2.12)"
         required: true
-        default: "release/v2.10"
+        default: "release/v2.11"
       new_fleet:
-        description: "New Fleet version (e.g. 0.8.0-rc.3)"
+        description: "New Fleet version (e.g. 0.13.0-rc.1)"
         required: true
         default: ""
       new_chart:
-        description: "New Rancher Chart version (e.g. 103.1.0)"
+        description: "New Rancher Chart version (e.g. 107.0.0)"
         required: true
         default: ""
       should_bump_api:
@@ -21,7 +21,7 @@ on:
       go_version:
         description: "Go version used for bumping the api. This should be the same version as in the go.mod file of the project."
         required: true
-        default: '1.23.*'
+        default: '1.24.*'
 
 env:
   GOARCH: amd64
@@ -47,6 +47,8 @@ jobs:
       - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5
         with:
           go-version: ${{ env.SETUP_GO_VERSION }}
+      - name: Install controller-gen
+        run: go install sigs.k8s.io/controller-tools/cmd/controller-gen@v0.17.2
       - name: Run release script
         run: |
           export CHARTS_DIR="${GITHUB_WORKSPACE}/rancher"
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 48ae60ec..5bad5e30 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -77,10 +77,10 @@ jobs:
         uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3
 
       - name: Set up Docker Buildx
-        uses: docker/setup-buildx-action@18ce135bb5112fa8ce4ed6c17ab05699d7f3a5e0 # v3
+        uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3
 
       - name: Install Cosign
-        uses: sigstore/cosign-installer@fb28c2b6339dcd94da6e4cbcbc5e888961f6f8c3 # v3.9.0
+        uses: sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac # v3.9.1
       - uses: rancherlabs/slsactl/actions/install-slsactl@43385ab2deb538998c480617f2939c5ddc7067ab # v0.0.14
 
       - name: "Read Vault Secrets"
@@ -146,12 +146,17 @@ jobs:
               --yes \
               --sign-container-identity="${{ env.PRIME_REGISTRY }}/rancher/${IMAGE}" \
               "${URL}"
+
+            # Pull the manifest locally to make it available for inspection
+            docker pull "${URL}"
           done
 
       - name: Attest provenance
         shell: bash
         run: |
-          for IMG_NAME in $(yq e '.dockers[].image_templates[0]' .goreleaser.yaml | grep STAGE_REGISTRY | sed "s/{{ .Env.STAGE_REGISTRY }}/${{ env.STAGE_REGISTRY }}/g" | sed "s/{{ .Tag }}/${{ github.ref_name }}/g"); do
+          IMAGES=$(yq e '.dockers[].image_templates[0]' .goreleaser.yaml | grep STAGE_REGISTRY | sed "s/{{ .Env.STAGE_REGISTRY }}/${{ env.STAGE_REGISTRY }}/g" | sed "s/{{ .Tag }}/${{ github.ref_name }}/g")
+
+          for IMG_NAME in $IMAGES; do
             # Extract Docker image reference plus digest from local image
             URL=$(docker inspect --format='{{index .RepoDigests 0}}' "${IMG_NAME}")
 
@@ -159,7 +164,13 @@ jobs:
             retry_delay=5
 
             for ((i=0; i<max_retries; i++)); do
-              if slsactl download provenance --format=slsav1 "${URL}" > provenance-slsav1.json; then
+              if [[ "${IMG_NAME}" =~ -linux-([^-]+)$ ]]; then
+                PLATFORM="linux/${BASH_REMATCH[1]}"
+              else
+                PLATFORM="linux/amd64"
+              fi
+
+              if slsactl download provenance --format=slsav1 --platform="${PLATFORM}" "${URL}" > provenance-slsav1.json; then
                 break
               fi
               if [ "${i}" -eq "$(( max_retries - 1 ))" ]; then
diff --git a/README.md b/README.md
index f0b98bd3..2b0a32f0 100644
--- a/README.md
+++ b/README.md
@@ -37,9 +37,9 @@ Install the Fleet Helm charts (there's two because we separate out CRDs for ulti
 
 ```shell
 helm -n cattle-fleet-system install --create-namespace --wait \
-    fleet-crd https://github.com/rancher/fleet/releases/download/v0.12.3/fleet-crd-0.12.3.tgz
+    fleet-crd https://github.com/rancher/fleet/releases/download/v0.12.4/fleet-crd-0.12.4.tgz
 helm -n cattle-fleet-system install --create-namespace --wait \
-    fleet https://github.com/rancher/fleet/releases/download/v0.12.3/fleet-0.12.3.tgz
+    fleet https://github.com/rancher/fleet/releases/download/v0.12.4/fleet-0.12.4.tgz
 ```
 
 ## Add a Git Repo to watch
diff --git a/charts/fleet-crd/templates/crds.yaml b/charts/fleet-crd/templates/crds.yaml
index 1e1eb705..d54ca504 100644
--- a/charts/fleet-crd/templates/crds.yaml
+++ b/charts/fleet-crd/templates/crds.yaml
@@ -452,6 +452,7 @@ spec:
                     ignore:
                       description: IgnoreOptions can be used to ignore fields when
                         monitoring the bundle.
+                      nullable: true
                       properties:
                         conditions:
                           description: Conditions is a list of conditions to be ignored
@@ -821,6 +822,7 @@ spec:
                     ignore:
                       description: IgnoreOptions can be used to ignore fields when
                         monitoring the bundle.
+                      nullable: true
                       properties:
                         conditions:
                           description: Conditions is a list of conditions to be ignored
@@ -1741,6 +1743,7 @@ spec:
                 ignore:
                   description: IgnoreOptions can be used to ignore fields when monitoring
                     the bundle.
+                  nullable: true
                   properties:
                     conditions:
                       description: Conditions is a list of conditions to be ignored
@@ -2611,6 +2614,7 @@ spec:
                       ignore:
                         description: IgnoreOptions can be used to ignore fields when
                           monitoring the bundle.
+                        nullable: true
                         properties:
                           conditions:
                             description: Conditions is a list of conditions to be
@@ -7535,6 +7539,7 @@ spec:
                 ignore:
                   description: IgnoreOptions can be used to ignore fields when monitoring
                     the bundle.
+                  nullable: true
                   properties:
                     conditions:
                       description: Conditions is a list of conditions to be ignored
@@ -8422,6 +8427,7 @@ spec:
                       ignore:
                         description: IgnoreOptions can be used to ignore fields when
                           monitoring the bundle.
+                        nullable: true
                         properties:
                           conditions:
                             description: Conditions is a list of conditions to be
diff --git a/charts/fleet/ci/debug-values.yaml b/charts/fleet/ci/debug-values.yaml
index fed47ac4..2acb48ef 100644
--- a/charts/fleet/ci/debug-values.yaml
+++ b/charts/fleet/ci/debug-values.yaml
@@ -19,6 +19,9 @@ priorityClassName: "prio1"
 gitops:
   enabled: true
 
+helmops:
+  enabled: true
+
 metrics:
   enabled: true
 
@@ -49,12 +52,6 @@ controller:
       bundle: "1"
       bundledeployment: "1"
 
-extraEnv:
-  - name: EXPERIMENTAL_OCI_STORAGE
-    value: "true"
-  - name: EXPERIMENTAL_HELM_OPS
-    value: "true"
-
 shards:
   - id: shard0
     nodeSelector:
diff --git a/charts/fleet/ci/nobootstrap-values.yaml b/charts/fleet/ci/nobootstrap-values.yaml
index 62ac3b94..f9a5db7c 100644
--- a/charts/fleet/ci/nobootstrap-values.yaml
+++ b/charts/fleet/ci/nobootstrap-values.yaml
@@ -19,6 +19,9 @@ priorityClassName: "prio1"
 gitops:
   enabled: true
 
+helmops:
+  enabled: true
+
 metrics:
   enabled: true
 
@@ -48,12 +51,6 @@ controller:
       bundle: "1"
       bundledeployment: "1"
 
-extraEnv:
-  - name: EXPERIMENTAL_OCI_STORAGE
-    value: "true"
-  - name: EXPERIMENTAL_HELM_OPS
-    value: "true"
-
 shards:
   - id: shard0
     nodeSelector:
diff --git a/charts/fleet/ci/nodebug-values.yaml b/charts/fleet/ci/nodebug-values.yaml
index ca23bfbd..b72f7b4c 100644
--- a/charts/fleet/ci/nodebug-values.yaml
+++ b/charts/fleet/ci/nodebug-values.yaml
@@ -19,6 +19,9 @@ priorityClassName: "prio1"
 gitops:
   enabled: true
 
+helmops:
+  enabled: true
+
 metrics:
   enabled: true
 
@@ -48,12 +51,6 @@ controller:
       bundle: "1"
       bundledeployment: "1"
 
-extraEnv:
-  - name: EXPERIMENTAL_OCI_STORAGE
-    value: "true"
-  - name: EXPERIMENTAL_HELM_OPS
-    value: "true"
-
 shards:
   - id: shard0
     nodeSelector:
diff --git a/charts/fleet/ci/nogitops-values.yaml b/charts/fleet/ci/nogitops-values.yaml
index 2eddf54f..3344c394 100644
--- a/charts/fleet/ci/nogitops-values.yaml
+++ b/charts/fleet/ci/nogitops-values.yaml
@@ -19,6 +19,9 @@ priorityClassName: "prio1"
 gitops:
   enabled: false
 
+helmops:
+  enabled: true
+
 metrics:
   enabled: true
 
@@ -48,12 +51,6 @@ controller:
       bundle: "1"
       bundledeployment: "1"
 
-extraEnv:
-  - name: EXPERIMENTAL_OCI_STORAGE
-    value: "true"
-  - name: EXPERIMENTAL_HELM_OPS
-    value: "true"
-
 shards:
   - id: shard0
     nodeSelector:
diff --git a/charts/fleet/ci/nohelmops-values.yaml b/charts/fleet/ci/nohelmops-values.yaml
new file mode 100644
index 00000000..e787758e
--- /dev/null
+++ b/charts/fleet/ci/nohelmops-values.yaml
@@ -0,0 +1,63 @@
+bootstrap:
+  enabled: true
+
+global:
+  cattle:
+    systemDefaultRegistry: "ghcr.io"
+
+nodeSelector:
+  kubernetes.io/os: winxp
+
+tolerations:
+  - key: node.cloudprovider.kubernetes.io/uninitialized
+    operator: "Equal"
+    value: "true"
+    effect: NoSchedule
+
+priorityClassName: "prio1"
+
+gitops:
+  enabled: true
+
+helmops:
+  enabled: false
+
+metrics:
+  enabled: true
+
+debug: false
+debugLevel: 4
+propagateDebugSettingsToAgents: true
+
+cpuPprof:
+  period: "60s"
+  volumeConfiguration:
+    hostPath:
+      path: /tmp/pprof
+      type: DirectoryOrCreate
+
+migrations:
+  clusterRegistrationCleanup: true
+
+leaderElection:
+  leaseDuration: 30s
+  retryPeriod: 10s
+  renewDeadline: 25s
+
+controller:
+  reconciler:
+    workers:
+      gitrepo: "1"
+      bundle: "1"
+      bundledeployment: "1"
+
+shards:
+  - id: shard0
+    nodeSelector:
+      kubernetes.io/hostname: k3d-upstream-server-0
+  - id: shard1
+    nodeSelector:
+      kubernetes.io/hostname: k3d-upstream-server-1
+  - id: shard2
+    nodeSelector:
+      kubernetes.io/hostname: k3d-upstream-server-2
diff --git a/charts/fleet/templates/deployment_helmops.yaml b/charts/fleet/templates/deployment_helmops.yaml
index bb80ef2f..998653f1 100644
--- a/charts/fleet/templates/deployment_helmops.yaml
+++ b/charts/fleet/templates/deployment_helmops.yaml
@@ -1,4 +1,4 @@
-{{- if has (dict "name" "EXPERIMENTAL_HELM_OPS" "value" "true") .Values.extraEnv }}
+{{- if $.Values.helmops.enabled }}
 {{- $shards := list (dict "id" "" "nodeSelector" dict) -}}
 {{- $uniqueShards := list -}}
 {{- if .Values.shards -}}
diff --git a/charts/fleet/templates/rbac_helmops.yaml b/charts/fleet/templates/rbac_helmops.yaml
index f3f75703..390a6557 100644
--- a/charts/fleet/templates/rbac_helmops.yaml
+++ b/charts/fleet/templates/rbac_helmops.yaml
@@ -1,4 +1,4 @@
-{{- if has (dict "name" "EXPERIMENTAL_HELM_OPS" "value" "true") .Values.extraEnv }}
+{{- if $.Values.helmops.enabled }}
 apiVersion: rbac.authorization.k8s.io/v1
 kind: ClusterRole
 metadata:
diff --git a/charts/fleet/templates/serviceaccount_helmops.yaml b/charts/fleet/templates/serviceaccount_helmops.yaml
index 84f39389..90272b9f 100644
--- a/charts/fleet/templates/serviceaccount_helmops.yaml
+++ b/charts/fleet/templates/serviceaccount_helmops.yaml
@@ -1,4 +1,4 @@
-{{- if has (dict "name" "EXPERIMENTAL_HELM_OPS" "value" "true") .Values.extraEnv }}
+{{- if $.Values.helmops.enabled }}
 apiVersion: v1
 kind: ServiceAccount
 metadata:
diff --git a/charts/fleet/values.yaml b/charts/fleet/values.yaml
index ded46017..ed704e40 100644
--- a/charts/fleet/values.yaml
+++ b/charts/fleet/values.yaml
@@ -120,6 +120,7 @@ gitjob:
   replicas: 1
 
 helmops:
+  enabled: true
   replicas: 1
 
 ## Fleet agent configuration
@@ -139,10 +140,8 @@ agent:
 
 # Extra environment variables passed to the fleet pods.
 # extraEnv:
-# - name: EXPERIMENTAL_OCI_STORAGE
-#   value: "true"
-# - name: EXPERIMENTAL_HELM_OPS
-#   value: "true"
+# - name: OCI_STORAGE
+#   value: "false"
 
 # shards:
 #   - id: shard0
diff --git a/dev/setup-fleet b/dev/setup-fleet
index f84ac8d0..86b5ba62 100755
--- a/dev/setup-fleet
+++ b/dev/setup-fleet
@@ -44,10 +44,6 @@ helm -n cattle-fleet-system upgrade --install --create-namespace --wait --reset-
   --set agent.leaderElection.leaseDuration=10s \
   --set agent.leaderElection.retryPeriod=1s \
   --set agent.leaderElection.renewDeadline=5s \
-  --set-string extraEnv[0].name=EXPERIMENTAL_OCI_STORAGE \
-  --set-string extraEnv[0].value=true \
-  --set-string extraEnv[1].name=EXPERIMENTAL_HELM_OPS \
-  --set-string extraEnv[1].value=true \
   --set garbageCollectionInterval=1s \
   --set insecureSkipHostKeyChecks=false \
   --set debug=true --set debugLevel=1 fleet charts/fleet
diff --git a/e2e/assets/multi-cluster/helmop.yaml b/e2e/assets/multi-cluster/helmop.yaml
new file mode 100644
index 00000000..6ca6998d
--- /dev/null
+++ b/e2e/assets/multi-cluster/helmop.yaml
@@ -0,0 +1,16 @@
+apiVersion: fleet.cattle.io/v1alpha1
+kind: HelmOp
+metadata:
+  name: {{.Name}}
+  namespace: {{.Namespace}}
+spec:
+  helm:
+    releaseName: testhelm
+    repo: {{.Repo}}
+    chart: {{.Chart}}
+    version: '{{.Version}}'
+  pollingInterval: {{.PollingInterval}}
+  helmSecretName: {{.HelmSecretName}}
+  insecureSkipTLSVerify: {{.InsecureSkipTLSVerify}}
+  targets:
+    - clusterSelector: {}
diff --git a/go.mod b/go.mod
index 0b116d5f..57420da2 100644
--- a/go.mod
+++ b/go.mod
@@ -11,11 +11,11 @@ replace (
 )
 
 require (
-	github.com/Masterminds/semver/v3 v3.3.1
+	github.com/Masterminds/semver/v3 v3.4.0
 	github.com/Masterminds/sprig/v3 v3.3.0
 	github.com/chartmuseum/helm-push v0.10.4
 	github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc
-	github.com/docker/docker v28.2.2+incompatible
+	github.com/docker/docker v28.3.0+incompatible
 	github.com/evanphx/json-patch v5.9.11+incompatible
 	github.com/go-git/go-billy/v5 v5.6.2
 	github.com/go-git/go-git/v5 v5.16.2
@@ -36,7 +36,7 @@ require (
 	github.com/pkg/errors v0.9.1
 	github.com/prometheus/client_golang v1.22.0
 	github.com/prometheus/client_model v0.6.2
-	github.com/prometheus/common v0.64.0
+	github.com/prometheus/common v0.65.0
 	github.com/rancher/fleet/pkg/apis v0.12.3
 	github.com/rancher/lasso v0.2.2
 	github.com/rancher/wrangler/v3 v3.2.1
@@ -60,15 +60,15 @@ require (
 	k8s.io/klog/v2 v2.130.1
 	k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff
 	k8s.io/kubectl v0.33.1
-	k8s.io/kubernetes v1.33.1
+	k8s.io/kubernetes v1.33.2
 	k8s.io/utils v0.0.0-20250502105355-0f33e8f1c979
 	oras.land/oras-go/v2 v2.6.0
 	sigs.k8s.io/cli-utils v0.37.2
 	sigs.k8s.io/controller-runtime v0.21.0
 	sigs.k8s.io/controller-tools v0.18.0
-	sigs.k8s.io/kustomize/api v0.19.0
-	sigs.k8s.io/kustomize/kyaml v0.19.0
-	sigs.k8s.io/yaml v1.4.0
+	sigs.k8s.io/kustomize/api v0.20.0
+	sigs.k8s.io/kustomize/kyaml v0.20.0
+	sigs.k8s.io/yaml v1.5.0
 )
 
 require (
@@ -89,6 +89,7 @@ require (
 	github.com/beorn7/perks v1.0.1 // indirect
 	github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d // indirect
 	github.com/blang/semver/v4 v4.0.0 // indirect
+	github.com/carapace-sh/carapace-shlex v1.0.1 // indirect
 	github.com/cenkalti/backoff/v4 v4.3.0 // indirect
 	github.com/cespare/xxhash/v2 v2.3.0 // indirect
 	github.com/chai2010/gettext-go v1.0.2 // indirect
@@ -136,7 +137,6 @@ require (
 	github.com/google/gnostic-models v0.6.9 // indirect
 	github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect
 	github.com/google/s2a-go v0.1.7 // indirect
-	github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
 	github.com/google/uuid v1.6.0 // indirect
 	github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect
 	github.com/googleapis/gax-go/v2 v2.12.0 // indirect
@@ -221,6 +221,8 @@ require (
 	go.opentelemetry.io/otel/trace v1.36.0 // indirect
 	go.uber.org/automaxprocs v1.6.0 // indirect
 	go.uber.org/multierr v1.11.0 // indirect
+	go.yaml.in/yaml/v2 v2.4.2 // indirect
+	go.yaml.in/yaml/v3 v3.0.3 // indirect
 	golang.org/x/mod v0.25.0 // indirect
 	golang.org/x/net v0.41.0 // indirect
 	golang.org/x/oauth2 v0.30.0 // indirect
diff --git a/go.sum b/go.sum
index 119361cd..dd9acdf2 100644
--- a/go.sum
+++ b/go.sum
@@ -622,8 +622,8 @@ github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ
 github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
 github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI=
 github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU=
-github.com/Masterminds/semver/v3 v3.3.1 h1:QtNSWtVZ3nBfk8mAOu/B6v7FMJ+NHTIgUPi7rj+4nv4=
-github.com/Masterminds/semver/v3 v3.3.1/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
+github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
+github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
 github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs=
 github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0=
 github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM=
@@ -661,6 +661,8 @@ github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl
 github.com/boombuler/barcode v1.0.1/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
 github.com/bshuster-repo/logrus-logstash-hook v1.0.0 h1:e+C0SB5R1pu//O4MQ3f9cFuPGoOVeF2fE4Og9otCc70=
 github.com/bshuster-repo/logrus-logstash-hook v1.0.0/go.mod h1:zsTqEiSzDgAa/8GZR7E1qaXrhYNDKBYy5/dWPTIflbk=
+github.com/carapace-sh/carapace-shlex v1.0.1 h1:ww0JCgWpOVuqWG7k3724pJ18Lq8gh5pHQs9j3ojUs1c=
+github.com/carapace-sh/carapace-shlex v1.0.1/go.mod h1:lJ4ZsdxytE0wHJ8Ta9S7Qq0XpjgjU0mdfCqiI2FHx7M=
 github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
 github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
 github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
@@ -733,8 +735,8 @@ github.com/docker/cli v28.2.2+incompatible h1:qzx5BNUDFqlvyq4AHzdNB7gSyVTmU4cgsy
 github.com/docker/cli v28.2.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
 github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk=
 github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
-github.com/docker/docker v28.2.2+incompatible h1:CjwRSksz8Yo4+RmQ339Dp/D2tGO5JxwYeqtMOEe0LDw=
-github.com/docker/docker v28.2.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
+github.com/docker/docker v28.3.0+incompatible h1:ffS62aKWupCWdvcee7nBU9fhnmknOqDPaJAMtfK0ImQ=
+github.com/docker/docker v28.3.0+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
 github.com/docker/docker-credential-helpers v0.9.3 h1:gAm/VtF9wgqJMoxzT3Gj5p4AqIjCBS4wrsOh9yRqcz8=
 github.com/docker/docker-credential-helpers v0.9.3/go.mod h1:x+4Gbw9aGmChi3qTLZj8Dfn0TD20M/fuWy0E5+WDeCo=
 github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
@@ -949,8 +951,6 @@ github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC
 github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
 github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o=
 github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw=
-github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
-github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
 github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -1185,8 +1185,8 @@ github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6T
 github.com/prometheus/client_model v0.3.0/go.mod h1:LDGWKZIo7rky3hgvBe+caln+Dr3dPggB5dvjtD7w9+w=
 github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
 github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
-github.com/prometheus/common v0.64.0 h1:pdZeA+g617P7oGv1CzdTzyeShxAGrTBsolKNOLQPGO4=
-github.com/prometheus/common v0.64.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
+github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE=
+github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
 github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
 github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
 github.com/rancher/lasso v0.2.2 h1:oKP5d4+eSupwHftLMTWo6QS1tYYZ5XK+ZjP+VNhwmk8=
@@ -1359,6 +1359,10 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
 go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
 go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
 go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
+go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
+go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
+go.yaml.in/yaml/v3 v3.0.3 h1:bXOww4E/J3f66rav3pX3m8w6jDE4knZjGOw8b5Y6iNE=
+go.yaml.in/yaml/v3 v3.0.3/go.mod h1:tBHosrYAkRZjRAOREWbDnBXUf08JOwYq++0QNwQiWzI=
 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
 golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
 golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
@@ -2118,8 +2122,8 @@ k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff h1:/usPimJzUKKu+m+TE36gUy
 k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff/go.mod h1:5jIi+8yX4RIb8wk3XwBo5Pq2ccx4FP10ohkbSKCZoK8=
 k8s.io/kubectl v0.33.1 h1:OJUXa6FV5bap6iRy345ezEjU9dTLxqv1zFTVqmeHb6A=
 k8s.io/kubectl v0.33.1/go.mod h1:Z07pGqXoP4NgITlPRrnmiM3qnoo1QrK1zjw85Aiz8J0=
-k8s.io/kubernetes v1.33.1 h1:86+VVY/f11taZdpEZrNciLw1MIQhu6BFXf/OMFn5EUg=
-k8s.io/kubernetes v1.33.1/go.mod h1:2nWuPk0seE4+6sd0x60wQ6rYEXcV7SoeMbU0YbFm/5k=
+k8s.io/kubernetes v1.33.2 h1:Vk3hsCaazyMQ6CXhu029AEPlBoYsEnD8oEIC0bP2pWQ=
+k8s.io/kubernetes v1.33.2/go.mod h1:nrt8sldmckKz2fCZhgRX3SKfS2e+CzXATPv6ITNkU00=
 k8s.io/utils v0.0.0-20250502105355-0f33e8f1c979 h1:jgJW5IePPXLGB8e/1wvd0Ich9QE97RvvF3a8J3fP/Lg=
 k8s.io/utils v0.0.0-20250502105355-0f33e8f1c979/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
 lukechampine.com/uint128 v1.1.1/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
@@ -2170,15 +2174,16 @@ sigs.k8s.io/controller-tools v0.18.0 h1:rGxGZCZTV2wJreeRgqVoWab/mfcumTMmSwKzoM9x
 sigs.k8s.io/controller-tools v0.18.0/go.mod h1:gLKoiGBriyNh+x1rWtUQnakUYEujErjXs9pf+x/8n1U=
 sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 h1:/Rv+M11QRah1itp8VhT6HoVx1Ray9eB4DBr+K+/sCJ8=
 sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3/go.mod h1:18nIHnGi6636UCz6m8i4DhaJ65T6EruyzmoQqI2BVDo=
-sigs.k8s.io/kustomize/api v0.19.0 h1:F+2HB2mU1MSiR9Hp1NEgoU2q9ItNOaBJl0I4Dlus5SQ=
-sigs.k8s.io/kustomize/api v0.19.0/go.mod h1:/BbwnivGVcBh1r+8m3tH1VNxJmHSk1PzP5fkP6lbL1o=
-sigs.k8s.io/kustomize/kyaml v0.19.0 h1:RFge5qsO1uHhwJsu3ipV7RNolC7Uozc0jUBC/61XSlA=
-sigs.k8s.io/kustomize/kyaml v0.19.0/go.mod h1:FeKD5jEOH+FbZPpqUghBP8mrLjJ3+zD3/rf9NNu1cwY=
+sigs.k8s.io/kustomize/api v0.20.0 h1:xPLqcobHI0bThyRUteO+nCV8G4d1Rlo5HafO57VRcas=
+sigs.k8s.io/kustomize/api v0.20.0/go.mod h1:F6CfaV27oevRCMJgehLqyX81dlUnRX/Fc13Uo7+OSo4=
+sigs.k8s.io/kustomize/kyaml v0.20.0 h1:tT8KMKi4R3hCJ1+9HDdek2VoXpkerP92ZfF6fDgGw14=
+sigs.k8s.io/kustomize/kyaml v0.20.0/go.mod h1:0EmkQHRUsJxY8Ug9Niig1pUMSCGHxQ5RklbpV/Ri6po=
 sigs.k8s.io/randfill v0.0.0-20250304075658-069ef1bbf016/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
 sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=
 sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
 sigs.k8s.io/structured-merge-diff/v4 v4.6.0 h1:IUA9nvMmnKWcj5jl84xn+T5MnlZKThmUW1TdblaLVAc=
 sigs.k8s.io/structured-merge-diff/v4 v4.6.0/go.mod h1:dDy58f92j70zLsuZVuUX5Wp9vtxXpaZnkPGWeqDfCps=
 sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc=
-sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E=
 sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY=
+sigs.k8s.io/yaml v1.5.0 h1:M10b2U7aEUY6hRtU870n2VTPgR5RZiL/I6Lcc2F4NUQ=
+sigs.k8s.io/yaml v1.5.0/go.mod h1:wZs27Rbxoai4C0f8/9urLZtZtF3avA3gKvGyPdDqTO4=
diff --git a/internal/bundlereader/charturl.go b/internal/bundlereader/charturl.go
index c34e4385..6ecad766 100644
--- a/internal/bundlereader/charturl.go
+++ b/internal/bundlereader/charturl.go
@@ -17,7 +17,7 @@ import (
 // ChartVersion returns the version of the helm chart from a helm repo server, by
 // inspecting the repo's index.yaml
 func ChartVersion(location fleet.HelmOptions, auth Auth) (string, error) {
-	if hasOCIURL.MatchString(location.Chart) {
+	if hasOCIURL.MatchString(location.Repo) {
 		return location.Version, nil
 	}
 
@@ -43,9 +43,14 @@ func ChartVersion(location fleet.HelmOptions, auth Auth) (string, error) {
 
 // chartURL returns the URL to the helm chart from a helm repo server, by
 // inspecting the repo's index.yaml
-func chartURL(location fleet.HelmOptions, auth Auth) (string, error) {
-	if hasOCIURL.MatchString(location.Chart) {
-		return location.Chart, nil
+func chartURL(location fleet.HelmOptions, auth Auth, isHelmOps bool) (string, error) {
+	OCIField := location.Chart
+	if isHelmOps {
+		OCIField = location.Repo
+	}
+
+	if hasOCIURL.MatchString(OCIField) {
+		return OCIField, nil
 	}
 
 	if location.Repo == "" {
diff --git a/internal/bundlereader/helm.go b/internal/bundlereader/helm.go
index 7a0f86b0..3a278d38 100644
--- a/internal/bundlereader/helm.go
+++ b/internal/bundlereader/helm.go
@@ -32,7 +32,7 @@ func GetManifestFromHelmChart(ctx context.Context, c client.Client, bd *fleet.Bu
 	}
 	auth.InsecureSkipVerify = bd.Spec.HelmChartOptions.InsecureSkipTLSverify
 
-	chartURL, err := chartURL(*helm, auth)
+	chartURL, err := chartURL(*helm, auth, true)
 	if err != nil {
 		return nil, err
 	}
diff --git a/internal/bundlereader/resources.go b/internal/bundlereader/resources.go
index 2a1762aa..7aac436d 100644
--- a/internal/bundlereader/resources.go
+++ b/internal/bundlereader/resources.go
@@ -202,7 +202,7 @@ func addRemoteCharts(directories []directory, base string, charts []*fleet.HelmO
 				auth = Auth{}
 			}
 
-			chartURL, err := chartURL(*chart, auth)
+			chartURL, err := chartURL(*chart, auth, false)
 			if err != nil {
 				return nil, fmt.Errorf("failed to resolve URL of %s: %w", downloadChartError(*chart), err)
 			}
diff --git a/internal/bundlereader/style.go b/internal/bundlereader/style.go
index 834ef09b..b8a1b76f 100644
--- a/internal/bundlereader/style.go
+++ b/internal/bundlereader/style.go
@@ -17,10 +17,16 @@ func joinAndClean(path, file string) string {
 }
 
 func chartPath(options fleet.BundleDeploymentOptions) (string, string) {
-	if options.Helm == nil || options.Helm.Chart == "" {
+	if options.Helm == nil {
 		return chartYAML, ""
 	}
-	return joinAndClean(options.Helm.Chart, chartYAML), checksum(options.Helm) + "/"
+
+	path := options.Helm.Chart
+	if len(path) == 0 {
+		path = options.Helm.Repo
+	}
+
+	return joinAndClean(path, chartYAML), checksum(options.Helm) + "/"
 }
 
 func kustomizePath(options fleet.BundleDeploymentOptions) string {
diff --git a/internal/cmd/agent/operator.go b/internal/cmd/agent/operator.go
index 1419ef7d..884804ff 100644
--- a/internal/cmd/agent/operator.go
+++ b/internal/cmd/agent/operator.go
@@ -345,9 +345,5 @@ func getAgentConfig(ctx context.Context, namespace string, cfg *rest.Config) (ag
 		return nil, fmt.Errorf("failed to parse config from ConfigMap: %w", err)
 	}
 
-	if agentConfig.AgentTLSMode == config.AgentTLSModeStrict {
-		config.BypassSystemCAStore()
-	}
-
 	return agentConfig, nil
 }
diff --git a/internal/cmd/agent/register/register.go b/internal/cmd/agent/register/register.go
index 5fc6511b..886cbd82 100644
--- a/internal/cmd/agent/register/register.go
+++ b/internal/cmd/agent/register/register.go
@@ -140,7 +140,8 @@ func runRegistration(ctx context.Context, k8s coreInterface, namespace string) (
 		return nil, fmt.Errorf("failed to look up client config %s/%s: %w", secret.Namespace, config.AgentConfigName, err)
 	}
 
-	clientConfig := createClientConfigFromSecret(secret, cfg.AgentTLSMode == config.AgentTLSModeSystemStore)
+	clientConfig, undoBypass := createClientConfigFromSecret(secret, cfg.AgentTLSMode == config.AgentTLSModeSystemStore)
+	defer undoBypass()
 
 	ns, _, err := clientConfig.Namespace()
 	if err != nil {
@@ -287,20 +288,22 @@ func values(data map[string][]byte) map[string][]byte {
 
 // createClientConfigFromSecret reads the fleet-agent-bootstrap secret and
 // creates a clientConfig to access the upstream cluster
-func createClientConfigFromSecret(secret *corev1.Secret, trustSystemStoreCAs bool) clientcmd.ClientConfig {
+func createClientConfigFromSecret(secret *corev1.Secret, trustSystemStoreCAs bool) (clientcmd.ClientConfig, func()) {
 	data := values(secret.Data)
 	apiServerURL := string(data[config.APIServerURLKey])
 	apiServerCA := data[config.APIServerCAKey]
 	namespace := string(data[ClusterNamespace])
 	token := string(data[Token])
 
+	undoBypass := func() {}
+
 	if trustSystemStoreCAs { // Save a request to the API server URL if system CAs are not to be trusted.
 		// NOTE(manno): client-go will use the system trust store even if a CA is configured. So, why do this?
 		if _, err := http.Get(apiServerURL); err == nil {
 			apiServerCA = nil
 		}
 	} else {
-		config.BypassSystemCAStore()
+		undoBypass = config.BypassSystemCAStore()
 	}
 
 	cfg := clientcmdapi.Config{
@@ -325,7 +328,7 @@ func createClientConfigFromSecret(secret *corev1.Secret, trustSystemStoreCAs boo
 		CurrentContext: "default",
 	}
 
-	return clientcmd.NewDefaultClientConfig(cfg, &clientcmd.ConfigOverrides{})
+	return clientcmd.NewDefaultClientConfig(cfg, &clientcmd.ConfigOverrides{}), undoBypass
 }
 
 func testClientConfig(cfg []byte) error {
diff --git a/internal/cmd/cli/apply/apply.go b/internal/cmd/cli/apply/apply.go
index 91924ace..cac28199 100644
--- a/internal/cmd/cli/apply/apply.go
+++ b/internal/cmd/cli/apply/apply.go
@@ -449,7 +449,7 @@ func printToOutput(w io.Writer, bundle *fleet.Bundle, scans []*fleet.ImageScan)
 }
 
 func shouldStoreInOCIRegistry(ctx context.Context, c client.Reader, ociSecretKey client.ObjectKey, ociOpts *ocistorage.OCIOpts) (bool, error) {
-	if !ocistorage.ExperimentalOCIIsEnabled() {
+	if !ocistorage.OCIIsEnabled() {
 		return false, nil
 	}
 
diff --git a/internal/cmd/controller/agentmanagement/controllers/cluster/import.go b/internal/cmd/controller/agentmanagement/controllers/cluster/import.go
index babf4a95..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.
@@ -204,6 +221,7 @@ func (i *importHandler) OnChange(key string, cluster *fleet.Cluster) (_ *fleet.C
 		return cluster, nil
 	}
 
+	// cluster.spec.KubeConfigSecret is empty when agent-initiated registration is used
 	if cluster.Spec.KubeConfigSecret == "" || agentDeployed(cluster) {
 		return cluster, nil
 	}
@@ -256,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
 	}
@@ -272,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
 	}
@@ -290,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
@@ -490,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
 	}
@@ -498,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(),
diff --git a/internal/cmd/controller/gitops/reconciler/gitjob.go b/internal/cmd/controller/gitops/reconciler/gitjob.go
index feea7650..d5273300 100644
--- a/internal/cmd/controller/gitops/reconciler/gitjob.go
+++ b/internal/cmd/controller/gitops/reconciler/gitjob.go
@@ -704,12 +704,13 @@ func argsAndEnvs(
 		env = append(env, gitSSHCommandEnvVar(knownHosts.IsStrict()))
 	}
 
-	if ocistorage.ExperimentalOCIIsEnabled() {
+	if !ocistorage.OCIIsEnabled() {
 		env = append(env,
 			corev1.EnvVar{
-				Name:  "EXPERIMENTAL_OCI_STORAGE",
-				Value: "true",
+				Name:  ocistorage.OCIStorageFlag,
+				Value: "false",
 			})
+	} else {
 		args = append(args, "--oci-registry-secret", gitrepo.Spec.OCIRegistrySecret)
 	}
 
diff --git a/internal/cmd/controller/helmops/reconciler/helmop_controller.go b/internal/cmd/controller/helmops/reconciler/helmop_controller.go
index 9edb1876..aafbe359 100644
--- a/internal/cmd/controller/helmops/reconciler/helmop_controller.go
+++ b/internal/cmd/controller/helmops/reconciler/helmop_controller.go
@@ -4,8 +4,7 @@ import (
 	"context"
 	"errors"
 	"fmt"
-	"os"
-	"strconv"
+	"strings"
 	"time"
 
 	"k8s.io/apimachinery/pkg/api/equality"
@@ -78,9 +77,6 @@ func (r *HelmOpReconciler) SetupWithManager(mgr ctrl.Manager) error {
 // For more details, check Reconcile and its Result here:
 // - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.15.0/pkg/reconcile
 func (r *HelmOpReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
-	if !experimentalHelmOpsEnabled() {
-		return ctrl.Result{}, fmt.Errorf("HelmOp resource was found but env variable EXPERIMENTAL_HELM_OPS is not set to true")
-	}
 	logger := log.FromContext(ctx).WithName("HelmOp")
 	helmop := &fleet.HelmOp{}
 
@@ -111,10 +107,7 @@ func (r *HelmOpReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctr
 			}
 		}
 
-		err := r.Scheduler.DeleteJob(jobKey(*helmop))
-		if errors.Is(err, quartz.ErrJobNotFound) { // ignore error in this case
-			err = nil
-		}
+		err := r.deletePollingJob(logger, *helmop)
 
 		return ctrl.Result{}, err
 	}
@@ -128,16 +121,19 @@ func (r *HelmOpReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctr
 		return ctrl.Result{Requeue: true}, nil
 	}
 
+	if err := validate(ctx, *helmop); err != nil {
+		if delErr := r.deletePollingJob(logger, *helmop); delErr != nil {
+			err = errutil.NewAggregate([]error{err, delErr})
+		}
+		return ctrl.Result{}, updateErrorStatusHelm(ctx, r.Client, req.NamespacedName, helmop, err)
+	}
+
 	// Reconciling
 	logger = logger.WithValues("generation", helmop.Generation, "chart", helmop.Spec.Helm.Chart)
 	ctx = log.IntoContext(ctx, logger)
 
 	logger.V(1).Info("Reconciling HelmOp")
 
-	if helmop.Spec.Helm.Chart == "" {
-		return ctrl.Result{}, nil
-	}
-
 	if _, err := r.createUpdateBundle(ctx, helmop); err != nil {
 		return ctrl.Result{}, updateErrorStatusHelm(ctx, r.Client, req.NamespacedName, helmop, err)
 	}
@@ -284,6 +280,22 @@ func (r *HelmOpReconciler) handleVersion(ctx context.Context, oldBundle *fleet.B
 	return nil
 }
 
+// deletePollingJob deletes the polling job scheduled for the provided helmop, if any, and returns any error that may
+// have happened in the process.
+// Returns a nil error if the job could be deleted or if none existed.
+func (r *HelmOpReconciler) deletePollingJob(logger logr.Logger, helmop fleet.HelmOp) error {
+	jobKey := jobKey(helmop)
+	if _, err := r.Scheduler.GetScheduledJob(jobKey); err == nil {
+		if err = r.Scheduler.DeleteJob(jobKey); err != nil {
+			return fmt.Errorf("failed to delete outdated polling job: %w", err)
+		}
+	} else if !errors.Is(err, quartz.ErrJobNotFound) {
+		return fmt.Errorf("failed to get outdated polling job for deletion: %w", err)
+	}
+
+	return nil
+}
+
 // managePollingJob creates, updates or deletes a polling job for the provided HelmOp.
 func (r *HelmOpReconciler) managePollingJob(logger logr.Logger, helmop fleet.HelmOp) error {
 	if r.Scheduler == nil {
@@ -411,6 +423,16 @@ func usesPolling(helmop fleet.HelmOp) bool {
 		return false
 	}
 
+	// Polling does not apply to OCI and tarball charts, where no index.yaml file is available to check for new
+	// chart versions.
+	if strings.HasSuffix(strings.ToLower(helmop.Spec.Helm.Chart), ".tgz") {
+		return false
+	}
+
+	if strings.HasPrefix(strings.ToLower(helmop.Spec.Helm.Repo), "oci://") {
+		return false
+	}
+
 	// we only need to poll if the version is set to a constraint on versions, which may resolve to
 	// different available versions as the contents of the Helm repository evolves over time.
 	_, err := semver.StrictNewVersion(helmop.Spec.Helm.Version)
@@ -451,9 +473,7 @@ func updateStatus(ctx context.Context, c client.Client, req types.NamespacedName
 		}
 		t.Status.Conditions = conds
 
-		if orgErr != nil {
-			setAcceptedConditionHelm(&t.Status, orgErr)
-		}
+		setAcceptedConditionHelm(&t.Status, orgErr)
 
 		statusPatch := client.MergeFrom(orig)
 		if patchData, err := statusPatch.Data(t); err == nil && string(patchData) == "{}" {
@@ -510,13 +530,6 @@ func helmChartSpecChanged(o *fleet.HelmOptions, n *fleet.HelmOptions, statusVers
 	return false
 }
 
-// experimentalHelmOpsEnabled returns true if the EXPERIMENTAL_HELM_OPS env variable is set to true
-// returns false otherwise
-func experimentalHelmOpsEnabled() bool {
-	value, err := strconv.ParseBool(os.Getenv("EXPERIMENTAL_HELM_OPS"))
-	return err == nil && value
-}
-
 // getChartVersion fetches the latest chart version from the Helm registry referenced by helmop, and returns it.
 // If this fails, it returns an empty version along with an error.
 func getChartVersion(ctx context.Context, c client.Client, helmop fleet.HelmOp) (string, error) {
@@ -543,6 +556,46 @@ func jobKey(h fleet.HelmOp) *quartz.JobKey {
 	return quartz.NewJobKey(string(h.UID))
 }
 
+// validate checks combinations of Chart, Repo and Version fields in h's Helm options.
+// It returns an error if those options are nil, or if they don't fall under any of these categories,
+// as per https://helm.sh/docs/helm/helm_install/ :
+// * tarball URL in Chart, empty Repo, empty Version
+// * OCI reference in the Repo field, empty Chart, optional Version
+// * non-empty Repo URL, non-empty Chart name, optional Version
+func validate(ctx context.Context, h fleet.HelmOp) error {
+	if h.Spec.Helm == nil {
+		return fmt.Errorf("helm options are empty in the HelmOp's spec")
+	}
+
+	fail := func(msg string) error {
+		return fmt.Errorf("helm options invalid: %s", msg)
+	}
+
+	if strings.HasSuffix(strings.ToLower(h.Spec.Helm.Chart), ".tgz") {
+		if len(h.Spec.Helm.Repo) > 0 {
+			return fail("tarball chart with a non-empty repo field")
+		}
+
+		if len(h.Spec.Helm.Version) > 0 {
+			return fail("tarball chart with a non-empty version field")
+		}
+	} else if strings.HasPrefix(strings.ToLower(h.Spec.Helm.Repo), "oci://") {
+		if len(h.Spec.Helm.Chart) > 0 {
+			return fail("OCI repository with a non-empty chart field")
+		}
+	} else { // Expecting full reference: chart + repo + optional version
+		if len(h.Spec.Helm.Chart) == 0 {
+			return fail("non-OCI repository with an empty chart field")
+		}
+
+		if len(h.Spec.Helm.Repo) == 0 {
+			return fail("non-tarball chart with an empty repo field")
+		}
+	}
+
+	return nil
+}
+
 // helmOpTrigger is a custom trigger, implementing the quartz.Trigger interface. This trigger is
 // used to schedule jobs to be run both:
 // * periodically, after the first polling interval, as would happen with Quartz's `simpleTrigger`
diff --git a/internal/cmd/controller/helmops/reconciler/helmop_status.go b/internal/cmd/controller/helmops/reconciler/helmop_status.go
index addd0fb8..839bf972 100644
--- a/internal/cmd/controller/helmops/reconciler/helmop_status.go
+++ b/internal/cmd/controller/helmops/reconciler/helmop_status.go
@@ -63,9 +63,6 @@ func (r *HelmOpStatusReconciler) SetupWithManager(mgr ctrl.Manager) error {
 // computes status fields for the HelmOp. This status is used to
 // display information to the user.
 func (r *HelmOpStatusReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
-	if !experimentalHelmOpsEnabled() {
-		return ctrl.Result{}, nil
-	}
 	logger := log.FromContext(ctx).WithName("helmop-status")
 	helmop := &fleet.HelmOp{}
 
@@ -80,10 +77,6 @@ func (r *HelmOpStatusReconciler) Reconcile(ctx context.Context, req ctrl.Request
 		return ctrl.Result{}, nil
 	}
 
-	if helmop.Spec.Helm.Chart == "" {
-		return ctrl.Result{}, nil
-	}
-
 	logger = logger.WithValues("generation", helmop.Generation, "chart", helmop.Spec.Helm.Chart).WithValues("conditions", helmop.Status.Conditions)
 	ctx = log.IntoContext(ctx, logger)
 
diff --git a/internal/cmd/controller/helmops/reconciler/polling_job.go b/internal/cmd/controller/helmops/reconciler/polling_job.go
index ad10b510..ed939a80 100644
--- a/internal/cmd/controller/helmops/reconciler/polling_job.go
+++ b/internal/cmd/controller/helmops/reconciler/polling_job.go
@@ -163,6 +163,7 @@ func (j *helmPollingJob) pollHelm(ctx context.Context) error {
 		t.Status.LastPollingTime = metav1.Time{Time: pollingTimestamp}
 		t.Status.Version = version
 
+		condition.Cond(fleet.HelmOpAcceptedCondition).SetStatusBool(&t.Status, true)
 		condition.Cond(fleet.HelmOpPolledCondition).SetStatusBool(&t.Status, true)
 
 		statusPatch := client.MergeFrom(h)
diff --git a/internal/cmd/controller/reconciler/bundle_controller.go b/internal/cmd/controller/reconciler/bundle_controller.go
index 4c1ab199..04c4c07d 100644
--- a/internal/cmd/controller/reconciler/bundle_controller.go
+++ b/internal/cmd/controller/reconciler/bundle_controller.go
@@ -6,13 +6,14 @@ import (
 	"context"
 	"errors"
 	"fmt"
-	"os"
+	"maps"
+	"reflect"
 	"slices"
-	"strconv"
 	"time"
 
 	"github.com/Masterminds/semver/v3"
 	"github.com/rancher/fleet/internal/cmd/agent/deployer/kv"
+	fleetutil "github.com/rancher/fleet/internal/cmd/controller/errorutil"
 	"github.com/rancher/fleet/internal/cmd/controller/finalize"
 	"github.com/rancher/fleet/internal/cmd/controller/summary"
 	"github.com/rancher/fleet/internal/cmd/controller/target"
@@ -23,10 +24,11 @@ import (
 	fleet "github.com/rancher/fleet/pkg/apis/fleet.cattle.io/v1alpha1"
 	fleetevent "github.com/rancher/fleet/pkg/event"
 	"github.com/rancher/fleet/pkg/sharding"
-	"github.com/rancher/wrangler/v3/pkg/genericcondition"
+	"github.com/rancher/wrangler/v3/pkg/condition"
 
 	"github.com/go-logr/logr"
 	corev1 "k8s.io/api/core/v1"
+	"k8s.io/apimachinery/pkg/api/equality"
 	apierrors "k8s.io/apimachinery/pkg/api/errors"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/runtime"
@@ -38,6 +40,7 @@ import (
 	"sigs.k8s.io/controller-runtime/pkg/client"
 	"sigs.k8s.io/controller-runtime/pkg/controller"
 	"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
+	"sigs.k8s.io/controller-runtime/pkg/event"
 	"sigs.k8s.io/controller-runtime/pkg/handler"
 	"sigs.k8s.io/controller-runtime/pkg/log"
 	"sigs.k8s.io/controller-runtime/pkg/predicate"
@@ -81,7 +84,8 @@ func (r *BundleReconciler) SetupWithManager(mgr ctrl.Manager) error {
 		For(&fleet.Bundle{}).
 		// Note: Maybe improve with WatchesMetadata, does it have access to labels?
 		Watches(
-			// Fan out from bundledeployment to bundle
+			// Fan out from bundledeployment to bundle, this is useful to update the
+			// bundle's status fields.
 			&fleet.BundleDeployment{},
 			handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, a client.Object) []ctrl.Request {
 				bd := a.(*fleet.BundleDeployment)
@@ -105,7 +109,7 @@ func (r *BundleReconciler) SetupWithManager(mgr ctrl.Manager) error {
 			builder.WithPredicates(bundleDeploymentStatusChangedPredicate()),
 		).
 		Watches(
-			// Fan out from cluster to bundle
+			// Fan out from cluster to bundle, this is useful for targeting and templating.
 			&fleet.Cluster{},
 			handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, a client.Object) []ctrl.Request {
 				cluster := a.(*fleet.Cluster)
@@ -125,13 +129,54 @@ func (r *BundleReconciler) SetupWithManager(mgr ctrl.Manager) error {
 
 				return requests
 			}),
-			builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}),
+			builder.WithPredicates(clusterChangedPredicate()),
 		).
 		WithEventFilter(sharding.FilterByShardID(r.ShardID)).
 		WithOptions(controller.Options{MaxConcurrentReconciles: r.Workers}).
 		Complete(r)
 }
 
+// clusterChangedPredicate filters cluster events that relate to bundldeployment creation.
+func clusterChangedPredicate() predicate.Funcs {
+	return predicate.Funcs{
+		CreateFunc: func(e event.CreateEvent) bool {
+			return true
+		},
+		UpdateFunc: func(e event.UpdateEvent) bool {
+			n := e.ObjectNew.(*fleet.Cluster)
+			o := e.ObjectOld.(*fleet.Cluster)
+			// cluster deletion will eventually trigger a delete event
+			if n == nil || !n.DeletionTimestamp.IsZero() {
+				return true
+			}
+			// labels and annotations are used for templating and targeting
+			if !maps.Equal(n.Labels, o.Labels) {
+				return true
+			}
+			if !maps.Equal(n.Annotations, o.Annotations) {
+				return true
+			}
+			// spec templateValues is used in templating
+			if !reflect.DeepEqual(n.Spec, o.Spec) {
+				return true
+			}
+			// this namespace contains the bundledeployments
+			if n.Status.Namespace != o.Status.Namespace {
+				return true
+			}
+			// this namespace indicates the agent is running
+			if n.Status.Agent.Namespace != o.Status.Agent.Namespace {
+				return true
+			}
+
+			return false
+		},
+		DeleteFunc: func(e event.DeleteEvent) bool {
+			return true
+		},
+	}
+}
+
 //+kubebuilder:rbac:groups=fleet.cattle.io,resources=bundles,verbs=get;list;watch;create;update;patch;delete
 //+kubebuilder:rbac:groups=fleet.cattle.io,resources=bundles/status,verbs=get;update;patch
 //+kubebuilder:rbac:groups=fleet.cattle.io,resources=bundles/finalizers,verbs=update
@@ -179,31 +224,19 @@ func (r *BundleReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctr
 		}
 	}
 
-	// if the bundle has the helmops options set but the experimental flag is not
-	// set we don't deploy the bundle.
-	// This is to avoid intentional or accidental deployment of bundles with no
-	// resources or not well defined.
-	if bundle.Spec.HelmOpOptions != nil && !experimentalHelmOpsEnabled() {
-		return ctrl.Result{}, fmt.Errorf("bundle contains data used by helm ops but env variable EXPERIMENTAL_HELM_OPS is not set to true")
-	}
-	contentsInOCI := bundle.Spec.ContentsID != "" && ocistorage.ExperimentalOCIIsEnabled()
+	contentsInOCI := bundle.Spec.ContentsID != "" && ocistorage.OCIIsEnabled()
 	contentsInHelmChart := bundle.Spec.HelmOpOptions != nil
 
 	// Skip bundle deployment creation if the bundle is a HelmOps bundle and the configured Helm version is still a
 	// version constraint. That constraint should be resolved into a strict version by the HelmOps reconciler before bundle
 	// deployments can be created.
-	if contentsInHelmChart && bundle.Spec.Helm != nil {
-		version := bundle.Spec.Helm.Version
-
-		if _, err := semver.StrictNewVersion(version); err != nil {
-			bundle.Status.Conditions = []genericcondition.GenericCondition{
-				{
-					Type:           string(fleet.Ready),
-					Status:         corev1.ConditionFalse,
-					Message:        fmt.Sprintf("Chart version cannot be deployed; check HelmOp status for more details: %v", err),
-					LastUpdateTime: metav1.Now().UTC().Format(time.RFC3339),
-				},
-			}
+	if contentsInHelmChart && bundle.Spec.Helm != nil && len(bundle.Spec.Helm.Version) > 0 {
+		if _, err := semver.StrictNewVersion(bundle.Spec.Helm.Version); err != nil {
+			setReadyCondition(
+				&bundle.Status,
+				fmt.Errorf("chart version cannot be deployed; check HelmOp status for more details: %v", err),
+			)
+
 			err := r.updateStatus(ctx, bundleOrig, bundle)
 			return ctrl.Result{}, err
 		}
@@ -234,14 +267,7 @@ func (r *BundleReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctr
 	if err != nil {
 		// When targeting fails, we don't want to continue and we make the error message visible in
 		// the UI. For that we use a status condition of type Ready.
-		bundle.Status.Conditions = []genericcondition.GenericCondition{
-			{
-				Type:           string(fleet.Ready),
-				Status:         corev1.ConditionFalse,
-				Message:        "Targeting error: " + err.Error(),
-				LastUpdateTime: metav1.Now().UTC().Format(time.RFC3339),
-			},
-		}
+		setReadyCondition(&bundle.Status, fmt.Errorf("targeting error: %v", err))
 
 		err := r.updateStatus(ctx, bundleOrig, bundle)
 		return ctrl.Result{}, err
@@ -303,8 +329,9 @@ func (r *BundleReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctr
 			continue
 		}
 
-		// NOTE we don't use the existing BundleDeployment, we discard annotations, status, etc
-		// copy labels from Bundle as they might have changed
+		// NOTE we don't re-use the existing BundleDeployment, we discard annotations, status, etc.
+		// and copy labels from Bundle as they might have changed.
+		// However, matchedTargets target.Deployment contains existing BundleDeployments.
 		bd := target.BundleDeployment()
 
 		// No need to check the deletion timestamp here before adding a finalizer, since the bundle has just
@@ -332,25 +359,25 @@ func (r *BundleReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctr
 			bundle.Spec.HelmOpOptions != nil,
 			manifestID)
 		if err != nil {
-			return ctrl.Result{}, err
+			return ctrl.Result{}, fmt.Errorf("failed to create bundle deployment: %w", err)
 		}
 		bundleDeploymentUIDs.Insert(bd.UID)
 
 		if bd.Spec.ValuesHash != "" {
 			if err := r.createOptionsSecret(ctx, bd, options, stagedOptions); err != nil {
-				return ctrl.Result{}, err
+				return ctrl.Result{}, fmt.Errorf("failed to create options secret: %w", err)
 			}
 		} else {
 			// No values to store, delete the secret if it exists
 			if err := r.Delete(ctx, &corev1.Secret{
 				ObjectMeta: metav1.ObjectMeta{Name: bd.Name, Namespace: bd.Namespace},
 			}); err != nil && !apierrors.IsNotFound(err) {
-				return ctrl.Result{}, err
+				return ctrl.Result{}, fmt.Errorf("failed to delete options secret: %w", err)
 			}
 		}
 
 		if err := r.handleContentAccessSecrets(ctx, bundle, bd); err != nil {
-			return ctrl.Result{}, err
+			return ctrl.Result{}, fmt.Errorf("failed to clone secrets downstream: %w", err)
 		}
 	}
 
@@ -616,8 +643,8 @@ func maybePurgeOCIReferenceSecret(ctx context.Context, c client.Client, old, new
 }
 
 func (r *BundleReconciler) handleContentAccessSecrets(ctx context.Context, bundle *fleet.Bundle, bd *fleet.BundleDeployment) error {
-	contentsInOCI := bundle.Spec.ContentsID != "" && ocistorage.ExperimentalOCIIsEnabled()
-	contentsInHelmChart := bundle.Spec.HelmOpOptions != nil && experimentalHelmOpsEnabled()
+	contentsInOCI := bundle.Spec.ContentsID != "" && ocistorage.OCIIsEnabled()
+	contentsInHelmChart := bundle.Spec.HelmOpOptions != nil
 
 	if contentsInOCI {
 		return r.cloneSecret(ctx, bundle.Namespace, bundle.Spec.ContentsID, fleet.SecretTypeOCIStorage, bd)
@@ -628,13 +655,6 @@ func (r *BundleReconciler) handleContentAccessSecrets(ctx context.Context, bundl
 	return nil
 }
 
-// experimentalHelmOpsEnabled returns true if the EXPERIMENTAL_HELM_OPS env variable is set to true
-// returns false otherwise
-func experimentalHelmOpsEnabled() bool {
-	value, err := strconv.ParseBool(os.Getenv("EXPERIMENTAL_HELM_OPS"))
-	return err == nil && value
-}
-
 // updateStatus patches the status of the bundle and collects metrics upon a successful update of
 // the bundle status. It returns nil if the status update is successful, otherwise it returns an
 // error.
@@ -712,3 +732,13 @@ func batchDeleteBundleDeployments(ctx context.Context, c client.Client, list []f
 
 	return errors.Join(errs...)
 }
+
+// setCondition sets the condition and updates the timestamp, if the condition changed
+func setReadyCondition(status *fleet.BundleStatus, err error) {
+	cond := condition.Cond(fleet.Ready)
+	origStatus := status.DeepCopy()
+	cond.SetError(status, "", fleetutil.IgnoreConflict(err))
+	if !equality.Semantic.DeepEqual(origStatus, status) {
+		cond.LastUpdated(status, time.Now().UTC().Format(time.RFC3339))
+	}
+}
diff --git a/internal/cmd/controller/target/partition.go b/internal/cmd/controller/target/partition.go
index 1c4897d9..98e67beb 100644
--- a/internal/cmd/controller/target/partition.go
+++ b/internal/cmd/controller/target/partition.go
@@ -28,6 +28,7 @@ func UpdatePartitions(status *fleet.BundleStatus, allTargets []*Target) (err err
 
 	for _, partition := range partitions {
 		partition := partition // fix gosec warning regarding "Implicit memory aliasing in for loop"
+
 		for _, target := range partition.Targets {
 			// for a new bundledeployment, only stage the first maxNew (50) targets
 			if target.Deployment == nil && status.NewlyCreated < status.MaxNew {
@@ -50,10 +51,10 @@ func UpdatePartitions(status *fleet.BundleStatus, allTargets []*Target) (err err
 
 		for _, currentTarget := range partition.Targets {
 			// NOTE this will propagate the staged, merged options to the current deployment
-			updateTarget(currentTarget, status, &partition.Status)
+			updateDeploymentFromStaged(currentTarget, status, &partition.Status)
 		}
 
-		if updateStatusUnavailable(&partition.Status, partition.Targets) {
+		if updatePartitionStatus(&partition.Status, partition.Targets) {
 			status.UnavailablePartitions++
 		}
 
@@ -75,9 +76,9 @@ func maxUnavailablePartitions(partitions []partition, targets []*Target) (int, e
 	return limit(len(partitions), rollout.MaxUnavailablePartitions, &defMaxUnavailablePartitions)
 }
 
-// updateTarget will update DeploymentID and Options for the target to the
+// updateDeploymentFromStaged will update DeploymentID and Options for the target to the
 // staging values, if it's in a deployable state
-func updateTarget(t *Target, status *fleet.BundleStatus, partitionStatus *fleet.PartitionStatus) {
+func updateDeploymentFromStaged(t *Target, bundleStatus *fleet.BundleStatus, partitionStatus *fleet.PartitionStatus) {
 	if t.Deployment != nil &&
 		// Not Paused
 		!t.IsPaused() &&
@@ -86,13 +87,13 @@ func updateTarget(t *Target, status *fleet.BundleStatus, partitionStatus *fleet.
 		// Is out of sync
 		t.Deployment.Spec.DeploymentID != t.Deployment.Spec.StagedDeploymentID &&
 		// Global max unavailable not reached
-		(status.Unavailable < status.MaxUnavailable || isUnavailable(t.Deployment)) &&
+		(bundleStatus.Unavailable < bundleStatus.MaxUnavailable || isUnavailable(t.Deployment)) &&
 		// Partition max unavailable not reached
 		(partitionStatus.Unavailable < partitionStatus.MaxUnavailable || isUnavailable(t.Deployment)) {
 
 		if !isUnavailable(t.Deployment) {
 			// If this was previously available, now increment unavailable count. "Upgrading" is treated as unavailable.
-			status.Unavailable++
+			bundleStatus.Unavailable++
 			partitionStatus.Unavailable++
 		}
 		t.Deployment.Spec.DeploymentID = t.Deployment.Spec.StagedDeploymentID
diff --git a/internal/cmd/controller/target/query.go b/internal/cmd/controller/target/query.go
index 3a32c0a1..2aa7034d 100644
--- a/internal/cmd/controller/target/query.go
+++ b/internal/cmd/controller/target/query.go
@@ -88,6 +88,7 @@ func (m *Manager) getBundlesInScopeForCluster(ctx context.Context, cluster *flee
 	return bundleSet.bundles(), nil
 }
 
+// clusterGroupsForCluster returns all cluster groups that match the given cluster.
 func (m *Manager) clusterGroupsForCluster(ctx context.Context, cluster *fleet.Cluster) (result []*fleet.ClusterGroup, _ error) {
 	cgs := &fleet.ClusterGroupList{}
 	err := m.client.List(ctx, cgs, client.InNamespace(cluster.Namespace))
diff --git a/internal/cmd/controller/target/rollout.go b/internal/cmd/controller/target/rollout.go
index c3905b54..d0ef5068 100644
--- a/internal/cmd/controller/target/rollout.go
+++ b/internal/cmd/controller/target/rollout.go
@@ -18,7 +18,13 @@ func partitions(targets []*Target) ([]partition, error) {
 	return manualPartition(rollout, targets)
 }
 
-// getRollout returns the rollout strategy for the specified targets (pure function)
+// getRollout returns the rollout strategy for the specified targets (pure
+// function).
+//
+// If targets contains several elements, the rollout strategy of the first
+// element is used. If no rollout strategy is found, an empty one is created
+// and returned. This function therefore assumes that all bundles in targets
+// have the same rollout strategy.
 func getRollout(targets []*Target) *fleet.RolloutStrategy {
 	var rollout *fleet.RolloutStrategy
 	if len(targets) > 0 {
@@ -51,6 +57,10 @@ func manualPartition(rollout *fleet.RolloutStrategy, targets []*Target) ([]parti
 					continue targetLoop
 				}
 			}
+			if len(target.ClusterGroups) == 0 && matcher.Match(target.Cluster.Name, "", nil, target.Cluster.Labels) {
+				partitionTargets = append(partitionTargets, target)
+				continue targetLoop
+			}
 		}
 
 		partitions, err = appendPartition(partitions, partitionDef.Name, partitionTargets, partitionDef.MaxUnavailable, rollout.MaxUnavailable)
@@ -89,10 +99,7 @@ func autoPartition(rollout *fleet.RolloutStrategy, targets []*Target) ([]partiti
 		if len(targets) == 0 {
 			return partitions, nil
 		}
-		end := maxSize
-		if len(targets) < maxSize {
-			end = len(targets)
-		}
+		end := min(len(targets), maxSize)
 
 		partitionTargets := targets[:end]
 		name := fmt.Sprintf("Partition %d - %d", offset, offset+end)
diff --git a/internal/cmd/controller/target/status.go b/internal/cmd/controller/target/status.go
index 545c4672..b9debb03 100644
--- a/internal/cmd/controller/target/status.go
+++ b/internal/cmd/controller/target/status.go
@@ -43,19 +43,20 @@ func Unavailable(targets []*Target) (count int) {
 	return
 }
 
-// updateStatusUnavailable recomputes and sets the status.Unavailable counter and returns true if the partition
-// is unavailable, eg. there are more unavailable targets than the maximum set (does not mutate targets)
-func updateStatusUnavailable(status *fleet.PartitionStatus, targets []*Target) bool {
+// updatePartitionStatus recomputes and sets the status.Unavailable counter
+// and returns true if the partition is unavailable, e.g. there are more
+// unavailable targets than the maximum set (does not mutate targets)
+func updatePartitionStatus(partitionStatus *fleet.PartitionStatus, targets []*Target) bool {
 	// Unavailable for a partition is stricter than unavailable for a target.
-	// For a partition a target must be available and update to date.
-	status.Unavailable = 0
+	// For a partition a target must be available and up-to-date.
+	partitionStatus.Unavailable = 0
 	for _, target := range targets {
 		if !upToDate(target) || isUnavailable(target.Deployment) {
-			status.Unavailable++
+			partitionStatus.Unavailable++
 		}
 	}
 
-	return status.Unavailable > status.MaxUnavailable
+	return partitionStatus.Unavailable > partitionStatus.MaxUnavailable
 }
 
 // upToDate returns true if the target is up to date (pure function)
@@ -70,7 +71,9 @@ func upToDate(target *Target) bool {
 	return true
 }
 
-// isUnavailable checks if target is not available (pure function)
+// isUnavailable checks if target is unavailable (pure function). If no target
+// is provided, it returns false, assuming that a nil target is always
+// available.
 func isUnavailable(target *fleet.BundleDeployment) bool {
 	if target == nil {
 		return false
@@ -79,8 +82,15 @@ func isUnavailable(target *fleet.BundleDeployment) bool {
 		!target.Status.Ready
 }
 
+// limit calculates the maximum number of unavailable items. It uses the first
+// non-nil value from the provided values. If no value is provided, it defaults
+// to a predefined limit. If a percentage is provided, it calculates the
+// percentage of the total count of items. If the percentage is less than or
+// equal to zero, it defaults to 1.
+//
+// The resulting percentage is rounded down to the nearest integer.
 func limit(count int, val ...*intstr.IntOrString) (int, error) {
-	if count == 0 {
+	if count <= 0 {
 		return 1, nil
 	}
 
@@ -98,6 +108,13 @@ func limit(count int, val ...*intstr.IntOrString) (int, error) {
 	}
 
 	if maxUnavailable.Type == intstr.Int {
+		i := maxUnavailable.IntValue()
+		if i > count {
+			return count, nil
+		}
+		if i < 0 {
+			return 0, nil
+		}
 		return maxUnavailable.IntValue(), nil
 	}
 
@@ -123,6 +140,9 @@ func limit(count int, val ...*intstr.IntOrString) (int, error) {
 	if i <= 0 {
 		return 1, nil
 	}
+	if i > count {
+		return count, nil
+	}
 
 	return i, nil
 }
diff --git a/internal/cmd/controller/target/target.go b/internal/cmd/controller/target/target.go
index b0383fb5..d973d28a 100644
--- a/internal/cmd/controller/target/target.go
+++ b/internal/cmd/controller/target/target.go
@@ -37,6 +37,9 @@ func BundleFromDeployment(labels map[string]string) (string, string) {
 		labels[fleet.BundleLabel]
 }
 
+// Target represents a bundle deployment target, encapsulating all relevant
+// information about the deployment, associated cluster groups, cluster,
+// bundle, deployment options, and deployment identifier.
 type Target struct {
 	Deployment    *fleet.BundleDeployment
 	ClusterGroups []*fleet.ClusterGroup
@@ -46,7 +49,7 @@ type Target struct {
 	DeploymentID  string
 }
 
-// BundleDeployment returns a new bd, it discards annotations, status, etc.
+// BundleDeployment returns a new BundleDeployment, it discards annotations, status, etc.
 // The labels are copied from the Bundle.
 func (t *Target) BundleDeployment() *fleet.BundleDeployment {
 	bd := &fleet.BundleDeployment{
diff --git a/internal/config/overrides.go b/internal/config/overrides.go
index f48c6d41..60086704 100644
--- a/internal/config/overrides.go
+++ b/internal/config/overrides.go
@@ -6,20 +6,37 @@ import (
 	"github.com/sirupsen/logrus"
 )
 
+const (
+	envVarSSLCertFile = "SSL_CERT_FILE"
+	envVarSSLCertDir  = "SSL_CERT_DIR"
+)
+
 // BypassSystemCAStore is used to bypass the OS trust store in agents through env vars, see
 // https://pkg.go.dev/crypto/x509#SystemCertPool for more info.
 // We set values to paths belonging to the root filesystem, which is read-only, to prevent tampering.
 // Eventually, this should not be necessary, if/when we find a way to set client-go's API Config to achieve similar
 // effects.
 // Note: this will not work on Windows nor Mac OS. Agents are expected to run on Linux nodes.
-func BypassSystemCAStore() {
-	err := os.Setenv("SSL_CERT_FILE", "/dev/null")
-	if err != nil {
-		logrus.Errorf("failed to set env var SSL_CERT_FILE: %s", err.Error())
+// Returns a function allowing the bypass to be undone.
+func BypassSystemCAStore() func() {
+	certFileBkp := os.Getenv(envVarSSLCertFile)
+	certDirBkp := os.Getenv(envVarSSLCertDir)
+
+	if err := os.Setenv(envVarSSLCertFile, "/dev/null"); err != nil {
+		logrus.Errorf("failed to set env var %s: %s", envVarSSLCertFile, err.Error())
 	}
 
-	err = os.Setenv("SSL_CERT_DIR", "/dev/null")
-	if err != nil {
-		logrus.Errorf("failed to set env var SSL_CERT_DIR: %s", err.Error())
+	if err := os.Setenv(envVarSSLCertDir, "/dev/null"); err != nil {
+		logrus.Errorf("failed to set env var %s: %s", envVarSSLCertDir, err.Error())
+	}
+
+	return func() {
+		if err := os.Setenv(envVarSSLCertFile, certFileBkp); err != nil {
+			logrus.Errorf("failed to restore env var %s: %s", envVarSSLCertFile, err.Error())
+		}
+
+		if err := os.Setenv(envVarSSLCertDir, certDirBkp); err != nil {
+			logrus.Errorf("failed to restore env var %s: %s", envVarSSLCertDir, err.Error())
+		}
 	}
 }
diff --git a/internal/ocistorage/ociwrapper.go b/internal/ocistorage/ociwrapper.go
index 328b7fc4..39d0b598 100644
--- a/internal/ocistorage/ociwrapper.go
+++ b/internal/ocistorage/ociwrapper.go
@@ -28,7 +28,7 @@ const (
 	fileType     = "application/fleet.file"
 	artifactType = "application/fleet.manifest"
 
-	OCIStorageExperimentalFlag = "EXPERIMENTAL_OCI_STORAGE"
+	OCIStorageFlag = "OCI_STORAGE"
 )
 
 type OCIOpts struct {
@@ -280,9 +280,19 @@ func (o *OCIWrapper) DeleteManifest(ctx context.Context, opts OCIOpts, id string
 	return repo.Delete(ctx, desc)
 }
 
-// ExperimentalOCIIsEnabled returns true if the EXPERIMENTAL_OCI_STORAGE env variable is set to true
-// returns false otherwise
-func ExperimentalOCIIsEnabled() bool {
-	value, err := strconv.ParseBool(os.Getenv(OCIStorageExperimentalFlag))
-	return err == nil && value
+// OCIIsEnabled returns true if the OCI_STORAGE env variable is not set or
+// if it's set to true
+func OCIIsEnabled() bool {
+	if v, ok := os.LookupEnv(OCIStorageFlag); ok {
+		value, err := strconv.ParseBool(v)
+		if err != nil {
+			// if the env variable is set to a non valid value, return true
+			// as OCI Storage is enabled by default.
+			return true
+		}
+		return value
+	}
+	// if not defined, return true.
+	// OCI Storage is enabled by default.
+	return true
 }
diff --git a/package/Dockerfile b/package/Dockerfile
index 317aa9a6..1f6a1fb8 100644
--- a/package/Dockerfile
+++ b/package/Dockerfile
@@ -1,7 +1,7 @@
 ARG BUILD_ENV=dapper
 ARG ARCH
 
-FROM --platform=linux/$ARCH registry.suse.com/bci/bci-base:15.6 AS base
+FROM --platform=linux/$ARCH registry.suse.com/bci/bci-base:15.7 AS base
 COPY package/log.sh /usr/bin/
 RUN zypper rm -y container-suseconnect && \
     zypper ar --priority=500 https://download.opensuse.org/repositories/Virtualization:containers/5.5/Virtualization:containers.repo && \
diff --git a/package/Dockerfile.agent b/package/Dockerfile.agent
index 99c4a7e9..6a4923c5 100644
--- a/package/Dockerfile.agent
+++ b/package/Dockerfile.agent
@@ -1,7 +1,7 @@
 ARG BUILD_ENV=dapper
 ARG ARCH
 
-FROM --platform=linux/$ARCH registry.suse.com/bci/bci-busybox:15.6 AS base
+FROM --platform=linux/$ARCH registry.suse.com/bci/bci-busybox:15.7 AS base
 
 FROM base AS copy_dapper
 ONBUILD ARG ARCH
diff --git a/pkg/apis/fleet.cattle.io/v1alpha1/bundle_types.go b/pkg/apis/fleet.cattle.io/v1alpha1/bundle_types.go
index 6bd82c26..0e36f71d 100644
--- a/pkg/apis/fleet.cattle.io/v1alpha1/bundle_types.go
+++ b/pkg/apis/fleet.cattle.io/v1alpha1/bundle_types.go
@@ -161,7 +161,7 @@ type BundleResource struct {
 	Encoding string `json:"encoding,omitempty"`
 }
 
-// RolloverStrategy controls the rollout of the bundle across clusters.
+// RolloutStrategy controls the rollout of the bundle across clusters.
 type RolloutStrategy struct {
 	// A number or percentage of clusters that can be unavailable during an update
 	// of a bundle. This follows the same basic approach as a deployment rollout
diff --git a/pkg/apis/fleet.cattle.io/v1alpha1/bundledeployment_types.go b/pkg/apis/fleet.cattle.io/v1alpha1/bundledeployment_types.go
index 17f069f6..bd85ba29 100644
--- a/pkg/apis/fleet.cattle.io/v1alpha1/bundledeployment_types.go
+++ b/pkg/apis/fleet.cattle.io/v1alpha1/bundledeployment_types.go
@@ -97,8 +97,8 @@ type BundleDeploymentOptions struct {
 	DeleteNamespace bool `json:"deleteNamespace,omitempty"`
 
 	//IgnoreOptions can be used to ignore fields when monitoring the bundle.
-	// +optional
-	IgnoreOptions `json:"ignore,omitempty"`
+	// +nullable
+	IgnoreOptions *IgnoreOptions `json:"ignore,omitempty"`
 
 	// CorrectDrift specifies how drift correction should work.
 	CorrectDrift *CorrectDrift `json:"correctDrift,omitempty"`
diff --git a/pkg/apis/fleet.cattle.io/v1alpha1/zz_generated.deepcopy.go b/pkg/apis/fleet.cattle.io/v1alpha1/zz_generated.deepcopy.go
index f3433732..fab85e60 100644
--- a/pkg/apis/fleet.cattle.io/v1alpha1/zz_generated.deepcopy.go
+++ b/pkg/apis/fleet.cattle.io/v1alpha1/zz_generated.deepcopy.go
@@ -174,7 +174,11 @@ func (in *BundleDeploymentOptions) DeepCopyInto(out *BundleDeploymentOptions) {
 		*out = new(DiffOptions)
 		(*in).DeepCopyInto(*out)
 	}
-	in.IgnoreOptions.DeepCopyInto(&out.IgnoreOptions)
+	if in.IgnoreOptions != nil {
+		in, out := &in.IgnoreOptions, &out.IgnoreOptions
+		*out = new(IgnoreOptions)
+		(*in).DeepCopyInto(*out)
+	}
 	if in.CorrectDrift != nil {
 		in, out := &in.CorrectDrift, &out.CorrectDrift
 		*out = new(CorrectDrift)



Test patch:
diff --git a/charts/fleet/tests/extraAnnotations_test.yaml b/charts/fleet/tests/extraAnnotations_test.yaml
index 148590ff..3ffe0cc5 100644
--- a/charts/fleet/tests/extraAnnotations_test.yaml
+++ b/charts/fleet/tests/extraAnnotations_test.yaml
@@ -45,9 +45,6 @@ tests:
       extraAnnotations.fleetController:
         test-annotation-1: testvalue1
         test-annotation-2: testvalue2
-      extraEnv:
-        - name: EXPERIMENTAL_HELM_OPS
-          value: "true"
     template: deployment_helmops.yaml
     asserts:
       - hasDocuments:
@@ -86,9 +83,6 @@ tests:
       extraAnnotations.helmops:
         test-annotation-1: testvalue1
         test-annotation-2: testvalue2
-      extraEnv:
-        - name: EXPERIMENTAL_HELM_OPS
-          value: "true"
     template: deployment_helmops.yaml
     asserts:
       - hasDocuments:
@@ -105,9 +99,6 @@ tests:
   - it: should not set extraAnnotations variables when empty
     set:
       extraAnnotations: {}
-      extraEnv:
-        - name: EXPERIMENTAL_HELM_OPS
-          value: "true"
     asserts:
       - hasDocuments:
           count: 1
diff --git a/charts/fleet/tests/extraLabels_test.yaml b/charts/fleet/tests/extraLabels_test.yaml
index 60bd2fe2..957e626b 100644
--- a/charts/fleet/tests/extraLabels_test.yaml
+++ b/charts/fleet/tests/extraLabels_test.yaml
@@ -45,9 +45,6 @@ tests:
       extraLabels.helmops:
         test-label-1: testvalue1
         test-label-2: testvalue2
-      extraEnv:
-        - name: EXPERIMENTAL_HELM_OPS
-          value: "true"
     template: deployment_helmops.yaml
     asserts:
       - hasDocuments:
@@ -65,9 +62,6 @@ tests:
   - it: should not set more labels in fleet-controller deployment when extraLabels is empty
     set:
       extraLabels: {}
-      extraEnv:
-        - name: EXPERIMENTAL_HELM_OPS
-          value: "true"
     template: deployment.yaml
     asserts:
       - hasDocuments:
@@ -84,9 +78,6 @@ tests:
   - it: should not set more labels in gitjob deployment when extraLabels is empty
     set:
       extraLabels: {}
-      extraEnv:
-        - name: EXPERIMENTAL_HELM_OPS
-          value: "true"
     template: deployment_gitjob.yaml
     asserts:
       - hasDocuments:
@@ -103,9 +94,6 @@ tests:
   - it: should not set more labels in helmops deployment when extraLabels is empty
     set:
       extraLabels: {}
-      extraEnv:
-        - name: EXPERIMENTAL_HELM_OPS
-          value: "true"
     template: deployment_helmops.yaml
     asserts:
       - hasDocuments:
diff --git a/charts/fleet/tests/helmops_controller_replica_count_test.yaml b/charts/fleet/tests/helmops_controller_replica_count_test.yaml
index 8ecb95ac..0f33bd62 100644
--- a/charts/fleet/tests/helmops_controller_replica_count_test.yaml
+++ b/charts/fleet/tests/helmops_controller_replica_count_test.yaml
@@ -5,9 +5,7 @@ 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'
+      helmops.enabled: true
     asserts:
       - isKind:
           of: Deployment
@@ -15,12 +13,10 @@ tests:
           path: spec.replicas
           value: 3
 
-  - it: should not render a document at all when 'EXPERIMENTAL_HELM_OPS' is false
+  - it: should not render a document at all when helmops.enabled is false
     set:
       helmops.replicas: 3
-      extraEnv:
-        - name: 'EXPERIMENTAL_HELM_OPS'
-          value: 'false'
+      helmops.enabled: false
     asserts:
       - hasDocuments:
           count: 0
diff --git a/e2e/multi-cluster/installation/agent_test.go b/e2e/multi-cluster/installation/agent_test.go
index 917ed0e7..5c8d6bd2 100644
--- a/e2e/multi-cluster/installation/agent_test.go
+++ b/e2e/multi-cluster/installation/agent_test.go
@@ -1,10 +1,15 @@
 package installation_test
 
 import (
+	"encoding/json"
 	"fmt"
+	"strings"
+	"time"
 
 	. "github.com/onsi/ginkgo/v2"
 	. "github.com/onsi/gomega"
+
+	"github.com/rancher/fleet/e2e/testenv"
 )
 
 var (
@@ -13,8 +18,6 @@ var (
 
 var _ = Describe("Fleet installation with TLS agent modes", func() {
 	BeforeEach(func() {
-		kd = env.Kubectl.Context(env.Downstream)
-
 		_, err := kd.Delete(
 			"pod",
 			"-n",
@@ -63,17 +66,17 @@ var _ = Describe("Fleet installation with TLS agent modes", func() {
 					g.Expect(logs).To(MatchRegexp("Failed to register agent.*could not find the requested resource"))
 
 					return nil
-				}).ShouldNot(HaveOccurred())
+				}).Should(Succeed())
 			})
 		})
 	})
 
 	Context("with strict agent TLS mode", func() {
-		When("fetching fleet-agent logs", func() {
-			BeforeEach(func() {
-				agentMode = "strict"
-			})
+		BeforeEach(func() {
+			agentMode = "strict"
+		})
 
+		When("fetching fleet-agent logs", func() {
 			It("cannot reach the server because the cert is signed by an unknown authority", func() {
 				Eventually(func(g Gomega) error {
 					logs, err := kd.Namespace("cattle-fleet-system").Logs(
@@ -93,8 +96,88 @@ var _ = Describe("Fleet installation with TLS agent modes", func() {
 					}
 
 					return nil
-				}).ShouldNot(HaveOccurred())
+				}).Should(Succeed())
+			})
+		})
+	})
+})
+
+var _ = Describe("HelmOps installation with strict TLS mode", func() {
+	BeforeEach(func() {
+		_, err := kd.Delete("pod", "-n", "cattle-fleet-system", "-l", "app=fleet-agent")
+		Expect(err).NotTo(HaveOccurred())
+	})
+
+	JustBeforeEach(func() {
+		strictCfg := strings.ReplaceAll(config, "system-store", "strict")
+
+		out, err := ku.Patch(
+			"configmap",
+			"fleet-controller",
+			"-n",
+			"cattle-fleet-system",
+			"--type=merge",
+			"-p",
+			fmt.Sprintf(`{"data":{"config":"%s"}}`, strictCfg),
+		)
+		Expect(err).ToNot(HaveOccurred(), string(out))
+
+		// Check that the config change has been applied downstream
+		type configWithTLSMode struct {
+			AgentTLSMode string `json:"agentTLSMode"`
+		}
+		Eventually(func(g Gomega) {
+			data, err := kd.Namespace("cattle-fleet-system").Get("configmap", "fleet-agent", "-o", "jsonpath={.data.config}")
+			g.Expect(err).ToNot(HaveOccurred(), data)
+
+			var c configWithTLSMode
+
+			err = json.Unmarshal([]byte(data), &c)
+			g.Expect(err).ToNot(HaveOccurred())
+
+			g.Expect(c.AgentTLSMode).To(Equal("strict"))
+		}).Should(Succeed())
+	})
+
+	When("installing a HelmOp", func() {
+		name := "basic"
+		ns := "fleet-default"
+
+		JustBeforeEach(func() {
+			ku = ku.Namespace(ns)
+			err := testenv.ApplyTemplate(ku, "../../assets/multi-cluster/helmop.yaml", struct {
+				Name                  string
+				Namespace             string
+				Repo                  string
+				Chart                 string
+				Version               string
+				PollingInterval       time.Duration
+				HelmSecretName        string
+				InsecureSkipTLSVerify bool
+			}{
+				name,
+				ns,
+				"",
+				"https://github.com/rancher/fleet/raw/refs/heads/main/integrationtests/cli/assets/helmrepository/config-chart-0.1.0.tgz",
+				"",
+				0,
+				"",
+				false,
 			})
+			Expect(err).ToNot(HaveOccurred())
+		})
+
+		It("deploys the chart", func() {
+			Eventually(func(g Gomega) {
+				outPods, err := kd.Get("configmaps")
+				g.Expect(err).ToNot(HaveOccurred())
+				g.Expect(outPods).To(ContainSubstring("test-simple-chart-config"))
+			}).Should(Succeed())
+		})
+
+		AfterEach(func() {
+			_, err := ku.Delete("helmop", name)
+			Expect(err).ToNot(HaveOccurred())
 		})
 	})
 })
diff --git a/e2e/single-cluster/helm_dependencies_test.go b/e2e/single-cluster/helm_dependencies_test.go
index 7db489a3..61833fbc 100644
--- a/e2e/single-cluster/helm_dependencies_test.go
+++ b/e2e/single-cluster/helm_dependencies_test.go
@@ -1,7 +1,6 @@
 package singlecluster_test
 
 import (
-	"fmt"
 	"math/rand"
 	"os"
 	"path"
@@ -10,7 +9,6 @@ import (
 
 	"github.com/rancher/fleet/e2e/testenv"
 	"github.com/rancher/fleet/e2e/testenv/githelper"
-	"github.com/rancher/fleet/e2e/testenv/infra/cmd"
 	"github.com/rancher/fleet/e2e/testenv/kubectl"
 
 	. "github.com/onsi/ginkgo/v2"
@@ -18,14 +16,6 @@ import (
 	cp "github.com/otiai10/copy"
 )
 
-func getChartMuseumExternalAddr() string {
-	username := os.Getenv("GIT_HTTP_USER")
-	passwd := os.Getenv("GIT_HTTP_PASSWORD")
-	Expect(username).ToNot(Equal(""))
-	Expect(passwd).ToNot(Equal(""))
-	return fmt.Sprintf("https://%s:%s@chartmuseum-service.%s.svc.cluster.local:8081", username, passwd, cmd.InfraNamespace)
-}
-
 func setupChartDepsInTmpDir(chartDir string, tmpDir string, namespace string, disableDependencyUpdate bool) {
 	err := cp.Copy(chartDir, tmpDir)
 	Expect(err).ToNot(HaveOccurred())
diff --git a/e2e/single-cluster/helmop_test.go b/e2e/single-cluster/helmop_test.go
index 0b517d21..5f7b1301 100644
--- a/e2e/single-cluster/helmop_test.go
+++ b/e2e/single-cluster/helmop_test.go
@@ -2,6 +2,7 @@ package singlecluster_test
 
 import (
 	"crypto/tls"
+	"encoding/json"
 	"fmt"
 	"math/rand"
 	"net/http"
@@ -13,7 +14,7 @@ import (
 	"github.com/rancher/fleet/e2e/testenv"
 	"github.com/rancher/fleet/e2e/testenv/infra/cmd"
 	"github.com/rancher/fleet/e2e/testenv/kubectl"
-	"github.com/rancher/fleet/e2e/testenv/zothelper"
+	fleet "github.com/rancher/fleet/pkg/apis/fleet.cattle.io/v1alpha1"
 
 	"github.com/chartmuseum/helm-push/pkg/chartmuseum"
 	. "github.com/onsi/ginkgo/v2"
@@ -177,11 +178,13 @@ var _ = Describe("HelmOp resource tests with polling", Label("infra-setup", "hel
 	})
 })
 
-var _ = Describe("HelmOp resource tests with oci registry", Label("infra-setup", "oci-registry"), func() {
+var _ = Describe("HelmOp resource tests with OCI registry", Label("infra-setup", "oci-registry"), func() {
 	var (
 		namespace string
 		name      string
+		repo      string
 		insecure  bool
+		ociRef    = getZotInternalRef()
 		k         kubectl.Command
 	)
 
@@ -205,9 +208,6 @@ var _ = Describe("HelmOp resource tests with oci registry", Label("infra-setup",
 		}
 		Expect(err).ToNot(HaveOccurred(), out)
 
-		ociRef, err := zothelper.GetOCIReference(k)
-		Expect(err).ToNot(HaveOccurred(), ociRef)
-
 		err = testenv.ApplyTemplate(k, testenv.AssetPath("helmop/helmop.yaml"), struct {
 			Name                  string
 			Namespace             string
@@ -220,8 +220,8 @@ var _ = Describe("HelmOp resource tests with oci registry", Label("infra-setup",
 		}{
 			name,
 			namespace,
+			repo,
 			"",
-			fmt.Sprintf("%s/sleeper-chart", ociRef),
 			0,
 			helmOpsSecretName,
 			insecure,
@@ -243,16 +243,18 @@ var _ = Describe("HelmOp resource tests with oci registry", Label("infra-setup",
 				namespace = "helmop-ns"
 				name = "basic-oci"
 				insecure = true
+
+				repo = fmt.Sprintf("%s/sleeper-chart", ociRef)
 			})
 			It("deploys the chart", func() {
-				Eventually(func() bool {
+				Eventually(func(g Gomega) {
 					outPods, _ := k.Namespace(namespace).Get("pods")
-					return strings.Contains(outPods, "sleeper-")
-				}).Should(BeTrue())
-				Eventually(func() bool {
+					g.Expect(outPods).To(ContainSubstring("sleeper-"))
+				}).Should(Succeed())
+				Eventually(func(g Gomega) {
 					outDeployments, _ := k.Namespace(namespace).Get("deployments")
-					return strings.Contains(outDeployments, "sleeper")
-				}).Should(BeTrue())
+					g.Expect(outDeployments).To(ContainSubstring("sleeper"))
+				}).Should(Succeed())
 			})
 		})
 		Context("containing a valid helmop description pointing to an oci registry and not TLS", func() {
@@ -260,6 +262,8 @@ var _ = Describe("HelmOp resource tests with oci registry", Label("infra-setup",
 				namespace = "helmop-ns2"
 				name = "basic-oci-no-tls"
 				insecure = false
+
+				repo = fmt.Sprintf("%s/sleeper-chart", ociRef)
 			})
 			It("does not deploy the chart because of TLS", func() {
 				Consistently(func() string {
@@ -269,6 +273,133 @@ var _ = Describe("HelmOp resource tests with oci registry", Label("infra-setup",
 			})
 		})
 	})
+
+	When("applying a helmop resource which cannot be deployed", func() {
+		Context("containing a helmop description pointing to an OCI registry using the wrong field", func() {
+			BeforeEach(func() {
+				namespace = "helmop-ns"
+				name = "basic-oci-invalid"
+				insecure = true
+
+				repo = fmt.Sprintf("%s/sleeper-chart-will-not-be-found", ociRef)
+			})
+			It("fails visibly", func() {
+				By("not deploying the chart")
+				Consistently(func(g Gomega) {
+					outPods, _ := k.Namespace(namespace).Get("pods")
+					g.Expect(outPods).NotTo(ContainSubstring("sleeper-"))
+
+					outDeployments, _ := k.Namespace(namespace).Get("deployments")
+					g.Expect(outDeployments).NotTo(ContainSubstring("sleeper"))
+				}, 5*time.Second, time.Second).Should(Succeed())
+
+				By("displaying the reason for the failure in the HelmOps' status")
+				Eventually(func(g Gomega) {
+					st, err := k.Get("helmop", name, "-o=jsonpath={.status}")
+					g.Expect(err).ToNot(HaveOccurred())
+
+					var status fleet.StatusBase
+					err = json.Unmarshal([]byte(st), &status)
+					g.Expect(err).ToNot(HaveOccurred())
+
+					g.Expect(status.Conditions).ToNot(BeEmpty())
+
+					var foundReady, foundAccepted bool
+					for _, cond := range status.Conditions {
+						if cond.Type == "Ready" {
+							foundReady = true
+
+							g.Expect(string(cond.Status)).To(Equal("False"))
+							g.Expect(cond.Message).To(ContainSubstring("ErrApplied"))
+							g.Expect(cond.Message).To(ContainSubstring("not found"))
+						}
+
+						if cond.Type == "Accepted" {
+							foundAccepted = true
+							g.Expect(string(cond.Status)).To(Equal("True"))
+						}
+					}
+					g.Expect(foundAccepted).To(BeTrue())
+					g.Expect(foundReady).To(BeTrue())
+				}).Should(Succeed())
+			})
+		})
+	})
+})
+
+var _ = Describe("HelmOp resource tests with tarball source", Label("infra-setup", "helm-registry"), func() {
+	var (
+		namespace string
+		name      string
+		insecure  = true
+		k         kubectl.Command
+		version   string
+	)
+
+	BeforeEach(func() {
+		k = env.Kubectl.Namespace(env.Namespace)
+	})
+
+	JustBeforeEach(func() {
+		namespace = testenv.NewNamespaceName(
+			name,
+			rand.New(rand.NewSource(time.Now().UnixNano())),
+		)
+
+		out, err := k.Create(
+			"secret", "generic", helmOpsSecretName,
+			"--from-literal=username="+os.Getenv("CI_OCI_USERNAME"),
+			"--from-literal=password="+os.Getenv("CI_OCI_PASSWORD"),
+		)
+		Expect(err).ToNot(HaveOccurred(), out)
+
+		err = testenv.ApplyTemplate(k, testenv.AssetPath("helmop/helmop.yaml"), struct {
+			Name                  string
+			Namespace             string
+			Repo                  string
+			Chart                 string
+			PollingInterval       time.Duration
+			HelmSecretName        string
+			InsecureSkipTLSVerify bool
+			Version               string
+		}{
+			name,
+			namespace,
+			"",
+			fmt.Sprintf("%s/charts/sleeper-chart-0.1.0.tgz", getChartMuseumExternalAddr()),
+			0,
+			helmOpsSecretName,
+			insecure,
+			version,
+		})
+		Expect(err).ToNot(HaveOccurred(), out)
+	})
+
+	AfterEach(func() {
+		out, err := k.Delete("helmop", name)
+		Expect(err).ToNot(HaveOccurred(), out)
+		out, err = k.Delete("secret", helmOpsSecretName)
+		Expect(err).ToNot(HaveOccurred(), out)
+	})
+
+	// Other version combinations are tested in HelmOps controller unit test
+	When("applying a helmop resource without a version", func() {
+		BeforeEach(func() {
+			namespace = "helmop-tarball-ns-no-version"
+			name = "basic-helmop"
+			version = ""
+		})
+		It("deploys the chart", func() {
+			Eventually(func(g Gomega) {
+				outPods, _ := k.Namespace(namespace).Get("pods")
+				g.Expect(outPods).To(ContainSubstring("sleeper-"))
+			}).Should(Succeed())
+			Eventually(func(g Gomega) {
+				outDeployments, _ := k.Namespace(namespace).Get("deployments")
+				g.Expect(outDeployments).To(ContainSubstring("sleeper"))
+			}).Should(Succeed())
+		})
+	})
 })
 
 // getExternalHelmAddr retrieves the external URL where our local Helm registry can be reached.
diff --git a/e2e/single-cluster/oci_registry_test.go b/e2e/single-cluster/oci_registry_test.go
index 10a7b7d6..c3033198 100644
--- a/e2e/single-cluster/oci_registry_test.go
+++ b/e2e/single-cluster/oci_registry_test.go
@@ -84,20 +84,22 @@ func createDefaultOCIRegistrySecret(
 // getActualEnvVariable returns the value of the given env variable
 // in the gitjob and fleet-controller deployments.
 // In order to be considered correct, both values should match
-func getActualEnvVariable(k kubectl.Command, env string) bool {
+func getActualEnvVariable(k kubectl.Command, env string) string {
 	actualValueController, err := checkEnvVariable(k, "deployment/fleet-controller", env)
 	Expect(err).ToNot(HaveOccurred())
 	actualValueGitjob, err := checkEnvVariable(k, "deployment/gitjob", env)
 	Expect(err).ToNot(HaveOccurred())
 	// both values should be the same, otherwise is a clear symptom that something went wrong
 	Expect(actualValueController).To(Equal(actualValueGitjob))
-	GinkgoWriter.Printf("Actual experimental env variable values: CONTROLLER: %t, GITJOB: %t\n", actualValueController, actualValueGitjob)
+	GinkgoWriter.Printf("Actual env variable values: CONTROLLER: %q, GITJOB: %q\n", actualValueController, actualValueGitjob)
 	return actualValueController
 }
 
 // checkEnvVariable runs a kubectl set env --list command for the given component
-// and returns the value of the given env variable
-func checkEnvVariable(k kubectl.Command, component string, env string) (bool, error) {
+// and returns the value of the given env variable.
+// The value returned is a string representing the boolean value or "unset" if the env
+// variable is not set.
+func checkEnvVariable(k kubectl.Command, component string, env string) (string, error) {
 	ns := "cattle-fleet-system"
 	out, err := k.Namespace(ns).Run("set", "env", component, "--list")
 	Expect(err).ToNot(HaveOccurred())
@@ -106,19 +108,23 @@ func checkEnvVariable(k kubectl.Command, component string, env string) (bool, er
 		if strings.Contains(line, env) {
 			keyValue := strings.Split(line, "=")
 			Expect(keyValue).To(HaveLen(2))
-			return strconv.ParseBool(keyValue[1])
+			boolVal, err := strconv.ParseBool(keyValue[1])
+			if err != nil {
+				return "", err
+			}
+			return fmt.Sprintf("%t", boolVal), nil
 		}
 	}
-	return false, fmt.Errorf("Environment variable was not found")
+	return "unset", nil
 }
 
-// updateExperimentalFlagValue updates the oci storage experimental flag to the given value.
+// updateOCIStorageFlagValue updates the oci storage env variable to the given value.
 // It compares the actual value vs the one given and, if they are different, updates the
 // fleet-controller and gitjob deployments to set the given one.
 // It waits until the pods related to both deployments are restarted.
-func updateExperimentalFlagValue(k kubectl.Command, value bool) {
-	actualEnvVal := getActualEnvVariable(k, ocistorage.OCIStorageExperimentalFlag)
-	GinkgoWriter.Printf("Env variable value to be used in this test: %t\n", value)
+func updateOCIStorageFlagValue(k kubectl.Command, value string) {
+	actualEnvVal := getActualEnvVariable(k, ocistorage.OCIStorageFlag)
+	GinkgoWriter.Printf("Env variable value to be used in this test: %q\n", value)
 	if actualEnvVal == value {
 		return
 	}
@@ -140,44 +146,49 @@ func updateExperimentalFlagValue(k kubectl.Command, value bool) {
 		return gitjobPod
 	}).WithTimeout(time.Second * 60).Should(ContainSubstring("pod/gitjob-"))
 
-	// set the experimental env value to the deployments
-	strEnvValue := fmt.Sprintf("%s=%t", ocistorage.OCIStorageExperimentalFlag, value)
+	// set the env value to the deployments
+	strEnvValue := ""
+	if value == "unset" {
+		// if the value is unset it adds a '-' at the end so kubectl deletes the env variable.
+		strEnvValue = fmt.Sprintf("%s-", ocistorage.OCIStorageFlag)
+	} else {
+		strEnvValue = fmt.Sprintf("%s=%s", ocistorage.OCIStorageFlag, value)
+	}
 	_, _ = k.Namespace(ns).Run("set", "env", "deployment/fleet-controller", strEnvValue)
 	_, _ = k.Namespace(ns).Run("set", "env", "deployment/gitjob", strEnvValue)
 
-	// wait for both pods to restart with the right value for the experimental feature env var
+	// wait for both pods to restart with the right value for the feature env var
 	Eventually(func(g Gomega) {
-		out, err := k.Namespace(ns).Get(
-			"pod",
-			"-l", "app=fleet-controller,fleet.cattle.io/shard-default=true",
-			"-o", fmt.Sprintf(`jsonpath={range .items[*]}{.metadata.name}{"\t"}{.spec.containers[0].env[?(@.name=="%s")].value}{end}`, ocistorage.OCIStorageExperimentalFlag),
-		)
 		g.Expect(err).ToNot(HaveOccurred())
 
-		elts := strings.Split(out, "\t")
-		controllerPodNow, valueNow := elts[0], elts[1]
+		labelSelector := client.MatchingLabels{
+			"app":                           "fleet-controller",
+			"fleet.cattle.io/shard-default": "true",
+		}
+		valueNow, controllerPodNow, err := k8sclient.GetPodEnvVariable(clientUpstream, ocistorage.OCIStorageFlag, labelSelector)
+		g.Expect(err).ToNot(HaveOccurred())
+
 		g.Expect(controllerPodNow).ToNot(BeEmpty())
 		g.Expect(controllerPodNow).To(ContainSubstring("fleet-controller-"))
 		g.Expect(controllerPodNow).NotTo(ContainSubstring(controllerPod))
 
-		g.Expect(valueNow).To(Equal(fmt.Sprintf("%t", value)))
+		g.Expect(valueNow).To(Equal(value))
 	}).WithTimeout(time.Second * 30).Should(Succeed())
 
 	Eventually(func(g Gomega) {
-		out, err := k.Namespace(ns).Get(
-			"pod",
-			"-l", "app=gitjob,fleet.cattle.io/shard-default=true",
-			"-o", fmt.Sprintf(`jsonpath={range .items[*]}{.metadata.name}{"\t"}{.spec.containers[0].env[?(@.name=="%s")].value}{end}`, ocistorage.OCIStorageExperimentalFlag),
-		)
+		labelSelector := client.MatchingLabels{
+			"app":                           "gitjob",
+			"fleet.cattle.io/shard-default": "true",
+		}
+
+		valueNow, gitjobPodNow, err := k8sclient.GetPodEnvVariable(clientUpstream, ocistorage.OCIStorageFlag, labelSelector)
 		g.Expect(err).ToNot(HaveOccurred())
 
-		elts := strings.Split(out, "\t")
-		gitjobPodNow, valueNow := elts[0], elts[1]
 		g.Expect(gitjobPodNow).ToNot(BeEmpty())
 		g.Expect(gitjobPodNow).To(ContainSubstring("gitjob-"))
 		g.Expect(gitjobPodNow).NotTo(ContainSubstring(gitjobPod))
 
-		g.Expect(valueNow).To(Equal(fmt.Sprintf("%t", value)))
+		g.Expect(valueNow).To(Equal(value))
 	}).WithTimeout(time.Second * 30).Should(Succeed())
 }
 
@@ -224,17 +235,17 @@ func getOCIRegistryExternalIP(k kubectl.Command) string {
 var _ = Describe("Single Cluster Deployments using OCI registry", Label("oci-registry", "infra-setup"), func() {
 	const asset = "single-cluster/test-oci.yaml"
 	var (
-		k                      kubectl.Command
-		tempDir                string
-		contentsID             string
-		downstreamNamespace    string
-		defaultOCIRegistry     string
-		ociRegistry            string
-		experimentalFlagBefore bool
-		contentIDToPurge       string
+		k                   kubectl.Command
+		tempDir             string
+		contentsID          string
+		downstreamNamespace string
+		defaultOCIRegistry  string
+		ociRegistry         string
+		envVarValueBefore   string
+		contentIDToPurge    string
 
 		// user-defined variables for defining the test behaviour
-		experimentalValue         bool
+		envVarValue               string
 		insecureSkipTLS           bool
 		deployDefaultSecret       bool
 		deploySpecificSecretName  string
@@ -252,9 +263,9 @@ var _ = Describe("Single Cluster Deployments using OCI registry", Label("oci-reg
 		defaultOCIRegistry = fmt.Sprintf("%s:8082", externalIP)
 		ociRegistry = defaultOCIRegistry
 
-		// store the actual value of the experimental env value
+		// store the actual value of the env value
 		// we'll restore in the AfterEach statement if needed
-		experimentalFlagBefore = getActualEnvVariable(k, ocistorage.OCIStorageExperimentalFlag)
+		envVarValueBefore = getActualEnvVariable(k, ocistorage.OCIStorageFlag)
 
 		// reset the value of the contents resource to purge
 		contentIDToPurge = ""
@@ -264,7 +275,7 @@ var _ = Describe("Single Cluster Deployments using OCI registry", Label("oci-reg
 		contentsID = ""
 		contentIDToPurge = ""
 
-		updateExperimentalFlagValue(k, experimentalValue)
+		updateOCIStorageFlagValue(k, envVarValue)
 
 		Expect(os.Getenv("CI_OCI_USERNAME")).NotTo(BeEmpty())
 		Expect(os.Getenv("CI_OCI_PASSWORD")).NotTo(BeEmpty())
@@ -351,10 +362,10 @@ var _ = Describe("Single Cluster Deployments using OCI registry", Label("oci-reg
 			return out
 		}).ShouldNot(ContainSubstring("secret/s-"))
 
-		// reset back experimental flag value if needed
-		if experimentalValue != experimentalFlagBefore {
-			GinkgoWriter.Printf("Restoring experimental env variable back to %t n", experimentalFlagBefore)
-			updateExperimentalFlagValue(k, experimentalFlagBefore)
+		// reset back env var value if needed
+		if envVarValue != envVarValueBefore {
+			GinkgoWriter.Printf("Restoring env variable back to %q\n", envVarValueBefore)
+			updateOCIStorageFlagValue(k, envVarValueBefore)
 		}
 
 		// check that contents have been purged when deleting the gitrepo
@@ -374,7 +385,7 @@ var _ = Describe("Single Cluster Deployments using OCI registry", Label("oci-reg
 	When("applying a gitrepo with the default ociSecret also deployed", func() {
 		Context("containing a valid helm chart", func() {
 			BeforeEach(func() {
-				experimentalValue = true
+				envVarValue = "unset"
 				insecureSkipTLS = true
 				deployDefaultSecret = true
 				deploySpecificSecretName = ""
@@ -428,7 +439,7 @@ var _ = Describe("Single Cluster Deployments using OCI registry", Label("oci-reg
 	When("applying a gitrepo with the default ociSecret with wrong reference and the user-defined secret also deployed with correct values", func() {
 		Context("containing a valid helm chart", func() {
 			BeforeEach(func() {
-				experimentalValue = true
+				envVarValue = "true"
 				insecureSkipTLS = true
 				deployDefaultSecret = true
 				deploySpecificSecretName = "test-secret"
@@ -472,10 +483,10 @@ var _ = Describe("Single Cluster Deployments using OCI registry", Label("oci-reg
 		})
 	})
 
-	When("applying a gitrepo with the default ociSecret also deployed with incorrect reference and experimental flag is not set", func() {
+	When("applying a gitrepo with the default ociSecret also deployed with incorrect reference and OCI env var is not set", func() {
 		Context("containing a public oci based helm chart", func() {
 			BeforeEach(func() {
-				experimentalValue = false
+				envVarValue = "false"
 				insecureSkipTLS = true
 				deployDefaultSecret = true
 				deploySpecificSecretName = ""
@@ -526,7 +537,7 @@ var _ = Describe("Single Cluster Deployments using OCI registry", Label("oci-reg
 	When("creating a gitrepo resource with invalid ociRegistry info", func() {
 		Context("containing a public oci based helm chart", func() {
 			BeforeEach(func() {
-				experimentalValue = true
+				envVarValue = "true"
 				insecureSkipTLS = true
 				deployDefaultSecret = true
 				deploySpecificSecretName = ""
@@ -572,7 +583,7 @@ var _ = Describe("Single Cluster Deployments using OCI registry", Label("oci-reg
 	When("creating a gitrepo resource with valid ociRegistry but not ignoring certs", func() {
 		Context("containing a public oci based helm chart", func() {
 			BeforeEach(func() {
-				experimentalValue = true
+				envVarValue = "true"
 				insecureSkipTLS = false
 				deployDefaultSecret = true
 				deploySpecificSecretName = ""
@@ -618,7 +629,7 @@ var _ = Describe("Single Cluster Deployments using OCI registry", Label("oci-reg
 	When("applying a gitrepo and the OCI artifact is changed with invalid contents", func() {
 		Context("containing a valid helm chart", func() {
 			BeforeEach(func() {
-				experimentalValue = true
+				envVarValue = "true"
 				insecureSkipTLS = true
 				deployDefaultSecret = true
 				deploySpecificSecretName = ""
@@ -690,7 +701,7 @@ var _ = Describe("Single Cluster Deployments using OCI registry", Label("oci-reg
 	When("creating a gitrepo resource using an oci registry secret with a username that has no write permissions", func() {
 		Context("containing a public oci based helm chart", func() {
 			BeforeEach(func() {
-				experimentalValue = true
+				envVarValue = "true"
 				insecureSkipTLS = true
 				deployDefaultSecret = true
 				deploySpecificSecretName = ""
@@ -736,7 +747,7 @@ var _ = Describe("Single Cluster Deployments using OCI registry", Label("oci-reg
 	When("applying a gitrepo and then applying a new one with different contents and the same name", func() {
 		Context("containing a valid helm chart and later some raw contents", func() {
 			BeforeEach(func() {
-				experimentalValue = true
+				envVarValue = "true"
 				insecureSkipTLS = true
 				deployDefaultSecret = true
 				deploySpecificSecretName = ""
@@ -833,7 +844,7 @@ var _ = Describe("Single Cluster Deployments using OCI registry", Label("oci-reg
 	When("applying a gitrepo and then applying a new one with different contents and the same name", func() {
 		Context("containing a valid helm chart and later some raw contents and using a user that has no delete permissions", func() {
 			BeforeEach(func() {
-				experimentalValue = true
+				envVarValue = "true"
 				insecureSkipTLS = true
 				deployDefaultSecret = true
 				deploySpecificSecretName = ""
diff --git a/e2e/single-cluster/status_test.go b/e2e/single-cluster/status_test.go
index ef4d66a2..97547515 100644
--- a/e2e/single-cluster/status_test.go
+++ b/e2e/single-cluster/status_test.go
@@ -206,7 +206,7 @@ var _ = Describe("Checks that template errors are shown in bundles and gitrepos"
 		for _, condition := range conditions {
 			if condition.Type == string(fleet.Ready) {
 				g.Expect(condition.Status).To(Equal(corev1.ConditionFalse))
-				g.Expect(condition.Message).To(ContainSubstring("Targeting error"))
+				g.Expect(condition.Message).To(ContainSubstring("targeting error"))
 				g.Expect(condition.Message).To(
 					ContainSubstring(
 						"<.ClusterLabels.foo>: map has no entry for key \"foo\""))
diff --git a/e2e/single-cluster/suite_test.go b/e2e/single-cluster/suite_test.go
index 3a0600aa..be77362b 100644
--- a/e2e/single-cluster/suite_test.go
+++ b/e2e/single-cluster/suite_test.go
@@ -2,6 +2,8 @@
 package singlecluster_test
 
 import (
+	"fmt"
+	"os"
 	"testing"
 	"time"
 
@@ -14,6 +16,7 @@ import (
 	"k8s.io/client-go/tools/clientcmd"
 
 	"github.com/rancher/fleet/e2e/testenv"
+	"github.com/rancher/fleet/e2e/testenv/infra/cmd"
 	fleet "github.com/rancher/fleet/pkg/apis/fleet.cattle.io/v1alpha1"
 	"sigs.k8s.io/controller-runtime/pkg/client"
 )
@@ -67,3 +70,15 @@ func getClientForContext(contextName string) client.Client {
 
 	return c
 }
+
+func getChartMuseumExternalAddr() string {
+	username := os.Getenv("GIT_HTTP_USER")
+	passwd := os.Getenv("GIT_HTTP_PASSWORD")
+	Expect(username).ToNot(Equal(""))
+	Expect(passwd).ToNot(Equal(""))
+	return fmt.Sprintf("https://%s:%s@chartmuseum-service.%s.svc.cluster.local:8081", username, passwd, cmd.InfraNamespace)
+}
+
+func getZotInternalRef() string {
+	return fmt.Sprintf("oci://zot-service.%s.svc.cluster.local:8082", cmd.InfraNamespace)
+}
diff --git a/e2e/testenv/k8sclient/k8sclient.go b/e2e/testenv/k8sclient/k8sclient.go
index f3adb5f5..2649eab1 100644
--- a/e2e/testenv/k8sclient/k8sclient.go
+++ b/e2e/testenv/k8sclient/k8sclient.go
@@ -8,6 +8,8 @@ import (
 	"sigs.k8s.io/controller-runtime/pkg/client"
 
 	. "github.com/onsi/gomega"
+
+	corev1 "k8s.io/api/core/v1"
 )
 
 // GetObjectShouldSucceed gets the object identified by name and namespace and ensures it succeeds
@@ -49,3 +51,32 @@ func ObjectShouldNotExist(c client.Client, name, namespace string, obj client.Ob
 		Expect(errors.IsNotFound(err)).To(BeTrue())
 	}
 }
+
+// GetPodEnvVariable returns the value of the given env variable in the first container of the first pod
+// that matches the given labelSelector.
+// Returns the pod name, the value of the env variable and error (if any).
+// Returns a NotFound error in case of not finding any matching pod or if the first pod has no containers.
+// Returns the value of the env variable or "unset" if it was not found in the container.
+func GetPodEnvVariable(c client.Client, envVarName string, labelSelector client.MatchingLabels) (string, string, error) {
+	podList := &corev1.PodList{}
+	err := c.List(context.TODO(), podList, labelSelector)
+	if err != nil {
+		return "", "", err
+	}
+	if len(podList.Items) == 0 {
+		return "", "", errors.NewNotFound(corev1.Resource("pods"), "no pods found")
+	}
+
+	pod := podList.Items[0]
+	if len(pod.Spec.Containers) == 0 {
+		return "", "", errors.NewNotFound(corev1.Resource("containers"), "no containers found")
+	}
+
+	envVars := pod.Spec.Containers[0].Env
+	for _, env := range envVars {
+		if env.Name == envVarName {
+			return env.Value, pod.Name, nil
+		}
+	}
+	return "unset", pod.Name, nil
+}
diff --git a/integrationtests/cli/apply/apply_online_test.go b/integrationtests/cli/apply/apply_online_test.go
index fc836691..364a1d30 100644
--- a/integrationtests/cli/apply/apply_online_test.go
+++ b/integrationtests/cli/apply/apply_online_test.go
@@ -17,7 +17,10 @@ import (
 	"github.com/rancher/fleet/internal/mocks"
 	fleet "github.com/rancher/fleet/pkg/apis/fleet.cattle.io/v1alpha1"
 
+	corev1 "k8s.io/api/core/v1"
+	"k8s.io/apimachinery/pkg/api/errors"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime/schema"
 	"k8s.io/apimachinery/pkg/types"
 )
 
@@ -37,7 +40,9 @@ var _ = Describe("Fleet apply online", Label("online"), func() {
 		//Setting up all the needed mocked interfaces for the test
 		ctrl = gomock.NewController(GinkgoT())
 		clientMock = mocks.NewMockClient(ctrl)
-		clientMock.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(
+		clientMock.EXPECT().Get(
+			gomock.Any(), gomock.Any(), gomock.AssignableToTypeOf(&fleet.Bundle{}),
+		).DoAndReturn(
 			func(_ context.Context, ns types.NamespacedName, bundle *fleet.Bundle, _ ...interface{}) error {
 				bundle.ObjectMeta = oldBundle.ObjectMeta
 				bundle.Name = ns.Name
@@ -48,6 +53,10 @@ var _ = Describe("Fleet apply online", Label("online"), func() {
 		)
 		clientMock.EXPECT().List(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
 		clientMock.EXPECT().Delete(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
+		// so it does not try to use OCI storage
+		clientMock.EXPECT().Get(
+			gomock.Any(), gomock.Any(), gomock.AssignableToTypeOf(&corev1.Secret{}),
+		).Return(errors.NewNotFound(schema.GroupResource{}, "")).AnyTimes()
 	})
 
 	When("We want to delete a label in the bundle from the cluster", func() {
diff --git a/integrationtests/controller/bundle/bundle_helm_test.go b/integrationtests/controller/bundle/bundle_helm_test.go
index 513a6217..54b93e9f 100644
--- a/integrationtests/controller/bundle/bundle_helm_test.go
+++ b/integrationtests/controller/bundle/bundle_helm_test.go
@@ -3,7 +3,6 @@ package bundle
 import (
 	"context"
 	"crypto/rand"
-	"os"
 	"time"
 
 	. "github.com/onsi/ginkgo/v2"
@@ -23,7 +22,6 @@ import (
 var _ = Describe("Bundle with helm options", Ordered, func() {
 	BeforeAll(func() {
 		var err error
-		os.Setenv("EXPERIMENTAL_HELM_OPS", "true")
 		namespace, err = utils.NewNamespaceName()
 		Expect(err).ToNot(HaveOccurred())
 
diff --git a/integrationtests/helmops/controller/controller_test.go b/integrationtests/helmops/controller/controller_test.go
index 458a4266..695ef4b0 100644
--- a/integrationtests/helmops/controller/controller_test.go
+++ b/integrationtests/helmops/controller/controller_test.go
@@ -304,7 +304,6 @@ var _ = Describe("HelmOps controller", func() {
 		var targets []fleet.BundleTarget
 		var doAfterNamespaceCreated func()
 		JustBeforeEach(func() {
-			os.Setenv("EXPERIMENTAL_HELM_OPS", "true")
 			nsSpec := &v1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: namespace}}
 			err := k8sClient.Create(ctx, nsSpec)
 			Expect(err).ToNot(HaveOccurred())
@@ -683,8 +682,33 @@ var _ = Describe("HelmOps controller", func() {
 							v1.ConditionFalse,
 							"improper constraint: foo",
 						)
+					}).Should(Succeed())
+
+					By("then updating the status when the version constraint is edited")
+					ns := types.NamespacedName{Name: helmop.Name, Namespace: helmop.Namespace}
+					err := k8sClient.Get(ctx, ns, &helmop)
+					Expect(err).ShouldNot(HaveOccurred())
+
+					helmop.Spec.Helm.Version = "0.1.2"
+
+					err = k8sClient.Update(ctx, &helmop)
+					Expect(err).ShouldNot(HaveOccurred())
 
+					Eventually(func(g Gomega) {
+						fh := &fleet.HelmOp{}
+						ns := types.NamespacedName{Name: helmop.Name, Namespace: helmop.Namespace}
+						err := k8sClient.Get(ctx, ns, fh)
+						g.Expect(err).ToNot(HaveOccurred())
+						// check that the condition has the error
+						checkConditionContains(
+							g,
+							fh,
+							fleet.HelmOpAcceptedCondition,
+							v1.ConditionTrue,
+							"",
+						)
 					}).Should(Succeed())
+
 				})
 			})
 
diff --git a/integrationtests/helmops/controller/status_test.go b/integrationtests/helmops/controller/status_test.go
index 3b133274..ed03a7c3 100644
--- a/integrationtests/helmops/controller/status_test.go
+++ b/integrationtests/helmops/controller/status_test.go
@@ -1,8 +1,6 @@
 package controller
 
 import (
-	"os"
-
 	. "github.com/onsi/ginkgo/v2"
 	. "github.com/onsi/gomega"
 
@@ -35,7 +33,6 @@ var _ = Describe("HelmOp Status Fields", func() {
 
 	When("Bundle changes", func() {
 		BeforeEach(func() {
-			os.Setenv("EXPERIMENTAL_HELM_OPS", "true")
 			cluster, err := utils.CreateCluster(ctx, k8sClient, "cluster", namespace, nil, namespace)
 			Expect(err).NotTo(HaveOccurred())
 			Expect(cluster).To(Not(BeNil()))
@@ -61,7 +58,7 @@ var _ = Describe("HelmOp Status Fields", func() {
 					BundleSpec: fleet.BundleSpec{
 						BundleDeploymentOptions: fleet.BundleDeploymentOptions{
 							Helm: &fleet.HelmOptions{
-								Chart: "test",
+								Chart: "http://foo.bar/baz/test.tgz",
 							},
 						},
 					},
diff --git a/internal/cmd/agent/deployer/monitor/conditions_test.go b/internal/cmd/agent/deployer/monitor/conditions_test.go
index 71bc31bb..093eedf9 100644
--- a/internal/cmd/agent/deployer/monitor/conditions_test.go
+++ b/internal/cmd/agent/deployer/monitor/conditions_test.go
@@ -27,37 +27,43 @@ func TestExcludeIgnoredConditions(t *testing.T) {
 	}
 	tests := map[string]struct {
 		obj           *unstructured.Unstructured
-		ignoreOptions fleet.IgnoreOptions
+		ignoreOptions *fleet.IgnoreOptions
 		expectedObj   *unstructured.Unstructured
 		expectedErr   error
 	}{
-		"nothing is changed without IgnoreOptions": {
+		"nothing is changed with empty IgnoreOptions": {
 			obj:           &unstructured.Unstructured{Object: uPodInitializedAndNotReady},
-			ignoreOptions: fleet.IgnoreOptions{},
+			ignoreOptions: &fleet.IgnoreOptions{},
+			expectedObj:   &unstructured.Unstructured{Object: uPodInitializedAndNotReady},
+			expectedErr:   nil,
+		},
+		"nothing is changed with nil IgnoreOptions": {
+			obj:           &unstructured.Unstructured{Object: uPodInitializedAndNotReady},
+			ignoreOptions: nil,
 			expectedObj:   &unstructured.Unstructured{Object: uPodInitializedAndNotReady},
 			expectedErr:   nil,
 		},
 		"nothing is changed when IgnoreOptions don't match any condition": {
 			obj:           &unstructured.Unstructured{Object: uPodInitializedAndNotReady},
-			ignoreOptions: fleet.IgnoreOptions{Conditions: []map[string]string{{"Not": "Found"}}},
+			ignoreOptions: &fleet.IgnoreOptions{Conditions: []map[string]string{{"Not": "Found"}}},
 			expectedObj:   &unstructured.Unstructured{Object: uPodInitializedAndNotReady},
 			expectedErr:   nil,
 		},
 		"'Type: Ready' condition is excluded when IgnoreOptions contains 'Type: Ready' condition": {
 			obj:           &unstructured.Unstructured{Object: uPodInitializedAndNotReady},
-			ignoreOptions: fleet.IgnoreOptions{Conditions: []map[string]string{{"type": "Ready"}}},
+			ignoreOptions: &fleet.IgnoreOptions{Conditions: []map[string]string{{"type": "Ready"}}},
 			expectedObj:   &unstructured.Unstructured{Object: uPodInitialized},
 			expectedErr:   nil,
 		},
 		"'Type: Ready' condition is excluded when IgnoreOptions contains 'Type: Ready, status: False' condition": {
 			obj:           &unstructured.Unstructured{Object: uPodInitializedAndNotReady},
-			ignoreOptions: fleet.IgnoreOptions{Conditions: []map[string]string{{"type": "Ready", "status": "False"}}},
+			ignoreOptions: &fleet.IgnoreOptions{Conditions: []map[string]string{{"type": "Ready", "status": "False"}}},
 			expectedObj:   &unstructured.Unstructured{Object: uPodInitialized},
 			expectedErr:   nil,
 		},
 		"nothing is changed when IgnoreOptions contains 'type: Ready, status: True' condition": {
 			obj:           &unstructured.Unstructured{Object: uPodInitializedAndNotReady},
-			ignoreOptions: fleet.IgnoreOptions{Conditions: []map[string]string{{"type": "Ready", "status": "True"}}},
+			ignoreOptions: &fleet.IgnoreOptions{Conditions: []map[string]string{{"type": "Ready", "status": "True"}}},
 			expectedObj:   &unstructured.Unstructured{Object: uPodInitializedAndNotReady},
 			expectedErr:   nil,
 		},
diff --git a/internal/cmd/agent/deployer/monitor/updatestatus.go b/internal/cmd/agent/deployer/monitor/updatestatus.go
index 018f83f6..283981d8 100644
--- a/internal/cmd/agent/deployer/monitor/updatestatus.go
+++ b/internal/cmd/agent/deployer/monitor/updatestatus.go
@@ -312,7 +312,7 @@ func calculateDesiredReady(liveResourceKeys map[fleet.ResourceKey]struct{}, modi
 	return desired
 }
 
-func nonReady(ctx context.Context, plan desiredset.Plan, ignoreOptions fleet.IgnoreOptions) (result []fleet.NonReadyStatus) {
+func nonReady(ctx context.Context, plan desiredset.Plan, ignoreOptions *fleet.IgnoreOptions) (result []fleet.NonReadyStatus) {
 	logger := log.FromContext(ctx)
 	defer func() {
 		sort.Slice(result, func(i, j int) bool {
@@ -322,7 +322,7 @@ func nonReady(ctx context.Context, plan desiredset.Plan, ignoreOptions fleet.Ign
 
 	for _, obj := range plan.Objects {
 		if u, ok := obj.(*unstructured.Unstructured); ok {
-			if ignoreOptions.Conditions != nil {
+			if ignoreOptions != nil && ignoreOptions.Conditions != nil {
 				if err := excludeIgnoredConditions(u, ignoreOptions); err != nil {
 					logger.Error(err, "failed to ignore conditions")
 				}
@@ -438,7 +438,11 @@ func isResourceInPreviousRelease(key objectset.ObjectKey, kind string, objsPrevi
 }
 
 // excludeIgnoredConditions removes the conditions that are included in ignoreOptions from the object passed as a parameter
-func excludeIgnoredConditions(obj *unstructured.Unstructured, ignoreOptions fleet.IgnoreOptions) error {
+func excludeIgnoredConditions(obj *unstructured.Unstructured, ignoreOptions *fleet.IgnoreOptions) error {
+	if ignoreOptions == nil {
+		return nil
+	}
+
 	conditions, _, err := unstructured.NestedSlice(obj.Object, "status", "conditions")
 	if err != nil {
 		return err
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 {
diff --git a/internal/cmd/controller/helmops/reconciler/helmop_controller_test.go b/internal/cmd/controller/helmops/reconciler/helmop_controller_test.go
index b193cc85..b8857bd8 100644
--- a/internal/cmd/controller/helmops/reconciler/helmop_controller_test.go
+++ b/internal/cmd/controller/helmops/reconciler/helmop_controller_test.go
@@ -9,7 +9,6 @@ import (
 	"fmt"
 	"net/http"
 	"net/http/httptest"
-	"os"
 	"strings"
 	"testing"
 	"time"
@@ -34,6 +33,51 @@ import (
 	"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
 )
 
+const (
+	helmRepoIndex = `apiVersion: v1
+entries:
+  alpine:
+    - created: 2016-10-06T16:23:20.499814565-06:00
+      description: Deploy a basic Alpine Linux pod
+      digest: 99c76e403d752c84ead610644d4b1c2f2b453a74b921f422b9dcb8a7c8b559cd
+      home: https://helm.sh/helm
+      name: alpine
+      sources:
+      - https://github.com/helm/helm
+      urls:
+      - https://technosophos.github.io/tscharts/alpine-0.2.0.tgz
+      version: 0.2.0
+    - created: 2016-10-06T16:23:20.499543808-06:00
+      description: Deploy a basic Alpine Linux pod
+      digest: 515c58e5f79d8b2913a10cb400ebb6fa9c77fe813287afbacf1a0b897cd78727
+      home: https://helm.sh/helm
+      name: alpine
+      sources:
+      - https://github.com/helm/helm
+      urls:
+      - https://technosophos.github.io/tscharts/alpine-0.1.0.tgz
+      version: 0.1.0
+  nginx:
+    - created: 2016-10-06T16:23:20.499543808-06:00
+      description: Create a basic nginx HTTP server
+      digest: aaff4545f79d8b2913a10cb400ebb6fa9c77fe813287afbacf1a0b897cdffffff
+      home: https://helm.sh/helm
+      name: nginx
+      sources:
+      - https://github.com/helm/charts
+      urls:
+      - https://technosophos.github.io/tscharts/nginx-0.1.0.tgz
+      version: 0.1.0
+generated: 2016-10-06T16:23:20.499029981-06:00`
+)
+
+func createHelmServer() *httptest.Server {
+	return httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(http.StatusOK)
+		fmt.Fprint(w, helmRepoIndex)
+	}))
+}
+
 func getCondition(helmop *fleet.HelmOp, condType string) (genericcondition.GenericCondition, bool) {
 	for _, cond := range helmop.Status.Conditions {
 		if cond.Type == condType {
@@ -44,7 +88,6 @@ func getCondition(helmop *fleet.HelmOp, condType string) (genericcondition.Gener
 }
 
 func TestReconcile_ReturnsAndRequeuesAfterAddingFinalizer(t *testing.T) {
-	os.Setenv("EXPERIMENTAL_HELM_OPS", "true")
 	mockCtrl := gomock.NewController(t)
 	defer mockCtrl.Finish()
 	scheme := runtime.NewScheme()
@@ -88,8 +131,233 @@ func TestReconcile_ReturnsAndRequeuesAfterAddingFinalizer(t *testing.T) {
 	}
 }
 
+func TestReconcile_Validate(t *testing.T) {
+	cases := []struct {
+		name   string
+		helmop fleet.HelmOp
+		err    string
+	}{
+		{
+			name: "error if Helm spec is empty",
+			helmop: fleet.HelmOp{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "helmop",
+					Namespace: "default",
+				},
+				Spec: fleet.HelmOpSpec{},
+			},
+			err: "helm options are empty",
+		},
+		{
+			name: "error if tarball chart with non-empty repo",
+			helmop: fleet.HelmOp{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "helmop",
+					Namespace: "default",
+				},
+				Spec: fleet.HelmOpSpec{
+					BundleSpec: fleet.BundleSpec{
+						BundleDeploymentOptions: fleet.BundleDeploymentOptions{
+							Helm: &fleet.HelmOptions{
+								Chart: "https://foo.bar/baz.tgz",
+								Repo:  "non-empty",
+							},
+						},
+					},
+				},
+			},
+			err: "tarball chart with a non-empty repo field",
+		},
+		{
+			name: "error if tarball chart with non-empty nor wildcard version",
+			helmop: fleet.HelmOp{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "helmop",
+					Namespace: "default",
+				},
+				Spec: fleet.HelmOpSpec{
+					BundleSpec: fleet.BundleSpec{
+						BundleDeploymentOptions: fleet.BundleDeploymentOptions{
+							Helm: &fleet.HelmOptions{
+								Chart:   "https://foo.bar/baz.tgz",
+								Version: "1.2.3",
+							},
+						},
+					},
+				},
+			},
+			err: "tarball chart with a non-empty version field",
+		},
+		{
+			name: "error if tarball chart with 'latest' version",
+			helmop: fleet.HelmOp{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "helmop",
+					Namespace: "default",
+				},
+				Spec: fleet.HelmOpSpec{
+					BundleSpec: fleet.BundleSpec{
+						BundleDeploymentOptions: fleet.BundleDeploymentOptions{
+							Helm: &fleet.HelmOptions{
+								Chart:   "https://foo.bar/baz.tgz",
+								Version: "latest",
+							},
+						},
+					},
+				},
+			},
+			err: "tarball chart with a non-empty version field",
+		},
+		{
+			name: "error if tarball chart with wildcard version",
+			helmop: fleet.HelmOp{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "helmop",
+					Namespace: "default",
+				},
+				Spec: fleet.HelmOpSpec{
+					BundleSpec: fleet.BundleSpec{
+						BundleDeploymentOptions: fleet.BundleDeploymentOptions{
+							Helm: &fleet.HelmOptions{
+								Chart:   "https://foo.bar/baz.tgz",
+								Version: "*",
+							},
+						},
+					},
+				},
+			},
+			err: "tarball chart with a non-empty version field",
+		},
+		{
+			name: "error if OCI repo with non-empty chart",
+			helmop: fleet.HelmOp{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "helmop",
+					Namespace: "default",
+				},
+				Spec: fleet.HelmOpSpec{
+					BundleSpec: fleet.BundleSpec{
+						BundleDeploymentOptions: fleet.BundleDeploymentOptions{
+							Helm: &fleet.HelmOptions{
+								Chart:   "hello",
+								Repo:    "oci://foo/bar/baz",
+								Version: "*",
+							},
+						},
+					},
+				},
+			},
+			err: "OCI repository with a non-empty chart field",
+		},
+		{
+			name: "error if non-OCI repo with empty chart",
+			helmop: fleet.HelmOp{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "helmop",
+					Namespace: "default",
+				},
+				Spec: fleet.HelmOpSpec{
+					BundleSpec: fleet.BundleSpec{
+						BundleDeploymentOptions: fleet.BundleDeploymentOptions{
+							Helm: &fleet.HelmOptions{
+								Repo: "https://foo/bar/baz",
+							},
+						},
+					},
+				},
+			},
+			err: "non-OCI repository with an empty chart field",
+		},
+		{
+			name: "error if non-tarball chart with empty repo",
+			helmop: fleet.HelmOp{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "helmop",
+					Namespace: "default",
+				},
+				Spec: fleet.HelmOpSpec{
+					BundleSpec: fleet.BundleSpec{
+						BundleDeploymentOptions: fleet.BundleDeploymentOptions{
+							Helm: &fleet.HelmOptions{
+								Chart: "foo",
+							},
+						},
+					},
+				},
+			},
+			err: "non-tarball chart with an empty repo field",
+		},
+	}
+
+	for _, c := range cases {
+		t.Run(c.name, func(t *testing.T) {
+			mockCtrl := gomock.NewController(t)
+			defer mockCtrl.Finish()
+			scheme := runtime.NewScheme()
+			utilruntime.Must(batchv1.AddToScheme(scheme))
+
+			namespacedName := types.NamespacedName{Name: c.helmop.Name, Namespace: c.helmop.Namespace}
+			client := mocks.NewMockClient(mockCtrl)
+			client.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(1).DoAndReturn(
+				func(ctx context.Context, req types.NamespacedName, fh *fleet.HelmOp, opts ...interface{}) error {
+					fh.Name = c.helmop.Name
+					fh.Namespace = c.helmop.Namespace
+					fh.Spec = c.helmop.Spec
+
+					controllerutil.AddFinalizer(fh, finalize.HelmOpFinalizer)
+					return nil
+				},
+			)
+
+			client.EXPECT().Get(gomock.Any(), namespacedName, matchesBundle(c.helmop.Name, c.helmop.Namespace), gomock.Any()).
+				DoAndReturn(
+					func(ctx context.Context, req types.NamespacedName, bundle *fleet.Bundle, opts ...interface{}) error {
+						bundle.ObjectMeta = metav1.ObjectMeta{
+							Name:      c.helmop.Name,
+							Namespace: c.helmop.Namespace,
+						}
+						bundle.Spec.HelmOpOptions = &fleet.BundleHelmOptions{
+							SecretName: "foo", //prevent collision errors; the value does not matter.
+						}
+
+						return nil
+					},
+				).AnyTimes()
+
+			// expected from addFinalizer
+			client.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes().Return(nil)
+			client.EXPECT().Update(gomock.Any(), gomock.Any()).AnyTimes().Return(nil)
+
+			statusClient := mocks.NewMockSubResourceWriter(mockCtrl)
+			client.EXPECT().Status().Return(statusClient)
+			statusClient.EXPECT().Patch(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any())
+
+			scheduler := mocks.NewMockScheduler(mockCtrl)
+
+			if len(c.err) > 0 {
+				job := mocks.NewMockScheduledJob(mockCtrl)
+				scheduler.EXPECT().GetScheduledJob(gomock.Any()).Return(job, nil)
+				scheduler.EXPECT().DeleteJob(gomock.Any()).Return(nil)
+			}
+
+			r := HelmOpReconciler{
+				Client:    client,
+				Scheme:    scheme,
+				Scheduler: scheduler,
+			}
+
+			ctx := context.TODO()
+
+			_, err := r.Reconcile(ctx, ctrl.Request{NamespacedName: namespacedName})
+			if err == nil || !strings.Contains(err.Error(), c.err) {
+				t.Errorf("unexpected error:\r\n\twanted\r\n\t\t%q in error message,\r\n\tgot \r\n\t\t%v", c.err, err)
+			}
+
+		})
+	}
+}
+
 func TestReconcile_ErrorCreatingBundleIsShownInStatus(t *testing.T) {
-	os.Setenv("EXPERIMENTAL_HELM_OPS", "true")
 	mockCtrl := gomock.NewController(t)
 	defer mockCtrl.Finish()
 	scheme := runtime.NewScheme()
@@ -107,8 +375,10 @@ func TestReconcile_ErrorCreatingBundleIsShownInStatus(t *testing.T) {
 			fh.Name = helmop.Name
 			fh.Namespace = helmop.Namespace
 			fh.Spec.Helm = &fleet.HelmOptions{
-				Chart: "chart",
+				Chart: "chart.tgz",
 			}
+			fh.Status = fleet.HelmOpStatus{}
+
 			controllerutil.AddFinalizer(fh, finalize.HelmOpFinalizer)
 			return nil
 		},
@@ -163,7 +433,6 @@ func TestReconcile_ErrorCreatingBundleIsShownInStatus(t *testing.T) {
 // Validates that the HelmOps reconciler will not create a bundle if another bundle exists with the same name, for
 // instance a gitOps bundle.
 func TestReconcile_ErrorCreatingBundleIfBundleWithSameNameExists(t *testing.T) {
-	os.Setenv("EXPERIMENTAL_HELM_OPS", "true")
 	mockCtrl := gomock.NewController(t)
 	defer mockCtrl.Finish()
 	scheme := runtime.NewScheme()
@@ -181,8 +450,10 @@ func TestReconcile_ErrorCreatingBundleIfBundleWithSameNameExists(t *testing.T) {
 			fh.Name = helmop.Name
 			fh.Namespace = helmop.Namespace
 			fh.Spec.Helm = &fleet.HelmOptions{
-				Chart: "chart",
+				Chart: "chart.tgz",
 			}
+			fh.Status = fleet.HelmOpStatus{}
+
 			controllerutil.AddFinalizer(fh, finalize.HelmOpFinalizer)
 			return nil
 		},
@@ -245,7 +516,9 @@ func TestReconcile_ErrorCreatingBundleIfBundleWithSameNameExists(t *testing.T) {
 }
 
 func TestReconcile_CreatesBundleAndUpdatesStatus(t *testing.T) {
-	os.Setenv("EXPERIMENTAL_HELM_OPS", "true")
+	svr1 := createHelmServer()
+	defer svr1.Close()
+
 	mockCtrl := gomock.NewController(t)
 	defer mockCtrl.Finish()
 	scheme := runtime.NewScheme()
@@ -262,29 +535,35 @@ func TestReconcile_CreatesBundleAndUpdatesStatus(t *testing.T) {
 		func(ctx context.Context, req types.NamespacedName, fh *fleet.HelmOp, opts ...interface{}) error {
 			fh.Name = helmop.Name
 			fh.Namespace = helmop.Namespace
+			fh.Spec.InsecureSkipTLSverify = true
 			fh.Spec.Helm = &fleet.HelmOptions{
-				Chart:   "chart",
-				Version: "1.1.x",
+				Repo:    svr1.URL,
+				Chart:   "alpine",
+				Version: "0.x.x",
 			}
+			fh.Status = fleet.HelmOpStatus{}
+
 			controllerutil.AddFinalizer(fh, finalize.HelmOpFinalizer)
 			return nil
 		},
 	)
 
-	client.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(2).DoAndReturn(
-		func(ctx context.Context, req types.NamespacedName, bundle *fleet.Bundle, opts ...interface{}) error {
-			return k8serrors.NewNotFound(schema.GroupResource{}, "Not found")
-		},
-	)
+	client.EXPECT().Get(gomock.Any(), namespacedName, gomock.Any(), gomock.Any()).Times(2).
+		DoAndReturn(
+			func(ctx context.Context, req types.NamespacedName, bundle *fleet.Bundle, opts ...interface{}) error {
+				return k8serrors.NewNotFound(schema.GroupResource{}, "Not found")
+			},
+		)
 
-	client.EXPECT().Create(gomock.Any(), gomock.Any(), gomock.Any()).Times(1).DoAndReturn(
-		func(ctx context.Context, bundle *fleet.Bundle, opts ...interface{}) error {
-			return nil
-		},
-	)
+	client.EXPECT().Create(gomock.Any(), matchesBundle(helmop.Name, helmop.Namespace), gomock.Any()).
+		DoAndReturn(
+			func(ctx context.Context, bundle *fleet.Bundle, opts ...interface{}) error {
+				return nil
+			},
+		)
 
-	client.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(1).DoAndReturn(
-		func(ctx context.Context, req types.NamespacedName, bundle *fleet.HelmOp, opts ...interface{}) error {
+	client.EXPECT().Get(gomock.Any(), gomock.Any(), &fleet.HelmOp{}, gomock.Any()).Times(1).DoAndReturn(
+		func(ctx context.Context, req types.NamespacedName, _ *fleet.HelmOp, opts ...interface{}) error {
 			return nil
 		},
 	)
@@ -294,8 +573,8 @@ func TestReconcile_CreatesBundleAndUpdatesStatus(t *testing.T) {
 	statusClient.EXPECT().Patch(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Do(
 		func(ctx context.Context, helmop *fleet.HelmOp, patch crclient.Patch, opts ...interface{}) {
 			// version in status should be the one in the spec
-			if helmop.Status.Version != "1.1.x" {
-				t.Errorf("expecting Status.Version == 1.1.x, got %q", helmop.Status.Version)
+			if helmop.Status.Version != "0.2.0" {
+				t.Errorf("expecting Status.Version == 0.2.0, got %q", helmop.Status.Version)
 			}
 		},
 	).Times(1)
@@ -321,54 +600,10 @@ func TestReconcile_CreatesBundleAndUpdatesStatus(t *testing.T) {
 }
 
 func TestReconcile_ManagePollingJobs(t *testing.T) {
-	os.Setenv("EXPERIMENTAL_HELM_OPS", "true")
-
-	helmRepoIndex := `apiVersion: v1
-entries:
-  alpine:
-    - created: 2016-10-06T16:23:20.499814565-06:00
-      description: Deploy a basic Alpine Linux pod
-      digest: 99c76e403d752c84ead610644d4b1c2f2b453a74b921f422b9dcb8a7c8b559cd
-      home: https://helm.sh/helm
-      name: alpine
-      sources:
-      - https://github.com/helm/helm
-      urls:
-      - https://technosophos.github.io/tscharts/alpine-0.2.0.tgz
-      version: 0.2.0
-    - created: 2016-10-06T16:23:20.499543808-06:00
-      description: Deploy a basic Alpine Linux pod
-      digest: 515c58e5f79d8b2913a10cb400ebb6fa9c77fe813287afbacf1a0b897cd78727
-      home: https://helm.sh/helm
-      name: alpine
-      sources:
-      - https://github.com/helm/helm
-      urls:
-      - https://technosophos.github.io/tscharts/alpine-0.1.0.tgz
-      version: 0.1.0
-  nginx:
-    - created: 2016-10-06T16:23:20.499543808-06:00
-      description: Create a basic nginx HTTP server
-      digest: aaff4545f79d8b2913a10cb400ebb6fa9c77fe813287afbacf1a0b897cdffffff
-      home: https://helm.sh/helm
-      name: nginx
-      sources:
-      - https://github.com/helm/charts
-      urls:
-      - https://technosophos.github.io/tscharts/nginx-0.1.0.tgz
-      version: 0.1.0
-generated: 2016-10-06T16:23:20.499029981-06:00`
-
-	svr1 := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		w.WriteHeader(http.StatusOK)
-		fmt.Fprint(w, helmRepoIndex)
-	}))
+	svr1 := createHelmServer()
 	defer svr1.Close()
 
-	svr2 := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		w.WriteHeader(http.StatusOK)
-		fmt.Fprint(w, helmRepoIndex)
-	}))
+	svr2 := createHelmServer()
 	defer svr2.Close()
 
 	cases := []struct {
@@ -376,7 +611,6 @@ generated: 2016-10-06T16:23:20.499029981-06:00`
 		helmOp                 fleet.HelmOp
 		expectedSchedulerCalls func(*gomock.Controller, *mocks.MockScheduler, fleet.HelmOp)
 		expectedError          string
-		expectStatusUpdate     bool // True if the reconciler (not the polling job) is expected to run a status update
 	}{
 		{
 			name: "does not poll if the version is static",
@@ -389,6 +623,7 @@ generated: 2016-10-06T16:23:20.499029981-06:00`
 					BundleSpec: fleet.BundleSpec{
 						BundleDeploymentOptions: fleet.BundleDeploymentOptions{
 							Helm: &fleet.HelmOptions{
+								Repo:    svr1.URL,
 								Chart:   "chart",
 								Version: "1.1.2", // static version
 							},
@@ -413,6 +648,7 @@ generated: 2016-10-06T16:23:20.499029981-06:00`
 					BundleSpec: fleet.BundleSpec{
 						BundleDeploymentOptions: fleet.BundleDeploymentOptions{
 							Helm: &fleet.HelmOptions{
+								Repo:    svr1.URL,
 								Chart:   "chart",
 								Version: "1.1.2", // static version
 							},
@@ -434,12 +670,14 @@ generated: 2016-10-06T16:23:20.499029981-06:00`
 					Namespace: "default",
 				},
 				Spec: fleet.HelmOpSpec{
+					InsecureSkipTLSverify: true,
+					// polling interval not set
 					BundleSpec: fleet.BundleSpec{
-						// polling interval not set
 						BundleDeploymentOptions: fleet.BundleDeploymentOptions{
 							Helm: &fleet.HelmOptions{
-								Chart:   "chart",
-								Version: "1.x.x",
+								Repo:    svr1.URL,
+								Chart:   "alpine",
+								Version: "0.x.x",
 							},
 						},
 					},
@@ -459,12 +697,14 @@ generated: 2016-10-06T16:23:20.499029981-06:00`
 					Namespace: "default",
 				},
 				Spec: fleet.HelmOpSpec{
+					InsecureSkipTLSverify: true,
+					// polling interval not set
 					BundleSpec: fleet.BundleSpec{
-						// polling interval not set
 						BundleDeploymentOptions: fleet.BundleDeploymentOptions{
 							Helm: &fleet.HelmOptions{
-								Chart:   "chart",
-								Version: "1.x.x",
+								Repo:    svr1.URL,
+								Chart:   "alpine",
+								Version: "0.x.x",
 							},
 						},
 					},
@@ -484,12 +724,14 @@ generated: 2016-10-06T16:23:20.499029981-06:00`
 					Namespace: "default",
 				},
 				Spec: fleet.HelmOpSpec{
-					PollingInterval: &metav1.Duration{Duration: 0 * time.Minute},
+					InsecureSkipTLSverify: true,
+					PollingInterval:       &metav1.Duration{Duration: 0 * time.Minute},
 					BundleSpec: fleet.BundleSpec{
 						BundleDeploymentOptions: fleet.BundleDeploymentOptions{
 							Helm: &fleet.HelmOptions{
-								Chart:   "chart",
-								Version: "1.x.x",
+								Repo:    svr1.URL,
+								Chart:   "alpine",
+								Version: "0.x.x",
 							},
 						},
 					},
@@ -509,12 +751,116 @@ generated: 2016-10-06T16:23:20.499029981-06:00`
 					Namespace: "default",
 				},
 				Spec: fleet.HelmOpSpec{
-					PollingInterval: &metav1.Duration{Duration: 0 * time.Minute},
+					InsecureSkipTLSverify: true,
+					PollingInterval:       &metav1.Duration{Duration: 0 * time.Minute},
 					BundleSpec: fleet.BundleSpec{
 						BundleDeploymentOptions: fleet.BundleDeploymentOptions{
 							Helm: &fleet.HelmOptions{
-								Chart:   "chart",
-								Version: "1.x.x",
+								Repo:    svr1.URL,
+								Chart:   "alpine",
+								Version: "0.x.x",
+							},
+						},
+					},
+				},
+			},
+			expectedSchedulerCalls: func(ctrl *gomock.Controller, scheduler *mocks.MockScheduler, helmop fleet.HelmOp) {
+				job := mocks.NewMockScheduledJob(ctrl)
+				scheduler.EXPECT().GetScheduledJob(gomock.Any()).Return(job, nil)
+				scheduler.EXPECT().DeleteJob(gomock.Any()).Return(nil)
+			},
+		},
+		{
+			name: "does not poll when referencing an OCI chart",
+			helmOp: fleet.HelmOp{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "helmop",
+					Namespace: "default",
+				},
+				Spec: fleet.HelmOpSpec{
+					PollingInterval:       &metav1.Duration{Duration: 1 * time.Minute},
+					InsecureSkipTLSverify: true,
+					BundleSpec: fleet.BundleSpec{
+						BundleDeploymentOptions: fleet.BundleDeploymentOptions{
+							Helm: &fleet.HelmOptions{
+								Repo:    "oci://chart",
+								Version: "0.x.x",
+							},
+						},
+					},
+				},
+			},
+			expectedSchedulerCalls: func(_ *gomock.Controller, scheduler *mocks.MockScheduler, helmop fleet.HelmOp) {
+				scheduler.EXPECT().GetScheduledJob(gomock.Any()).Return(nil, quartz.ErrJobNotFound)
+
+				// No job expected to be created nor deleted
+			},
+		},
+		{
+			name: "deletes existing polling job when referencing an OCI chart",
+			helmOp: fleet.HelmOp{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "helmop",
+					Namespace: "default",
+				},
+				Spec: fleet.HelmOpSpec{
+					PollingInterval:       &metav1.Duration{Duration: 1 * time.Minute},
+					InsecureSkipTLSverify: true,
+					BundleSpec: fleet.BundleSpec{
+						BundleDeploymentOptions: fleet.BundleDeploymentOptions{
+							Helm: &fleet.HelmOptions{
+								Repo:    "oci://chart",
+								Version: "0.x.x",
+							},
+						},
+					},
+				},
+			},
+			expectedSchedulerCalls: func(ctrl *gomock.Controller, scheduler *mocks.MockScheduler, helmop fleet.HelmOp) {
+				job := mocks.NewMockScheduledJob(ctrl)
+				scheduler.EXPECT().GetScheduledJob(gomock.Any()).Return(job, nil)
+				scheduler.EXPECT().DeleteJob(gomock.Any()).Return(nil)
+			},
+		},
+		{
+			name: "does not poll when referencing a tarball chart",
+			helmOp: fleet.HelmOp{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "helmop",
+					Namespace: "default",
+				},
+				Spec: fleet.HelmOpSpec{
+					PollingInterval:       &metav1.Duration{Duration: 1 * time.Minute},
+					InsecureSkipTLSverify: true,
+					BundleSpec: fleet.BundleSpec{
+						BundleDeploymentOptions: fleet.BundleDeploymentOptions{
+							Helm: &fleet.HelmOptions{
+								Chart: fmt.Sprintf("%s/alpine/alpine-0.1.0.tgz", svr1.URL),
+							},
+						},
+					},
+				},
+			},
+			expectedSchedulerCalls: func(_ *gomock.Controller, scheduler *mocks.MockScheduler, helmop fleet.HelmOp) {
+				scheduler.EXPECT().GetScheduledJob(gomock.Any()).Return(nil, quartz.ErrJobNotFound)
+
+				// No job expected to be created nor deleted
+			},
+		},
+		{
+			name: "deletes existing polling job when referencing a tarball chart",
+			helmOp: fleet.HelmOp{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "helmop",
+					Namespace: "default",
+				},
+				Spec: fleet.HelmOpSpec{
+					PollingInterval:       &metav1.Duration{Duration: 1 * time.Minute},
+					InsecureSkipTLSverify: true,
+					BundleSpec: fleet.BundleSpec{
+						BundleDeploymentOptions: fleet.BundleDeploymentOptions{
+							Helm: &fleet.HelmOptions{
+								Chart: fmt.Sprintf("%s/alpine/alpine-0.1.0.tgz", svr1.URL),
 							},
 						},
 					},
@@ -534,12 +880,14 @@ generated: 2016-10-06T16:23:20.499029981-06:00`
 					Namespace: "default",
 				},
 				Spec: fleet.HelmOpSpec{
-					PollingInterval: &metav1.Duration{Duration: 0 * time.Minute},
+					InsecureSkipTLSverify: true,
+					PollingInterval:       &metav1.Duration{Duration: 0 * time.Minute},
 					BundleSpec: fleet.BundleSpec{
 						BundleDeploymentOptions: fleet.BundleDeploymentOptions{
 							Helm: &fleet.HelmOptions{
-								Chart:   "chart",
-								Version: "1.x.x",
+								Repo:    svr1.URL,
+								Chart:   "alpine",
+								Version: "0.x.x",
 							},
 						},
 					},
@@ -550,8 +898,7 @@ generated: 2016-10-06T16:23:20.499029981-06:00`
 				scheduler.EXPECT().GetScheduledJob(gomock.Any()).Return(job, nil)
 				scheduler.EXPECT().DeleteJob(gomock.Any()).Return(errors.New("something happened!"))
 			},
-			expectedError:      "something happened!",
-			expectStatusUpdate: true,
+			expectedError: "something happened!",
 		},
 		{
 			name: "returns an error when failing to schedule a new job replacing an existing one",
@@ -562,12 +909,14 @@ generated: 2016-10-06T16:23:20.499029981-06:00`
 					UID:       "foo",
 				},
 				Spec: fleet.HelmOpSpec{
-					PollingInterval: &metav1.Duration{Duration: 1 * time.Minute},
+					InsecureSkipTLSverify: true,
+					PollingInterval:       &metav1.Duration{Duration: 1 * time.Minute},
 					BundleSpec: fleet.BundleSpec{
 						BundleDeploymentOptions: fleet.BundleDeploymentOptions{
 							Helm: &fleet.HelmOptions{
-								Chart:   "chart",
-								Version: "1.x.x",
+								Repo:    svr1.URL,
+								Chart:   "alpine",
+								Version: "0.x.x",
 							},
 						},
 					},
@@ -583,8 +932,7 @@ generated: 2016-10-06T16:23:20.499029981-06:00`
 				scheduler.EXPECT().GetScheduledJob(gomock.Any()).Return(job, nil)
 				scheduler.EXPECT().ScheduleJob(matchesJobDetailReplace(true, helmop), gomock.Any()).Return(errors.New("something happened!"))
 			},
-			expectedError:      "something happened!",
-			expectStatusUpdate: true,
+			expectedError: "something happened!",
 		},
 		{
 			name: "returns an error when failing to schedule a new job with no existing one",
@@ -595,12 +943,14 @@ generated: 2016-10-06T16:23:20.499029981-06:00`
 					UID:       "foo",
 				},
 				Spec: fleet.HelmOpSpec{
-					PollingInterval: &metav1.Duration{Duration: 1 * time.Minute},
+					InsecureSkipTLSverify: true,
+					PollingInterval:       &metav1.Duration{Duration: 1 * time.Minute},
 					BundleSpec: fleet.BundleSpec{
 						BundleDeploymentOptions: fleet.BundleDeploymentOptions{
 							Helm: &fleet.HelmOptions{
-								Chart:   "chart",
-								Version: "1.x.x",
+								Repo:    svr1.URL,
+								Chart:   "alpine",
+								Version: "0.x.x",
 							},
 						},
 					},
@@ -610,8 +960,7 @@ generated: 2016-10-06T16:23:20.499029981-06:00`
 				scheduler.EXPECT().GetScheduledJob(gomock.Any()).Return(nil, quartz.ErrJobNotFound)
 				scheduler.EXPECT().ScheduleJob(matchesJobDetailReplace(true, helmop), gomock.Any()).Return(errors.New("something happened!"))
 			},
-			expectedError:      "something happened!",
-			expectStatusUpdate: true,
+			expectedError: "something happened!",
 		},
 		{
 			name: "creates a polling job if all conditions are met",
@@ -622,12 +971,14 @@ generated: 2016-10-06T16:23:20.499029981-06:00`
 					UID:       "foo",
 				},
 				Spec: fleet.HelmOpSpec{
-					PollingInterval: &metav1.Duration{Duration: 1 * time.Minute},
+					InsecureSkipTLSverify: true,
+					PollingInterval:       &metav1.Duration{Duration: 1 * time.Minute},
 					BundleSpec: fleet.BundleSpec{
 						BundleDeploymentOptions: fleet.BundleDeploymentOptions{
 							Helm: &fleet.HelmOptions{
-								Chart:   "chart",
-								Version: "1.x.x",
+								Repo:    svr1.URL,
+								Chart:   "alpine",
+								Version: "0.x.x",
 							},
 						},
 					},
@@ -876,12 +1227,10 @@ generated: 2016-10-06T16:23:20.499029981-06:00`
 			// Only expected in happy cases. If errors happen, only status updates are expected.
 			client.EXPECT().Update(gomock.Any(), matchesBundle(c.helmOp.Name, c.helmOp.Namespace), gomock.Any()).Return(nil).AnyTimes()
 
-			if c.expectStatusUpdate {
-				statusClient := mocks.NewMockSubResourceWriter(mockCtrl)
-				statusClient.EXPECT().Patch(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil)
+			statusClient := mocks.NewMockSubResourceWriter(mockCtrl)
+			statusClient.EXPECT().Patch(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil)
 
-				client.EXPECT().Status().Return(statusClient).Times(1)
-			}
+			client.EXPECT().Status().Return(statusClient).Times(1)
 
 			c.expectedSchedulerCalls(mockCtrl, scheduler, c.helmOp)
 
diff --git a/internal/cmd/controller/target/rollout_test.go b/internal/cmd/controller/target/rollout_test.go
new file mode 100644
index 00000000..1011ffd1
--- /dev/null
+++ b/internal/cmd/controller/target/rollout_test.go
@@ -0,0 +1,438 @@
+package target
+
+import (
+	"fmt"
+	"strconv"
+	"testing"
+
+	"github.com/davecgh/go-spew/spew"
+	fleet "github.com/rancher/fleet/pkg/apis/fleet.cattle.io/v1alpha1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/util/intstr"
+)
+
+// createTargets creates a slice of targets with sequentially numbered clusters
+// and bundles. Both values, start and stop, are inclusive, meaning the targets
+// will be created from start to stop and having that number in the deployment
+// id.
+func createTargets(start, stop int) []*Target {
+	targets := make([]*Target, stop-start+1)
+	for i := range stop - start + 1 {
+		targets[i] = &Target{
+			Cluster: &fleet.Cluster{
+				ObjectMeta: metav1.ObjectMeta{
+					Namespace: "default",
+					Name:      "cluster-" + strconv.Itoa(start),
+				},
+			},
+			Bundle: &fleet.Bundle{
+				ObjectMeta: metav1.ObjectMeta{
+					Name: "bundle-" + strconv.Itoa(start),
+				},
+			},
+			Deployment: &fleet.BundleDeployment{
+				ObjectMeta: metav1.ObjectMeta{},
+				Spec:       fleet.BundleDeploymentSpec{},
+				Status:     fleet.BundleDeploymentStatus{},
+			},
+			DeploymentID: "deployment-" + strconv.Itoa(start),
+		}
+		start++
+	}
+	return targets
+}
+
+func Test_createTargets(t *testing.T) {
+	tests := []struct {
+		name        string
+		start, stop int
+		want        []*Target
+	}{
+		{
+			name:  "start and stop should be inclusive",
+			start: 1,
+			stop:  5,
+			want: []*Target{
+				{DeploymentID: "deployment-1"},
+				{DeploymentID: "deployment-2"},
+				{DeploymentID: "deployment-3"},
+				{DeploymentID: "deployment-4"},
+				{DeploymentID: "deployment-5"},
+			},
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got := createTargets(tt.start, tt.stop)
+			if err := targetsEqual(got, tt.want); err != nil {
+				t.Errorf("createTargets(%d, %d): %v", tt.start, tt.stop, err)
+			}
+		})
+	}
+}
+
+func withCluster(targets []*Target, cluster *fleet.Cluster) {
+	for _, target := range targets {
+		target.Cluster = cluster
+	}
+}
+
+func withClusterGroup(targets []*Target, clusterGroup *fleet.ClusterGroup) {
+	for _, target := range targets {
+		target.ClusterGroups = append(target.ClusterGroups, clusterGroup)
+	}
+}
+
+func targetsEqual(got, want []*Target) error {
+	if len(want) != len(got) {
+		return fmt.Errorf("targets have different lengths: got %d but want %d", len(got), len(want))
+	}
+
+	for i := range want {
+		if want[i].DeploymentID != got[i].DeploymentID {
+			return fmt.Errorf("target %d has different deployment IDs: got %v but want %v", i, got[i], want[i])
+		}
+	}
+
+	return nil
+}
+
+// partitionsEqual compares two slices of partitions for equality. It ignores
+// the status of the partitions.
+func partitionsEqual(got, want []partition) error {
+	type targetStats struct {
+		length   int
+		from, to string
+	}
+	stats := func() []*targetStats {
+		stats := make([]*targetStats, len(got))
+		for i, p := range got {
+			stats[i] = &targetStats{
+				length: len(p.Targets),
+				// targets are sorted by Name
+				from: p.Targets[0].DeploymentID,
+				to:   p.Targets[len(p.Targets)-1].DeploymentID,
+			}
+		}
+		return stats
+	}
+	if len(got) != len(want) {
+		return fmt.Errorf("partitions have different lengths: got %d but want %d\nstats of got: %s", len(got), len(want), spew.Sdump(stats()))
+	}
+	for i := range want {
+		if err := targetsEqual(got[i].Targets, want[i].Targets); err != nil {
+			return fmt.Errorf("partition %d has different targets: %v\nstats of got: %s", i, err, spew.Sdump(stats()))
+		}
+
+		if got[i].Status.MaxUnavailable != want[i].Status.MaxUnavailable {
+			return fmt.Errorf(
+				"partition %d has different MaxUnavailable: got %d but want %d",
+				i,
+				got[i].Status.MaxUnavailable,
+				want[i].Status.MaxUnavailable,
+			)
+		}
+	}
+	return nil
+}
+
+func Test_autoPartition(t *testing.T) {
+	tests := []struct {
+		name    string
+		rollout *fleet.RolloutStrategy
+		targets []*Target
+		want    []partition
+		wantErr bool
+	}{
+		{
+			name: "should partition according to fixed AutoPartitionSize (only >=200 clusters)",
+			rollout: &fleet.RolloutStrategy{
+				AutoPartitionSize: &intstr.IntOrString{Type: intstr.Int, IntVal: 100},
+			},
+			targets: createTargets(1, 200),
+			want: []partition{
+				{Targets: createTargets(1, 100), Status: fleet.PartitionStatus{MaxUnavailable: 100}},
+				{Targets: createTargets(101, 200), Status: fleet.PartitionStatus{MaxUnavailable: 100}},
+			},
+		},
+		{
+			name: "less than 200 targets should all be in one partition",
+			rollout: &fleet.RolloutStrategy{
+				AutoPartitionSize: &intstr.IntOrString{Type: intstr.String, StrVal: "25%"},
+				MaxUnavailable:    &intstr.IntOrString{Type: intstr.Int, IntVal: 1},
+			},
+			targets: createTargets(1, 199),
+			want: []partition{
+				{Targets: createTargets(1, 199), Status: fleet.PartitionStatus{MaxUnavailable: 1}},
+			},
+		},
+		{
+			name:    "with 200 targets and above, we expect 4 partitions with a default of 25% for partition size",
+			rollout: &fleet.RolloutStrategy{}, // MaxUnavailable defaults to 100%
+			targets: createTargets(1, 200),
+			want: []partition{
+				{Targets: createTargets(1, 50), Status: fleet.PartitionStatus{MaxUnavailable: 50}},
+				{Targets: createTargets(51, 100), Status: fleet.PartitionStatus{MaxUnavailable: 50}},
+				{Targets: createTargets(101, 150), Status: fleet.PartitionStatus{MaxUnavailable: 50}},
+				{Targets: createTargets(151, 200), Status: fleet.PartitionStatus{MaxUnavailable: 50}},
+			},
+		},
+		{
+			name: "rest ends up in a separate partition",
+			rollout: &fleet.RolloutStrategy{
+				AutoPartitionSize: &intstr.IntOrString{Type: intstr.String, StrVal: "49%"},
+			},
+			targets: createTargets(1, 1000),
+			want: []partition{
+				{Targets: createTargets(1, 490), Status: fleet.PartitionStatus{MaxUnavailable: 490}},
+				{Targets: createTargets(491, 980), Status: fleet.PartitionStatus{MaxUnavailable: 490}},
+				{Targets: createTargets(981, 1000), Status: fleet.PartitionStatus{MaxUnavailable: 20}},
+			},
+		},
+		{
+			name: "MaxUnavailable from RolloutStrategy should be used in each partition",
+			rollout: &fleet.RolloutStrategy{
+				AutoPartitionSize: &intstr.IntOrString{Type: intstr.String, StrVal: "10%"},
+				MaxUnavailable:    &intstr.IntOrString{Type: intstr.String, StrVal: "10%"},
+			},
+			targets: createTargets(1, 1000),
+			want: []partition{
+				{Targets: createTargets(1, 100), Status: fleet.PartitionStatus{MaxUnavailable: 10}},
+				{Targets: createTargets(101, 200), Status: fleet.PartitionStatus{MaxUnavailable: 10}},
+				{Targets: createTargets(201, 300), Status: fleet.PartitionStatus{MaxUnavailable: 10}},
+				{Targets: createTargets(301, 400), Status: fleet.PartitionStatus{MaxUnavailable: 10}},
+				{Targets: createTargets(401, 500), Status: fleet.PartitionStatus{MaxUnavailable: 10}},
+				{Targets: createTargets(501, 600), Status: fleet.PartitionStatus{MaxUnavailable: 10}},
+				{Targets: createTargets(601, 700), Status: fleet.PartitionStatus{MaxUnavailable: 10}},
+				{Targets: createTargets(701, 800), Status: fleet.PartitionStatus{MaxUnavailable: 10}},
+				{Targets: createTargets(801, 900), Status: fleet.PartitionStatus{MaxUnavailable: 10}},
+				{Targets: createTargets(901, 1000), Status: fleet.PartitionStatus{MaxUnavailable: 10}},
+			},
+		},
+		{
+			name:    "rounding with 230 clusters",
+			rollout: &fleet.RolloutStrategy{},
+			targets: createTargets(1, 230),
+			want: []partition{
+				{Targets: createTargets(1, 57), Status: fleet.PartitionStatus{MaxUnavailable: 57}},
+				{Targets: createTargets(58, 114), Status: fleet.PartitionStatus{MaxUnavailable: 57}},
+				{Targets: createTargets(115, 171), Status: fleet.PartitionStatus{MaxUnavailable: 57}},
+				{Targets: createTargets(172, 228), Status: fleet.PartitionStatus{MaxUnavailable: 57}},
+				{Targets: createTargets(229, 230), Status: fleet.PartitionStatus{MaxUnavailable: 2}},
+			},
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got, gotErr := autoPartition(tt.rollout, tt.targets)
+			if gotErr != nil {
+				if !tt.wantErr {
+					t.Errorf("autoPartition() failed: %v", gotErr)
+				}
+				return
+			}
+			if tt.wantErr {
+				t.Fatal("autoPartition() succeeded unexpectedly")
+			}
+
+			if err := partitionsEqual(got, tt.want); err != nil {
+				t.Errorf("autoPartition(): %v", err)
+			}
+		})
+	}
+}
+
+func Test_manualPartition(t *testing.T) {
+	tests := []struct {
+		name      string
+		rollout   *fleet.RolloutStrategy
+		targets   []*Target
+		targetsFn func() []*Target
+		want      []partition
+		wantErr   bool
+	}{
+		{
+			name: "should match cluster names",
+			rollout: &fleet.RolloutStrategy{
+				Partitions: []fleet.Partition{
+					{
+						Name:        "Partition 1",
+						ClusterName: "cluster-1",
+					},
+					{
+						Name:        "Partition 2",
+						ClusterName: "cluster-2",
+					},
+				},
+			},
+			targetsFn: func() []*Target {
+				cluster1 := &fleet.Cluster{
+					ObjectMeta: metav1.ObjectMeta{
+						Name: "cluster-1",
+					},
+				}
+				cluster2 := &fleet.Cluster{
+					ObjectMeta: metav1.ObjectMeta{
+						Name: "cluster-2",
+					},
+				}
+				targets := createTargets(1, 4)
+				withCluster(targets[0:2], cluster1)
+				withCluster(targets[2:4], cluster2)
+				return targets
+			},
+			want: []partition{
+				{
+					Targets: createTargets(1, 2),
+					Status:  fleet.PartitionStatus{MaxUnavailable: 2},
+				},
+				{
+					Targets: createTargets(3, 4),
+					Status:  fleet.PartitionStatus{MaxUnavailable: 2},
+				},
+			},
+		},
+		{
+			name: "should match cluster groups",
+			rollout: &fleet.RolloutStrategy{
+				Partitions: []fleet.Partition{
+					{
+						Name:         "Partition 1",
+						ClusterGroup: "group-1",
+					},
+					{
+						Name:         "Partition 2",
+						ClusterGroup: "group-2",
+					},
+				},
+			},
+			targetsFn: func() []*Target {
+				targets := createTargets(1, 4)
+				cluster1 := &fleet.Cluster{
+					ObjectMeta: metav1.ObjectMeta{
+						Name: "cluster-1",
+					},
+				}
+				cluster2 := &fleet.Cluster{
+					ObjectMeta: metav1.ObjectMeta{
+						Name: "cluster-2",
+					},
+				}
+				withCluster(targets[0:2], cluster1)
+				withCluster(targets[2:4], cluster2)
+				withClusterGroup(targets[0:2], &fleet.ClusterGroup{
+					ObjectMeta: metav1.ObjectMeta{
+						Name: "group-1",
+					},
+					Spec: fleet.ClusterGroupSpec{
+						Selector: &metav1.LabelSelector{
+							MatchLabels: map[string]string{"group": "group-1"},
+						},
+					},
+				})
+				withClusterGroup(targets[2:4], &fleet.ClusterGroup{
+					ObjectMeta: metav1.ObjectMeta{
+						Name: "group-2",
+					},
+					Spec: fleet.ClusterGroupSpec{
+						Selector: &metav1.LabelSelector{
+							MatchLabels: map[string]string{"group": "group-2"},
+						},
+					},
+				})
+				return targets
+			},
+			want: []partition{
+				{Targets: createTargets(1, 2), Status: fleet.PartitionStatus{MaxUnavailable: 2}},
+				{Targets: createTargets(3, 4), Status: fleet.PartitionStatus{MaxUnavailable: 2}},
+			},
+		},
+		{
+			name: "selectors that match more than once should lead to having targets in multiple partitions",
+			rollout: &fleet.RolloutStrategy{
+				Partitions: []fleet.Partition{
+					{ClusterSelector: &metav1.LabelSelector{MatchLabels: map[string]string{"env": "testing"}}},
+					{ClusterSelector: &metav1.LabelSelector{MatchLabels: map[string]string{"group": "a"}}},
+				},
+			},
+			targetsFn: func() []*Target {
+				targets := createTargets(1, 100)
+				withCluster(targets[0:40], &fleet.Cluster{
+					ObjectMeta: metav1.ObjectMeta{
+						Labels: map[string]string{
+							"env": "testing",
+						},
+					},
+				})
+				withCluster(targets[40:60], &fleet.Cluster{
+					ObjectMeta: metav1.ObjectMeta{
+						Labels: map[string]string{
+							"env":   "testing",
+							"group": "a",
+						},
+					},
+				})
+				withCluster(targets[60:100], &fleet.Cluster{
+					ObjectMeta: metav1.ObjectMeta{
+						Labels: map[string]string{
+							"group": "a",
+						},
+					},
+				})
+				return targets
+			},
+			want: []partition{
+				{Targets: createTargets(1, 60), Status: fleet.PartitionStatus{MaxUnavailable: 60}},
+				{Targets: createTargets(41, 100), Status: fleet.PartitionStatus{MaxUnavailable: 60}},
+			},
+		},
+		{
+			name: "doesn't put unmatched clusters in separate partitions at the end using AutoPartitionSize",
+			rollout: &fleet.RolloutStrategy{
+				AutoPartitionSize: &intstr.IntOrString{Type: intstr.String, StrVal: "50%"},
+				Partitions: []fleet.Partition{
+					{Name: "first", ClusterSelector: &metav1.LabelSelector{MatchLabels: map[string]string{"group": "a"}}},
+				},
+			},
+			targetsFn: func() []*Target {
+				targets := createTargets(1, 100)
+				withCluster(targets[0:50], &fleet.Cluster{
+					ObjectMeta: metav1.ObjectMeta{
+						Labels: map[string]string{
+							"group": "a",
+						},
+					},
+				})
+				return targets
+			},
+			want: []partition{
+				{Targets: createTargets(1, 50), Status: fleet.PartitionStatus{MaxUnavailable: 50}},
+			},
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			var targets []*Target
+			if len(tt.targets) > 0 {
+				targets = tt.targets
+			} else {
+				targets = tt.targetsFn()
+			}
+			got, gotErr := manualPartition(tt.rollout, targets)
+			if gotErr != nil {
+				if !tt.wantErr {
+					t.Errorf("manualPartition() failed: %v", gotErr)
+				}
+				return
+			}
+			if tt.wantErr {
+				t.Fatal("manualPartition() succeeded unexpectedly")
+			}
+			if err := partitionsEqual(got, tt.want); err != nil {
+				fmt.Printf("got: %+v\n", got)
+				fmt.Printf("want: %+v\n", tt.want)
+				t.Errorf("manualPartition(): %v", err)
+			}
+		})
+	}
+}
diff --git a/internal/cmd/controller/target/status_test.go b/internal/cmd/controller/target/status_test.go
new file mode 100644
index 00000000..1ab1961f
--- /dev/null
+++ b/internal/cmd/controller/target/status_test.go
@@ -0,0 +1,531 @@
+package target
+
+import (
+	"testing"
+
+	fleet "github.com/rancher/fleet/pkg/apis/fleet.cattle.io/v1alpha1"
+	"k8s.io/apimachinery/pkg/util/intstr"
+)
+
+// availableTarget returns a Target that is considered available.
+func availableTarget() *Target {
+	return &Target{
+		Deployment: &fleet.BundleDeployment{
+			Spec: fleet.BundleDeploymentSpec{
+				DeploymentID:       "id",
+				StagedDeploymentID: "id",
+			},
+			Status: fleet.BundleDeploymentStatus{
+				AppliedDeploymentID: "id",
+				Ready:               true,
+			},
+		},
+		DeploymentID: "id",
+	}
+}
+
+// unavailableTargetMismatchedID returns a Target that is considered
+// unavailable due to a mismatched ID.
+func unavailableTargetMismatchedID() *Target {
+	return &Target{
+		Deployment: &fleet.BundleDeployment{
+			Spec: fleet.BundleDeploymentSpec{
+				DeploymentID:       "id",
+				StagedDeploymentID: "id",
+			},
+			Status: fleet.BundleDeploymentStatus{
+				AppliedDeploymentID: "off-id",
+				Ready:               true,
+			},
+		},
+		DeploymentID: "id",
+	}
+}
+
+// unavailableTargetNonReady returns a Target that is considered
+// unavailable due to not being ready.
+func unavailableTargetNonReady() *Target {
+	return &Target{
+		Deployment: &fleet.BundleDeployment{
+			Spec: fleet.BundleDeploymentSpec{
+				DeploymentID:       "id",
+				StagedDeploymentID: "id",
+			},
+			Status: fleet.BundleDeploymentStatus{
+				AppliedDeploymentID: "id",
+				Ready:               false,
+			},
+		},
+		DeploymentID: "id",
+	}
+}
+
+func targetWithRolloutStrategy(target *Target, rolloutStrategy fleet.RolloutStrategy) *Target {
+	if target.Bundle == nil {
+		target.Bundle = &fleet.Bundle{}
+	}
+	target.Bundle.Spec.RolloutStrategy = &rolloutStrategy
+	return target
+}
+
+func Test_limit(t *testing.T) {
+	tests := []struct {
+		name    string
+		count   int
+		val     []*intstr.IntOrString
+		want    int
+		wantErr bool
+	}{
+		{
+			name:  "fixed value below count",
+			count: 10,
+			val: []*intstr.IntOrString{
+				{IntVal: 5},
+			},
+			want: 5,
+		},
+		{
+			name:  "fixed value above count should result in count",
+			count: 10,
+			val: []*intstr.IntOrString{
+				{IntVal: 15},
+			},
+			want: 10,
+		},
+		{
+			name:  "with value with zero count",
+			count: 0,
+			val: []*intstr.IntOrString{
+				{IntVal: 15},
+			},
+			want: 1,
+		},
+		{
+			name:  "fixed value with negative count",
+			count: -15,
+			val: []*intstr.IntOrString{
+				{IntVal: 15},
+			},
+			want: 1,
+		},
+		{
+			name:  "two fixed values should take the first one",
+			count: 10,
+			val: []*intstr.IntOrString{
+				{IntVal: 5},
+				{IntVal: 15},
+			},
+			want: 5,
+		},
+		{
+			name:  "two fixed values should ignore nil",
+			count: 10,
+			val: []*intstr.IntOrString{
+				nil,
+				{IntVal: 10},
+			},
+			want: 10,
+		},
+		{
+			name:  "percent value 50",
+			count: 10,
+			val: []*intstr.IntOrString{
+				{Type: intstr.String, StrVal: "50%"},
+			},
+			want: 5,
+		},
+		{
+			name:  "percent value 100",
+			count: 11,
+			val: []*intstr.IntOrString{
+				{Type: intstr.String, StrVal: "100%"},
+			},
+			want: 11,
+		},
+		{
+			name:  "percent value 150",
+			count: 10,
+			val: []*intstr.IntOrString{
+				{Type: intstr.String, StrVal: "150%"},
+			},
+			want: 10,
+		},
+		{
+			name:  "percent value 10",
+			count: 10,
+			val: []*intstr.IntOrString{
+				{Type: intstr.String, StrVal: "10%"},
+			},
+			want: 1,
+		},
+		{
+			name:  "negative percent value",
+			count: 10,
+			val: []*intstr.IntOrString{
+				{Type: intstr.String, StrVal: "-10%"},
+			},
+			want: 1,
+		},
+		{
+			name:  "percent value 10 with count 5",
+			count: 5,
+			val: []*intstr.IntOrString{
+				{Type: intstr.String, StrVal: "10%"},
+			},
+			want: 1,
+		},
+		{
+			name:  "no value should match count",
+			count: 50,
+			val:   []*intstr.IntOrString{},
+			want:  50,
+		},
+		{
+			name:  "percentages are always rounded down",
+			count: 10,
+			val: []*intstr.IntOrString{
+				{Type: intstr.String, StrVal: "49%"},
+			},
+			want: 4,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got, gotErr := limit(tt.count, tt.val...)
+			if gotErr != nil {
+				if !tt.wantErr {
+					t.Errorf("limit() failed: %v", gotErr)
+				}
+				return
+			}
+			if tt.wantErr {
+				t.Fatal("limit() succeeded unexpectedly")
+			}
+			if got != tt.want {
+				t.Errorf("limit() = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}
+
+func Test_isUnavailable(t *testing.T) {
+	tests := []struct {
+		name   string
+		target *fleet.BundleDeployment
+		want   bool
+	}{
+		{
+			name:   "empty target should not be unavailable",
+			target: nil,
+			want:   false,
+		},
+		{
+			name: "ready but AppliedDeploymentID does not match DeploymentID",
+			target: &fleet.BundleDeployment{
+				Spec: fleet.BundleDeploymentSpec{
+					DeploymentID: "123",
+				},
+				Status: fleet.BundleDeploymentStatus{
+					AppliedDeploymentID: "456",
+					Ready:               true,
+				},
+			},
+			want: true,
+		},
+		{
+			name: "ready and AppliedDeploymentID does match DeploymentID",
+			target: &fleet.BundleDeployment{
+				Spec: fleet.BundleDeploymentSpec{
+					DeploymentID: "123",
+				},
+				Status: fleet.BundleDeploymentStatus{
+					AppliedDeploymentID: "123",
+					Ready:               true,
+				},
+			},
+			want: false,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got := isUnavailable(tt.target)
+			if got != tt.want {
+				t.Errorf("isAvailable() = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}
+
+func Test_upToDate(t *testing.T) {
+	tests := []struct {
+		name   string
+		target *Target
+		want   bool
+	}{
+		{
+			name:   "is not up-to-date if deployment is nil",
+			target: &Target{},
+			want:   false,
+		},
+		{
+			name: "is not up-to-date if .Spec.StagedDeploymentID does not match target.DeploymentID",
+			target: &Target{
+				Deployment: &fleet.BundleDeployment{
+					Spec: fleet.BundleDeploymentSpec{
+						DeploymentID:       "id",
+						StagedDeploymentID: "off-id",
+					},
+					Status: fleet.BundleDeploymentStatus{
+						AppliedDeploymentID: "id",
+					},
+				},
+				DeploymentID: "id",
+			},
+			want: false,
+		},
+		{
+			name: "is not up-to-date if .Spec.DeploymentID does not match target.DeploymentID",
+			target: &Target{
+				Deployment: &fleet.BundleDeployment{
+					Spec: fleet.BundleDeploymentSpec{
+						DeploymentID:       "off-id",
+						StagedDeploymentID: "id",
+					},
+					Status: fleet.BundleDeploymentStatus{
+						AppliedDeploymentID: "id",
+					},
+				},
+				DeploymentID: "id",
+			},
+			want: false,
+		},
+		{
+			name: "is not up-to-date if .Status.AppliedDeploymentID does not match target.DeploymentID",
+			target: &Target{
+				Deployment: &fleet.BundleDeployment{
+					Spec: fleet.BundleDeploymentSpec{
+						DeploymentID:       "id",
+						StagedDeploymentID: "id",
+					},
+					Status: fleet.BundleDeploymentStatus{
+						AppliedDeploymentID: "off-id",
+					},
+				},
+				DeploymentID: "id",
+			},
+			want: false,
+		},
+		{
+			name: "is up-to-date",
+			target: &Target{
+				Deployment: &fleet.BundleDeployment{
+					Spec: fleet.BundleDeploymentSpec{
+						DeploymentID:       "id",
+						StagedDeploymentID: "id",
+					},
+					Status: fleet.BundleDeploymentStatus{
+						AppliedDeploymentID: "id",
+					},
+				},
+				DeploymentID: "id",
+			},
+			want: true,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got := upToDate(tt.target)
+			if got != tt.want {
+				t.Errorf("upToDate() = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}
+
+func Test_updateStatusAndCheckUnavailable(t *testing.T) {
+	tests := []struct {
+		name    string
+		status  *fleet.PartitionStatus
+		targets []*Target
+		want    bool
+	}{
+		{
+			name: "should be available if all targets are available",
+			status: &fleet.PartitionStatus{
+				MaxUnavailable: 0,
+			},
+			targets: []*Target{
+				availableTarget(),
+				availableTarget(),
+			},
+			want: false,
+		},
+		{
+			name: "should be unavailable if one target is unavailable but 0 can be",
+			status: &fleet.PartitionStatus{
+				MaxUnavailable: 0,
+			},
+			targets: []*Target{
+				availableTarget(),
+				availableTarget(),
+				unavailableTargetMismatchedID(),
+			},
+			want: true,
+		},
+		{
+			name: "should be available if max unavailable is 1 and one target is unavailable",
+			status: &fleet.PartitionStatus{
+				MaxUnavailable: 1,
+			},
+			targets: []*Target{
+				availableTarget(),
+				availableTarget(),
+				unavailableTargetMismatchedID(),
+			},
+			want: false,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got := updatePartitionStatus(tt.status, tt.targets)
+			if got != tt.want {
+				t.Errorf("updateStatusUnavailable() = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}
+
+func TestUnavailable(t *testing.T) {
+	tests := []struct {
+		name    string
+		targets []*Target
+		want    int
+	}{
+		{
+			name: "should correctly count unavailable targets",
+			targets: []*Target{
+				availableTarget(),
+				availableTarget(),
+				unavailableTargetMismatchedID(),
+				unavailableTargetNonReady(),
+				availableTarget(),
+				{}, // Deployment is nil
+			},
+			want: 2,
+		},
+		{
+			name: "all targets available should return 0",
+			targets: []*Target{
+				availableTarget(),
+				availableTarget(),
+				availableTarget(),
+			},
+			want: 0,
+		},
+		{
+			name: "all targets unavailable should return count",
+			targets: []*Target{
+				unavailableTargetMismatchedID(),
+				unavailableTargetNonReady(),
+				unavailableTargetMismatchedID(),
+				unavailableTargetNonReady(),
+			},
+			want: 4,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got := Unavailable(tt.targets)
+			if got != tt.want {
+				t.Errorf("Unavailable() = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}
+
+// TestMaxUnavailable tests the MaxUnavailable function to ensure it correctly
+// calculates the maximum number of unavailable targets based on the provided
+// rollout strategy.
+func TestMaxUnavailable(t *testing.T) {
+	tests := []struct {
+		name    string
+		targets []*Target
+		want    int
+		wantErr bool
+	}{
+		{
+			name: "zero should be zero",
+			targets: []*Target{
+				targetWithRolloutStrategy(&Target{}, fleet.RolloutStrategy{
+					MaxUnavailable: &intstr.IntOrString{Type: intstr.Int, IntVal: 0},
+				}),
+			},
+			want: 0,
+		},
+		{
+			name: "percentage that leads to less than 1 max unavailable should return 1",
+			targets: []*Target{
+				targetWithRolloutStrategy(unavailableTargetNonReady(), fleet.RolloutStrategy{
+					MaxUnavailable: &intstr.IntOrString{Type: intstr.String, StrVal: "1%"},
+				}),
+				unavailableTargetNonReady(),
+				unavailableTargetNonReady(),
+				unavailableTargetNonReady(),
+			},
+			want: 1,
+		},
+		{
+			name: "25% of 4 is 1",
+			targets: []*Target{
+				targetWithRolloutStrategy(unavailableTargetNonReady(), fleet.RolloutStrategy{
+					MaxUnavailable: &intstr.IntOrString{Type: intstr.String, StrVal: "25%"},
+				}),
+				unavailableTargetNonReady(),
+				unavailableTargetNonReady(),
+				unavailableTargetNonReady(),
+			},
+			want: 1,
+		},
+		{
+			name: "49% of 4 is 1",
+			targets: []*Target{
+				targetWithRolloutStrategy(unavailableTargetNonReady(), fleet.RolloutStrategy{
+					MaxUnavailable: &intstr.IntOrString{Type: intstr.String, StrVal: "49%"},
+				}),
+				unavailableTargetNonReady(),
+				unavailableTargetNonReady(),
+				unavailableTargetNonReady(),
+			},
+			want: 1,
+		},
+		{
+			name: "50% of 4 is 2",
+			targets: []*Target{
+				targetWithRolloutStrategy(unavailableTargetNonReady(), fleet.RolloutStrategy{
+					MaxUnavailable: &intstr.IntOrString{Type: intstr.String, StrVal: "50%"},
+				}),
+				unavailableTargetNonReady(),
+				unavailableTargetNonReady(),
+				unavailableTargetNonReady(),
+			},
+			want: 2,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got, gotErr := MaxUnavailable(tt.targets)
+			if gotErr != nil {
+				if !tt.wantErr {
+					t.Errorf("MaxUnavailable() failed: %v", gotErr)
+				}
+				return
+			}
+			if tt.wantErr {
+				t.Fatal("MaxUnavailable() succeeded unexpectedly")
+			}
+			if got != tt.want {
+				t.Errorf("MaxUnavailable() = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}
diff --git a/internal/ocistorage/ociwrapper_test.go b/internal/ocistorage/ociwrapper_test.go
index f2f8eaf1..93e63fb8 100644
--- a/internal/ocistorage/ociwrapper_test.go
+++ b/internal/ocistorage/ociwrapper_test.go
@@ -315,38 +315,38 @@ var _ = Describe("OCIUtils tests", func() {
 	})
 })
 
-var _ = Describe("OCIUtils Experimental flag tests", func() {
+var _ = Describe("OCIUtils flag tests", func() {
 	var envBeforeTest string
 
 	BeforeEach(func() {
-		envBeforeTest = os.Getenv(OCIStorageExperimentalFlag)
+		envBeforeTest = os.Getenv(OCIStorageFlag)
 	})
 
 	AfterEach(func() {
 		if envBeforeTest != "" {
 			// set the value it had before the test
-			Expect(os.Setenv(OCIStorageExperimentalFlag, envBeforeTest)).ToNot(HaveOccurred())
+			Expect(os.Setenv(OCIStorageFlag, envBeforeTest)).ToNot(HaveOccurred())
 		} else {
-			Expect(os.Unsetenv(OCIStorageExperimentalFlag)).ToNot(HaveOccurred())
+			Expect(os.Unsetenv(OCIStorageFlag)).ToNot(HaveOccurred())
 		}
 	})
 
 	DescribeTable("Check value returned is the expected one",
 		func(value string, expected bool) {
 			if value == "unset" {
-				Expect(os.Unsetenv(OCIStorageExperimentalFlag)).ToNot(HaveOccurred())
+				Expect(os.Unsetenv(OCIStorageFlag)).ToNot(HaveOccurred())
 			} else {
-				Expect(os.Setenv(OCIStorageExperimentalFlag, value)).ToNot(HaveOccurred())
+				Expect(os.Setenv(OCIStorageFlag, value)).ToNot(HaveOccurred())
 			}
-			result := ExperimentalOCIIsEnabled()
+			result := OCIIsEnabled()
 			Expect(result).To(Equal(expected))
 		},
 		Entry("When setting to True", "True", true),
 		Entry("When setting to true", "true", true),
 		Entry("When setting to TRUE", "TRUE", true),
-		Entry("When setting to tRue", "tRue", false),
+		Entry("When setting to tRue", "tRue", true), // true because OCI storage is enabled by default.
 		Entry("When setting to false", "false", false),
-		Entry("When setting to whatever", "whatever", false),
-		Entry("When not setting the value", "unset", false),
+		Entry("When setting to whatever", "whatever", true), // true because OCI storage is enabled by default.
+		Entry("When not setting the value", "unset", true),  // true because OCI storage is enabled by default.
 	)
 })