Repository: rancher/fleet
Pull request #1987 (2023-12-01 13:41:12)

Add config for user/passwords in test infra


Patch:
diff --git a/.drone.yml b/.drone.yml
index 637ac5e9..afe14d80 100644
--- a/.drone.yml
+++ b/.drone.yml
@@ -218,99 +218,6 @@ volumes:
 
 ---
 
-kind: pipeline
-type: docker
-name: linux-s390x
-platform:
-  os: linux
-  arch: amd64
-# Hack needed for s390x: https://gist.github.com/colstrom/c2f359f72658aaabb44150ac20b16d7c#gistcomment-3858388
-node:
-  arch: s390x
-steps:
-- name: build
-  image: rancher/dapper:v0.5.8
-  commands:
-  - dapper build
-  volumes:
-  - name: docker
-    path: /var/run/docker.sock
-- name: github_binary_release
-  image: rancher/drone-images:github-release-s390x
-  settings:
-    api_key:
-      from_secret: github_token
-    prerelease: true
-    checksum:
-    - sha256
-    checksum_file: CHECKSUMsum-s390x.txt
-    checksum_flatten: true
-    files:
-    - "dist/artifacts/*"
-  when:
-    instance:
-    - drone-publish.rancher.io
-    ref:
-      include:
-      - refs/head/master
-      - refs/tags/*
-      exclude:
-      - refs/tags/*-debug-*
-    event:
-    - tag
-- name: docker-publish-agent
-  image: rancher/drone-images:docker-s390x
-  volumes:
-  - name: docker
-    path: /var/run/docker.sock
-  settings:
-    dockerfile: package/Dockerfile.agent
-    password:
-      from_secret: docker_password
-    repo: "rancher/fleet-agent"
-    tag: "${DRONE_TAG}-linux-s390x"
-    username:
-      from_secret: docker_username
-    build_args:
-      - "ARCH=s390x"
-  when:
-    instance:
-    - drone-publish.rancher.io
-    ref:
-    - refs/head/master
-    - refs/tags/*
-    event:
-    - tag
-- name: docker-publish
-  image: rancher/drone-images:docker-s390x
-  volumes:
-  - name: docker
-    path: /var/run/docker.sock
-  settings:
-    dockerfile: package/Dockerfile
-    password:
-      from_secret: docker_password
-    repo: "rancher/fleet"
-    tag: "${DRONE_TAG}-linux-s390x"
-    username:
-      from_secret: docker_username
-    build_args:
-      - "ARCH=s390x"
-  when:
-    instance:
-    - drone-publish.rancher.io
-    ref:
-    - refs/head/master
-    - refs/tags/*
-    event:
-    - tag
-volumes:
-- name: docker
-  host:
-    path: /var/run/docker.sock
-
----
-
 kind: pipeline
 type: docker
 name: windows-1809
@@ -453,4 +360,3 @@ depends_on:
 - linux-arm64
 - windows-1809
 - windows-2022
-- linux-s390x
diff --git a/.github/scripts/create-zot-certs.sh b/.github/scripts/create-zot-certs.sh
index c26d6a4c..ae488641 100755
--- a/.github/scripts/create-zot-certs.sh
+++ b/.github/scripts/create-zot-certs.sh
@@ -4,8 +4,8 @@ CA_name=${1-'FleetCI-RootCA'}
 validity_days=365
 passphrase=foo # this should not matter too much for local, ephemeral certs and keys
 
-mkdir -p $CA_name
-cd $CA_name
+mkdir -p "$CA_name"
+cd "$CA_name"
 
 must_create_certs ()
 {
diff --git a/.github/scripts/register-downstream-clusters.sh b/.github/scripts/register-downstream-clusters.sh
index fbf68e42..4704f2db 100755
--- a/.github/scripts/register-downstream-clusters.sh
+++ b/.github/scripts/register-downstream-clusters.sh
@@ -3,7 +3,7 @@
 set -euxo pipefail
 
 
-external_ip="${external_ip-172.18.0.1.omg.howdoi.website}"
+public_hostname="${public_hostname-172.18.0.1.omg.howdoi.website}"
 cluster_downstream="${cluster_downstream-k3d-downstream}"
 ctx=$(kubectl config current-context)
 
@@ -41,7 +41,7 @@ userPrincipal:
   provider: local
 EOF
 
-echo -e "4\n" | rancher login "https://$external_ip" --token "$token" --skip-verify
+echo -e "4\n" | rancher login "https://$public_hostname" --token "$token" --skip-verify
 
 rancher clusters create second --import
 until rancher cluster ls --format json | jq -r 'select(.Name=="second") | .ID' | grep -Eq "c-[a-z0-9]" ; do sleep 1; done
diff --git a/.github/scripts/setup-rancher.sh b/.github/scripts/setup-rancher.sh
index a7742fe2..8499982c 100755
--- a/.github/scripts/setup-rancher.sh
+++ b/.github/scripts/setup-rancher.sh
@@ -2,7 +2,7 @@
 
 set -euxo pipefail
 
-external_ip="${external_ip-172.18.0.1.omg.howdoi.website}"
+public_hostname="${public_hostname-172.18.0.1.omg.howdoi.website}"
 fleetns="${fleetns-cattle-fleet-system}"
 upstream_ctx="${FLEET_E2E_CLUSTER-k3d-upstream}"
 version="${1-v2.7.0}"
@@ -22,10 +22,10 @@ helm upgrade rancher rancher-latest/rancher --version "$version" \
   --create-namespace \
   --namespace cattle-system \
   --set replicas=1 \
-  --set hostname="$external_ip" \
+  --set hostname="$public_hostname" \
   --set bootstrapPassword=admin \
   --set "extraEnv[0].name=CATTLE_SERVER_URL" \
-  --set "extraEnv[0].value=https://$external_ip" \
+  --set "extraEnv[0].value=https://$public_hostname" \
   --set "extraEnv[1].name=CATTLE_BOOTSTRAP_PASSWORD" \
   --set "extraEnv[1].value=rancherpassword"
 
diff --git a/.github/workflows/rancher-integration.yml b/.github/workflows/rancher-integration.yml
index a30d93ca..344639a9 100644
--- a/.github/workflows/rancher-integration.yml
+++ b/.github/workflows/rancher-integration.yml
@@ -151,13 +151,13 @@ jobs:
       -
         name: Set up latest Rancher
         env:
-          external_ip: "172.18.0.1.omg.howdoi.website"
+          public_hostname: "172.18.0.1.omg.howdoi.website"
         run: |
           ./.github/scripts/setup-latest-rancher.sh
       -
         name: Register Rancher's downstream clusters
         env:
-          external_ip: "172.18.0.1.omg.howdoi.website"
+          public_hostname: "172.18.0.1.omg.howdoi.website"
         run: |
           ./.github/scripts/wait-for-loadbalancer.sh
           ./.github/scripts/register-downstream-clusters.sh
diff --git a/.github/workflows/rancher-upgrade-fleet.yml b/.github/workflows/rancher-upgrade-fleet.yml
index 7f02157a..254d691c 100644
--- a/.github/workflows/rancher-upgrade-fleet.yml
+++ b/.github/workflows/rancher-upgrade-fleet.yml
@@ -113,7 +113,7 @@ jobs:
       -
         name: Set up Rancher
         env:
-          external_ip: "172.18.0.1.omg.howdoi.website"
+          public_hostname: "172.18.0.1.omg.howdoi.website"
           fleetns: "${{ matrix.rancher_version == 'v2.5.16' && 'fleet-system' || 'cattle-fleet-system' }}"
         run: |
           ./.github/scripts/setup-rancher.sh "${{matrix.rancher_version}}"
diff --git a/dev/README.md b/dev/README.md
index e63c7bcc..a5ca4557 100644
--- a/dev/README.md
+++ b/dev/README.md
@@ -118,6 +118,24 @@ export CI_OCI_CERTS_DIR="../../FleetCI-RootCA"
 # optional, for selecting Helm versions (see [Troubleshooting](#troubleshooting))
 export HELM_PATH="/usr/bin/helm"
 ```
+
+### `public_hostname`
+
+Several scripts support the `public_hostname` configuration variable.
+The variable is set to a DNS record, which points to the public interface IP of the host.
+The k3d cluster is then set up with port forwardings, so that host ports are
+redirected to services inside the cluster.
+
+The default `public_hostname` is `172.18.0.1.omg.howdoi.website`, which points
+to the default Docker network gateway. That gateway address might vary for
+custom networks, see for example: `docker network inspect fleet -f '{{(index .IPAM.Config0).Gateway}}'`.
+Careful, several internet routers provide "DNS rebind protection" and won't return an IP for `172.18.0.1.omg.howdoi.website`, unless the `omg.howdoi.website` domain is in an allow list.
+Any magic wildcard DNS resolver will do, or you can create an A record in your own DNS zone.
+
+The k3d cluster is set up with multiple port forwardings by the scripts: `-p '80:80@server:0' -p '443:443@server:0'`.
+More arguments can be provided via the `k3d_args` variable.
+
+
 ### Troubleshooting
 
 If running the `infra setup` script returns an error about flag
@@ -143,6 +161,30 @@ incompatible way at any day.
 This will download and prepare setup-envtest, then it will execute all the
 integration tests.
 
+## Local Infra Setup
+
+The local infra setup creates pods for:
+* git server, using nginx with git-http-backend, port 8080/tcp
+* OCI repo server, using Zot, port 8081/tcp
+* Helm registry, using chartmuseum, port 5000/tcp
+
+To build and run the infra setup command do:
+
+```
+pushd e2e/testenv/infra
+  go build -o . ./...
+popd
+./e2e/testenv/infra/infra setup
+
+```
+
+The resulting deployments use a loadbalancer service, which means the host must be able to reach the loadbalancer IP.
+Therefore the infra setup doesn't work with the `public_hostname` config variable.
+This is not a problem, unless k3d is running in a VM and not directly on the host.
+
+It is possible to override the loadbalancer IP by setting the `external_ip` environment variable.
+
+
 ## Running Github Actions locally
 
 Sometimes, it may be beneficial to be able to run the Github Action tests using
diff --git a/dev/setup-fleet-downstream b/dev/setup-fleet-downstream
index 6e581f3c..07f8b29b 100755
--- a/dev/setup-fleet-downstream
+++ b/dev/setup-fleet-downstream
@@ -54,13 +54,13 @@ else
 fi
 
 # docker network inspect bridge -f '{{(index .IPAM.Config 0).Gateway}}'
-external_ip="${external_ip-172.17.0.1.omg.howdoi.website}"
+public_hostname="${public_hostname-172.17.0.1.omg.howdoi.website}"
 
 kubectl config use-context "$downstream_ctx"
 helm -n cattle-fleet-system upgrade --install --create-namespace --wait fleet-agent charts/fleet-agent \
   --set-string labels.env=test \
   --set apiServerCA="$ca" \
-  --set apiServerURL="https://$external_ip:36443" \
+  --set apiServerURL="https://$public_hostname:36443" \
   --set clusterNamespace="$ns" \
   --set token="$token"
   #--set systemRegistrationNamespace="fleet-clusters-system" \
diff --git a/dev/setup-rancher-clusters b/dev/setup-rancher-clusters
index fc1db5d0..20ed4776 100755
--- a/dev/setup-rancher-clusters
+++ b/dev/setup-rancher-clusters
@@ -7,7 +7,7 @@ if [ ! -d ./.github/scripts ]; then
   exit 1
 fi
 
-external_ip="${external_ip-172.18.0.1.omg.howdoi.website}"
+public_hostname="${public_hostname-172.18.0.1.omg.howdoi.website}"
 upstream_ctx="${FLEET_E2E_CLUSTER-k3d-upstream}"
 downstream_ctx="${FLEET_E2E_CLUSTER_DOWNSTREAM-k3d-downstream}"
 
@@ -32,10 +32,10 @@ helm upgrade rancher rancher-latest/rancher --version "$version" \
   --create-namespace \
   --namespace cattle-system \
   --set replicas=1 \
-  --set hostname="$external_ip" \
+  --set hostname="$public_hostname" \
   --set bootstrapPassword=admin \
   --set "extraEnv[0].name=CATTLE_SERVER_URL" \
-  --set "extraEnv[0].value=https://$external_ip" \
+  --set "extraEnv[0].value=https://$public_hostname" \
   --set "extraEnv[1].name=CATTLE_BOOTSTRAP_PASSWORD" \
   --set "extraEnv[1].value=rancherpassword"
 
@@ -50,6 +50,6 @@ kubectl -n cattle-fleet-system rollout status deploy/fleet-controller
 ./.github/scripts/wait-for-loadbalancer.sh
 
 export cluster_downstream="$downstream_ctx"
-./.github/scripts/register-downstream-clusters.sh "$external_ip"
+./.github/scripts/register-downstream-clusters.sh "$public_hostname"
 
 ./.github/scripts/label-downstream-cluster.sh
diff --git a/go.mod b/go.mod
index b2983885..e1770b24 100644
--- a/go.mod
+++ b/go.mod
@@ -47,7 +47,7 @@ require (
 	github.com/gobwas/glob v0.2.3
 	github.com/golang/mock v1.6.0
 	github.com/google/go-cmp v0.6.0
-	github.com/google/go-containerregistry v0.15.2
+	github.com/google/go-containerregistry v0.17.0
 	github.com/hashicorp/go-getter v1.7.3
 	github.com/jpillora/backoff v1.0.0
 	github.com/onsi/ginkgo/v2 v2.13.1
diff --git a/go.sum b/go.sum
index 68fe0a9a..a3559925 100644
--- a/go.sum
+++ b/go.sum
@@ -501,8 +501,8 @@ github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN
 github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
 github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
 github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
-github.com/google/go-containerregistry v0.15.2 h1:MMkSh+tjSdnmJZO7ljvEqV1DjfekB6VUEAZgy3a+TQE=
-github.com/google/go-containerregistry v0.15.2/go.mod h1:wWK+LnOv4jXMM23IT/F1wdYftGWGr47Is8CG+pmHK1Q=
+github.com/google/go-containerregistry v0.17.0 h1:5p+zYs/R4VGHkhyvgWurWrpJ2hW4Vv9fQI+GzdcwXLk=
+github.com/google/go-containerregistry v0.17.0/go.mod h1:u0qB2l7mvtWVR5kNcbFIhFY1hLbf8eeGapA+vbFDCtQ=
 github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
 github.com/google/gofuzz v1.1.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
 github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
diff --git a/internal/cmd/agent/controller.go b/internal/cmd/agent/controller.go
index e5e033a5..b9dbfc14 100644
--- a/internal/cmd/agent/controller.go
+++ b/internal/cmd/agent/controller.go
@@ -150,16 +150,17 @@ func newReconciler(ctx, localCtx context.Context, mgr manager.Manager, localConf
 	}
 
 	// Build the helm deployer, which uses a getter for local cluster's client-go client for helm SDK
-	getter := cli.New().RESTClientGetter() // uses local kubeconfig
-	helmDeployer, _ := helmdeployer.NewHelm(
-		ctx,
-		localClient,
+	helmDeployer := helmdeployer.New(
 		systemNamespace,
 		defaultNamespace,
 		defaultNamespace,
 		agentScope,
-		getter,
 	)
+	err = helmDeployer.Setup(ctx, localClient, cli.New().RESTClientGetter())
+	if err != nil {
+		setupLog.Error(err, "unable to setup local helm SDK client")
+		return nil, err
+	}
 
 	// Build the deployer that the bundledeployment reconciler will use
 	deployer := deployer.New(
diff --git a/internal/cmd/agent/deployer/cleanup/cleanup.go b/internal/cmd/agent/deployer/cleanup/cleanup.go
index 43a3e92e..9ae22035 100644
--- a/internal/cmd/agent/deployer/cleanup/cleanup.go
+++ b/internal/cmd/agent/deployer/cleanup/cleanup.go
@@ -98,7 +98,7 @@ func (c *Cleanup) garbageCollect(ctx context.Context) {
 }
 
 func (c *Cleanup) cleanup(ctx context.Context, logger logr.Logger) error {
-	deployed, err := c.helmDeployer.ListDeployments()
+	deployed, err := c.helmDeployer.ListDeployments(c.helmDeployer.NewListAction())
 	if err != nil {
 		return err
 	}
@@ -109,7 +109,7 @@ func (c *Cleanup) cleanup(ctx context.Context, logger logr.Logger) error {
 		if apierror.IsNotFound(err) {
 			// found a helm secret, but no bundle deployment, so uninstall the release
 			logger.Info("Deleting orphan bundle ID, helm uninstall", "bundleID", deployed.BundleID, "release", deployed.ReleaseName)
-			if err := c.helmDeployer.Delete(ctx, deployed.BundleID, deployed.ReleaseName); err != nil {
+			if err := c.helmDeployer.DeleteRelease(ctx, deployed); err != nil {
 				return err
 			}
 
@@ -122,7 +122,7 @@ func (c *Cleanup) cleanup(ctx context.Context, logger logr.Logger) error {
 		if key != deployed.ReleaseName {
 			// found helm secret and bundle deployment for BundleID, but release name doesn't match, so delete the release
 			logger.Info("Deleting unknown bundle ID, helm uninstall", "bundleID", deployed.BundleID, "release", deployed.ReleaseName, "expectedRelease", key)
-			if err := c.helmDeployer.Delete(ctx, deployed.BundleID, deployed.ReleaseName); err != nil {
+			if err := c.helmDeployer.DeleteRelease(ctx, deployed); err != nil {
 				return err
 			}
 		}
@@ -133,7 +133,7 @@ func (c *Cleanup) cleanup(ctx context.Context, logger logr.Logger) error {
 
 func (c *Cleanup) delete(ctx context.Context, bundleDeploymentKey string) error {
 	_, name := kv.RSplit(bundleDeploymentKey, "/")
-	return c.helmDeployer.Delete(ctx, name, "")
+	return c.helmDeployer.Delete(ctx, name)
 }
 
 // releaseKey returns a deploymentKey from namespace+releaseName
diff --git a/internal/helmdeployer/delete.go b/internal/helmdeployer/delete.go
new file mode 100644
index 00000000..32d2868c
--- /dev/null
+++ b/internal/helmdeployer/delete.go
@@ -0,0 +1,155 @@
+package helmdeployer
+
+import (
+	"context"
+	"fmt"
+	"strings"
+
+	"github.com/go-logr/logr"
+	"helm.sh/helm/v3/pkg/action"
+	"helm.sh/helm/v3/pkg/release"
+
+	fleet "github.com/rancher/fleet/pkg/apis/fleet.cattle.io/v1alpha1"
+
+	"github.com/rancher/wrangler/v2/pkg/kv"
+
+	"sigs.k8s.io/controller-runtime/pkg/log"
+)
+
+// DeleteRelease deletes the release for the DeployedBundle.
+func (h *Helm) DeleteRelease(ctx context.Context, deployment DeployedBundle) error {
+	return h.deleteByRelease(ctx, deployment.BundleID, deployment.ReleaseName, deployment.KeepResources)
+}
+
+// Delete the release for the given bundleID. The bundleID is the name of the
+// bundledeployment.
+func (h *Helm) Delete(ctx context.Context, bundleID string) error {
+	releaseName := ""
+	keepResources := false
+	deployments, err := h.ListDeployments(h.NewListAction())
+	if err != nil {
+		return err
+	}
+	for _, deployment := range deployments {
+		if deployment.BundleID == bundleID {
+			releaseName = deployment.ReleaseName
+			keepResources = deployment.KeepResources
+			break
+		}
+	}
+	if releaseName == "" {
+		// Never found anything to delete
+		return nil
+	}
+	return h.deleteByRelease(ctx, bundleID, releaseName, keepResources)
+}
+
+func (h *Helm) deleteByRelease(ctx context.Context, bundleID, releaseName string, keepResources bool) error {
+	logger := log.FromContext(ctx).WithName("deleteByRelease").WithValues("releaseName", releaseName, "keepResources", keepResources)
+	releaseNamespace, releaseName := kv.Split(releaseName, "/")
+	rels, err := h.globalCfg.Releases.List(func(r *release.Release) bool {
+		return r.Namespace == releaseNamespace &&
+			r.Name == releaseName &&
+			r.Chart.Metadata.Annotations[BundleIDAnnotation] == bundleID &&
+			r.Chart.Metadata.Annotations[AgentNamespaceAnnotation] == h.agentNamespace
+	})
+	if err != nil {
+		return nil
+	}
+	if len(rels) == 0 {
+		return nil
+	}
+
+	var (
+		serviceAccountName string
+	)
+	for _, rel := range rels {
+		serviceAccountName = rel.Chart.Metadata.Annotations[ServiceAccountNameAnnotation]
+		if serviceAccountName != "" {
+			break
+		}
+	}
+
+	cfg, err := h.getCfg(ctx, releaseNamespace, serviceAccountName)
+	if err != nil {
+		return err
+	}
+
+	if strings.HasPrefix(bundleID, "fleet-agent") {
+		// Never uninstall the fleet-agent, just "forget" it
+		return deleteHistory(cfg, logger, bundleID)
+	}
+
+	if keepResources {
+		// don't delete resources, just delete the helm release secrets
+		return deleteHistory(cfg, logger, bundleID)
+	}
+
+	u := action.NewUninstall(&cfg)
+	_, err = u.Run(releaseName)
+	return err
+}
+
+func (h *Helm) delete(ctx context.Context, bundleID string, options fleet.BundleDeploymentOptions, dryRun bool) error {
+	logger := log.FromContext(ctx).WithName("HelmDeployer").WithName("delete").WithValues("dryRun", dryRun)
+	timeout, _, releaseName := h.getOpts(bundleID, options)
+
+	r, err := h.globalCfg.Releases.Last(releaseName)
+	if err != nil {
+		return nil
+	}
+
+	if r.Chart.Metadata.Annotations[BundleIDAnnotation] != bundleID {
+		rels, err := h.globalCfg.Releases.History(releaseName)
+		if err != nil {
+			return nil
+		}
+		r = nil
+		for _, rel := range rels {
+			if rel.Chart.Metadata.Annotations[BundleIDAnnotation] == bundleID {
+				r = rel
+				break
+			}
+		}
+		if r == nil {
+			return fmt.Errorf("failed to find helm release to delete for %s", bundleID)
+		}
+	}
+
+	serviceAccountName := r.Chart.Metadata.Annotations[ServiceAccountNameAnnotation]
+	cfg, err := h.getCfg(ctx, r.Namespace, serviceAccountName)
+	if err != nil {
+		return err
+	}
+
+	if strings.HasPrefix(bundleID, "fleet-agent") {
+		// Never uninstall the fleet-agent, just "forget" it
+		return deleteHistory(cfg, logger, bundleID)
+	}
+
+	u := action.NewUninstall(&cfg)
+	u.DryRun = dryRun
+	u.Timeout = timeout
+
+	if !dryRun {
+		logger.Info("Helm: Uninstalling")
+	}
+	_, err = u.Run(releaseName)
+	return err
+}
+
+func deleteHistory(cfg action.Configuration, logger logr.Logger, bundleID string) error {
+	releases, err := cfg.Releases.List(func(r *release.Release) bool {
+		return r.Name == bundleID && r.Chart.Metadata.Annotations[BundleIDAnnotation] == bundleID
+	})
+	if err != nil {
+		return err
+	}
+	for _, release := range releases {
+		logger.Info("Helm: Deleting release", "releaseVersion", release.Version)
+		if _, err := cfg.Releases.Delete(release.Name, release.Version); err != nil {
+			return err
+		}
+	}
+	return nil
+}
diff --git a/internal/helmdeployer/deployer.go b/internal/helmdeployer/deployer.go
index 6ca9adfd..36a22ddd 100644
--- a/internal/helmdeployer/deployer.go
+++ b/internal/helmdeployer/deployer.go
@@ -1,41 +1,25 @@
 package helmdeployer
 
 import (
-	"bytes"
 	"context"
 	"fmt"
-	"strconv"
 	"strings"
 	"time"
 
-	"github.com/go-logr/logr"
 	"github.com/pkg/errors"
 	"github.com/rancher/fleet/internal/config"
 	"github.com/rancher/fleet/internal/helmdeployer/helmcache"
 	"helm.sh/helm/v3/pkg/action"
-	"helm.sh/helm/v3/pkg/chart"
-	"helm.sh/helm/v3/pkg/chart/loader"
 	"helm.sh/helm/v3/pkg/kube"
-	"helm.sh/helm/v3/pkg/release"
 	"helm.sh/helm/v3/pkg/storage"
 	"helm.sh/helm/v3/pkg/storage/driver"
 
-	"github.com/rancher/fleet/internal/helmdeployer/kustomize"
-	"github.com/rancher/fleet/internal/helmdeployer/rawyaml"
-	"github.com/rancher/fleet/internal/helmdeployer/render"
-	"github.com/rancher/fleet/internal/manifest"
 	name2 "github.com/rancher/fleet/internal/name"
 	fleet "github.com/rancher/fleet/pkg/apis/fleet.cattle.io/v1alpha1"
 
-	"github.com/rancher/wrangler/v2/pkg/apply"
-	"github.com/rancher/wrangler/v2/pkg/kv"
 	"github.com/rancher/wrangler/v2/pkg/name"
-	"github.com/rancher/wrangler/v2/pkg/yaml"
 
-	corev1 "k8s.io/api/core/v1"
-	"k8s.io/apimachinery/pkg/api/meta"
 	"k8s.io/apimachinery/pkg/runtime"
-	"k8s.io/apimachinery/pkg/types"
 	"k8s.io/cli-runtime/pkg/genericclioptions"
 	"sigs.k8s.io/controller-runtime/pkg/client"
 	"sigs.k8s.io/controller-runtime/pkg/log"
@@ -58,16 +42,6 @@ var (
 	DefaultKey      = "values.yaml"
 )
 
-type postRender struct {
-	labelPrefix string
-	labelSuffix string
-	bundleID    string
-	manifest    *manifest.Manifest
-	chart       *chart.Chart
-	mapper      meta.RESTMapper
-	opts        fleet.BundleDeploymentOptions
-}
-
 type Helm struct {
 	client         client.Client
 	agentNamespace string
@@ -87,6 +61,7 @@ type Resources struct {
 	Objects          []runtime.Object `json:"objects,omitempty"`
 }
 
+// DeployedBundle is the link between a bundledeployment and a helm release
 type DeployedBundle struct {
 	// BundleID is the bundledeployment.Name
 	BundleID string
@@ -96,194 +71,43 @@ type DeployedBundle struct {
 	KeepResources bool
 }
 
-// NewHelm returns a new helm deployer
+// New returns a new helm deployer
 // * namespace is the system namespace, which is the namespace the agent is running in, e.g. cattle-fleet-system
-func NewHelm(ctx context.Context, client client.Client, namespace, defaultNamespace, labelPrefix, labelSuffix string, getter genericclioptions.RESTClientGetter) (*Helm, error) {
-	h := &Helm{
-		client:           client,
-		getter:           getter,
+func New(namespace, defaultNamespace, labelPrefix, labelSuffix string) *Helm {
+	return &Helm{
 		defaultNamespace: defaultNamespace,
 		agentNamespace:   namespace,
 		labelPrefix:      labelPrefix,
 		labelSuffix:      labelSuffix,
 	}
-	cfg, err := h.createCfg(ctx, "")
-	if err != nil {
-		return nil, err
-	}
-	h.globalCfg = cfg
-
-	return h, nil
 }
 
-func (p *postRender) Run(renderedManifests *bytes.Buffer) (modifiedManifests *bytes.Buffer, err error) {
-	data := renderedManifests.Bytes()
-
-	objs, err := yaml.ToObjects(bytes.NewBuffer(data))
-	if err != nil {
-		return nil, err
-	}
-
-	if len(objs) == 0 {
-		data = nil
-	}
-
-	// Kustomize applies some restrictions fleet does not have, like a regular expression, which checks for valid file
-	// names. If no instructions for kustomize are found in the manifests, then kustomize shouldn't be called at all
-	// to prevent causing issues with these restrictions.
-	kustomizable := false
-	for _, resource := range p.manifest.Resources {
-		if strings.HasSuffix(resource.Name, "kustomization.yaml") ||
-			strings.HasSuffix(resource.Name, "kustomization.yml") ||
-			strings.HasSuffix(resource.Name, "Kustomization") {
-			kustomizable = true
-			break
-		}
-	}
-	if kustomizable {
-		newObjs, processed, err := kustomize.Process(p.manifest, data, p.opts.Kustomize.Dir)
-		if err != nil {
-			return nil, err
-		}
-		if processed {
-			objs = newObjs
-		}
-	}
-
-	yamlObjs, err := rawyaml.ToObjects(p.chart)
-	if err != nil {
-		return nil, err
-	}
-	objs = append(objs, yamlObjs...)
-
-	setID := GetSetID(p.bundleID, p.labelPrefix, p.labelSuffix)
-	labels, annotations, err := apply.GetLabelsAndAnnotations(setID, nil)
-	if err != nil {
-		return nil, err
-	}
-
-	for _, obj := range objs {
-		m, err := meta.Accessor(obj)
-		if err != nil {
-			return nil, err
-		}
-		m.SetLabels(mergeMaps(m.GetLabels(), labels))
-		m.SetAnnotations(mergeMaps(m.GetAnnotations(), annotations))
-
-		if p.opts.TargetNamespace != "" {
-			if p.mapper != nil {
-				gvk := obj.GetObjectKind().GroupVersionKind()
-				mapping, err := p.mapper.RESTMapping(gvk.GroupKind(), gvk.Version)
-				if err != nil {
-					return nil, err
-				}
-				if mapping.Scope.Name() == meta.RESTScopeNameRoot {
-					apiVersion, kind := gvk.ToAPIVersionAndKind()
-					return nil, fmt.Errorf("invalid cluster scoped object [name=%s kind=%v apiVersion=%s] found, consider using \"defaultNamespace\", not \"namespace\" in fleet.yaml", m.GetName(),
-						kind, apiVersion)
-				}
-			}
-			m.SetNamespace(p.opts.TargetNamespace)
+// GetSetID constructs a identifier from the provided args, bundleID "fleet-agent" is special
+func GetSetID(bundleID, labelPrefix, labelSuffix string) string {
+	// bundle is fleet-agent bundle, we need to use setID fleet-agent-bootstrap since it was applied with import controller
+	if strings.HasPrefix(bundleID, "fleet-agent") {
+		if labelSuffix == "" {
+			return config.AgentBootstrapConfigName
 		}
+		return name.SafeConcatName(config.AgentBootstrapConfigName, labelSuffix)
 	}
-
-	data, err = yaml.ToBytes(objs)
-	return bytes.NewBuffer(data), err
-}
-
-// Deploy deploys an unpacked content resource with helm. bundleID is the name of the bundledeployment.
-func (h *Helm) Deploy(ctx context.Context, bundleID string, manifest *manifest.Manifest, options fleet.BundleDeploymentOptions) (*Resources, error) {
-	if options.Helm == nil {
-		options.Helm = &fleet.HelmOptions{}
-	}
-	if options.Kustomize == nil {
-		options.Kustomize = &fleet.KustomizeOptions{}
-	}
-
-	tar, err := render.HelmChart(bundleID, manifest, options)
-	if err != nil {
-		return nil, err
-	}
-
-	chart, err := loader.LoadArchive(tar)
-	if err != nil {
-		return nil, err
-	}
-
-	if chart.Metadata.Annotations == nil {
-		chart.Metadata.Annotations = map[string]string{}
-	}
-	chart.Metadata.Annotations[ServiceAccountNameAnnotation] = options.ServiceAccount
-	chart.Metadata.Annotations[BundleIDAnnotation] = bundleID
-	chart.Metadata.Annotations[AgentNamespaceAnnotation] = h.agentNamespace
-	chart.Metadata.Annotations[KeepResourcesAnnotation] = strconv.FormatBool(options.KeepResources)
-
-	if manifest.Commit != "" {
-		chart.Metadata.Annotations[CommitAnnotation] = manifest.Commit
-	}
-
-	if options.Helm.SkipSchemaValidation {
-		// TODO: instead of manipulating the chart object, use helm's own functionality when it's available:
-		//       https://github.com/helm/helm/pull/11510
-		chart.Schema = nil
-	}
-
-	if resources, err := h.install(ctx, bundleID, manifest, chart, options, true); err != nil {
-		return nil, err
-	} else if h.template {
-		return releaseToResources(resources)
-	}
-
-	release, err := h.install(ctx, bundleID, manifest, chart, options, false)
-	if err != nil {
-		return nil, err
+	if labelSuffix != "" {
+		return name.SafeConcatName(labelPrefix, bundleID, labelSuffix)
 	}
-
-	return releaseToResources(release)
+	return name.SafeConcatName(labelPrefix, bundleID)
 }
 
-// RemoveExternalChanges does a helm rollback to remove changes made outside of fleet.
-// It removes the helm history entry if the rollback fails.
-func (h *Helm) RemoveExternalChanges(ctx context.Context, bd *fleet.BundleDeployment) error {
-	log.FromContext(ctx).WithName("RemoveExternalChanges").Info("Drift correction: rollback")
+func (h *Helm) Setup(ctx context.Context, client client.Client, getter genericclioptions.RESTClientGetter) error {
+	h.client = client
+	h.getter = getter
 
-	_, defaultNamespace, releaseName := h.getOpts(bd.Name, bd.Spec.Options)
-	cfg, err := h.getCfg(ctx, defaultNamespace, bd.Spec.Options.ServiceAccount)
-	if err != nil {
-		return err
-	}
-	currentRelease, err := cfg.Releases.Last(releaseName)
+	cfg, err := h.createCfg(ctx, "")
 	if err != nil {
 		return err
 	}
+	h.globalCfg = cfg
 
-	r := action.NewRollback(&cfg)
-	r.Version = currentRelease.Version
-	if bd.Spec.CorrectDrift.Force {
-		r.Force = true
-	}
-	err = r.Run(releaseName)
-	if err != nil && !bd.Spec.CorrectDrift.KeepFailHistory {
-		return removeFailedRollback(cfg, currentRelease, err)
-	}
-
-	return err
-}
-
-func (h *Helm) mustUninstall(cfg *action.Configuration, releaseName string) (bool, error) {
-	r, err := cfg.Releases.Last(releaseName)
-	if err != nil {
-		return false, nil
-	}
-	return r.Info.Status == release.StatusUninstalling, err
-}
-
-func (h *Helm) mustInstall(cfg *action.Configuration, releaseName string) (bool, error) {
-	_, err := cfg.Releases.Deployed(releaseName)
-	if err != nil && strings.Contains(err.Error(), "has no deployed releases") {
-		return true, nil
-	}
-	return false, err
+	return nil
 }
 
 func (h *Helm) getOpts(bundleID string, options fleet.BundleDeploymentOptions) (time.Duration, string, string) {
@@ -350,429 +174,6 @@ func (h *Helm) getCfg(ctx context.Context, namespace, serviceAccountName string)
 	return cfg, err
 }
 
-// install runs helm install or upgrade and supports dry running the action. Will run helm rollback in case of a failed upgrade.
-func (h *Helm) install(ctx context.Context, bundleID string, manifest *manifest.Manifest, chart *chart.Chart, options fleet.BundleDeploymentOptions, dryRun bool) (*release.Release, error) {
-	logger := log.FromContext(ctx).WithName("HelmDeployer").WithName("install").WithValues("commit", manifest.Commit, "dryRun", dryRun)
-	timeout, defaultNamespace, releaseName := h.getOpts(bundleID, options)
-
-	values, err := h.getValues(ctx, options, defaultNamespace)
-	if err != nil {
-		return nil, err
-	}
-
-	cfg, err := h.getCfg(ctx, defaultNamespace, options.ServiceAccount)
-	if err != nil {
-		return nil, err
-	}
-
-	uninstall, err := h.mustUninstall(&cfg, releaseName)
-	if err != nil {
-		return nil, err
-	}
-
-	if uninstall {
-		logger.Info("Uninstalling helm release first")
-		if err := h.delete(ctx, bundleID, options, dryRun); err != nil {
-			return nil, err
-		}
-		if dryRun {
-			return nil, nil
-		}
-	}
-
-	install, err := h.mustInstall(&cfg, releaseName)
-	if err != nil {
-		return nil, err
-	}
-
-	pr := &postRender{
-		labelPrefix: h.labelPrefix,
-		labelSuffix: h.labelSuffix,
-		bundleID:    bundleID,
-		manifest:    manifest,
-		opts:        options,
-		chart:       chart,
-	}
-
-	if !h.useGlobalCfg {
-		mapper, err := cfg.RESTClientGetter.ToRESTMapper()
-		if err != nil {
-			return nil, err
-		}
-		pr.mapper = mapper
-	}
-
-	if install {
-		u := action.NewInstall(&cfg)
-		u.ClientOnly = h.template || dryRun
-		if cfg.Capabilities != nil {
-			if cfg.Capabilities.KubeVersion.Version != "" {
-				u.KubeVersion = &cfg.Capabilities.KubeVersion
-			}
-			if cfg.Capabilities.APIVersions != nil {
-				u.APIVersions = cfg.Capabilities.APIVersions
-			}
-		}
-		u.ForceAdopt = options.Helm.TakeOwnership
-		u.EnableDNS = !options.Helm.DisableDNS
-		u.Replace = true
-		u.ReleaseName = releaseName
-		u.CreateNamespace = true
-		u.Namespace = defaultNamespace
-		u.Timeout = timeout
-		u.DryRun = dryRun
-		u.PostRenderer = pr
-		u.WaitForJobs = options.Helm.WaitForJobs
-		if u.Timeout > 0 {
-			u.Wait = true
-		}
-		if !dryRun {
-			logger.Info("Installing helm release")
-		}
-		return u.Run(chart, values)
-	}
-
-	u := action.NewUpgrade(&cfg)
-	u.Adopt = true
-	u.EnableDNS = !options.Helm.DisableDNS
-	u.Force = options.Helm.Force
-	u.Atomic = options.Helm.Atomic
-	u.MaxHistory = options.Helm.MaxHistory
-	if u.MaxHistory == 0 {
-		u.MaxHistory = MaxHelmHistory
-	}
-	u.Namespace = defaultNamespace
-	u.Timeout = timeout
-	u.DryRun = dryRun
-	u.DisableOpenAPIValidation = h.template || dryRun
-	u.PostRenderer = pr
-	u.WaitForJobs = options.Helm.WaitForJobs
-	if u.Timeout > 0 {
-		u.Wait = true
-	}
-	if !dryRun {
-		logger.Info("Upgrading helm release")
-	}
-	rel, err := u.Run(releaseName, chart, values)
-	if err != nil && err.Error() == HelmUpgradeInterruptedError {
-		logger.Info("Helm doing a rollback", "error", HelmUpgradeInterruptedError)
-		r := action.NewRollback(&cfg)
-		err = r.Run(releaseName)
-		if err != nil {
-			return nil, err
-		}
-		logger.V(1).Info("Retrying upgrade after rollback")
-
-		return u.Run(releaseName, chart, values)
-	}
-
-	return rel, err
-}
-
-func (h *Helm) getValues(ctx context.Context, options fleet.BundleDeploymentOptions, defaultNamespace string) (map[string]interface{}, error) {
-	if options.Helm == nil {
-		return nil, nil
-	}
-
-	var values map[string]interface{}
-	if options.Helm.Values != nil {
-		values = options.Helm.Values.Data
-	}
-
-	// do not run this when using template
-	if !h.template {
-		for _, valuesFrom := range options.Helm.ValuesFrom {
-			var tempValues map[string]interface{}
-			if valuesFrom.ConfigMapKeyRef != nil {
-				name := valuesFrom.ConfigMapKeyRef.Name
-				namespace := valuesFrom.ConfigMapKeyRef.Namespace
-				if namespace == "" {
-					namespace = defaultNamespace
-				}
-				key := valuesFrom.ConfigMapKeyRef.Key
-				if key == "" {
-					key = DefaultKey
-				}
-				configMap := &corev1.ConfigMap{}
-				err := h.client.Get(ctx, types.NamespacedName{Name: name, Namespace: namespace}, configMap)
-				if err != nil {
-					return nil, err
-				}
-				tempValues, err = valuesFromConfigMap(name, namespace, key, configMap)
-				if err != nil {
-					return nil, err
-				}
-			}
-			if tempValues != nil {
-				values = mergeValues(values, tempValues)
-				tempValues = nil
-			}
-
-			// merge secret last to be compatible with fleet <= 0.6.0
-			if valuesFrom.SecretKeyRef != nil {
-				name := valuesFrom.SecretKeyRef.Name
-				namespace := valuesFrom.SecretKeyRef.Namespace
-				if namespace == "" {
-					namespace = defaultNamespace
-				}
-				key := valuesFrom.SecretKeyRef.Key
-				if key == "" {
-					key = DefaultKey
-				}
-				secret := &corev1.Secret{}
-				err := h.client.Get(ctx, types.NamespacedName{Namespace: namespace, Name: name}, secret)
-				if err != nil {
-					return nil, err
-				}
-				tempValues, err = valuesFromSecret(name, namespace, key, secret)
-				if err != nil {
-					return nil, err
-				}
-			}
-			if tempValues != nil {
-				values = mergeValues(values, tempValues)
-			}
-		}
-	}
-
-	return values, nil
-}
-
-// ListDeployments returns a list of bundles by listing all helm releases via
-// helm's storage driver (secrets)
-func (h *Helm) ListDeployments() ([]DeployedBundle, error) {
-	list := action.NewList(&h.globalCfg)
-	list.All = true
-	releases, err := list.Run()
-	if err != nil {
-		return nil, err
-	}
-
-	var (
-		result []DeployedBundle
-	)
-
-	for _, release := range releases {
-		d := release.Chart.Metadata.Annotations[BundleIDAnnotation]
-		if d == "" {
-			continue
-		}
-		ns := release.Chart.Metadata.Annotations[AgentNamespaceAnnotation]
-		if ns != "" && ns != h.agentNamespace {
-			continue
-		}
-		// ignore error as keepResources should be false if annotation not found
-		keepResources, _ := strconv.ParseBool(release.Chart.Metadata.Annotations[KeepResourcesAnnotation])
-		result = append(result, DeployedBundle{
-			BundleID:      d,
-			ReleaseName:   release.Namespace + "/" + release.Name,
-			KeepResources: keepResources,
-		})
-	}
-
-	return result, nil
-}
-
-func getReleaseNameVersionAndNamespace(bundleID, resourcesID string) (string, int, string, error) {
-	// When a bundle is installed a resourcesID is generated. If there is no
-	// resourcesID then there isn't anything to lookup.
-	if resourcesID == "" {
-		return "", 0, "", ErrNoResourceID
-	}
-	namespace, name := kv.Split(resourcesID, "/")
-	releaseName, versionStr := kv.Split(name, ":")
-	version, _ := strconv.Atoi(versionStr)
-
-	if releaseName == "" {
-		releaseName = bundleID
-	}
-
-	return releaseName, version, namespace, nil
-}
-
-func (h *Helm) getRelease(releaseName, namespace string, version int) (*release.Release, error) {
-	hist := action.NewHistory(&h.globalCfg)
-
-	releases, err := hist.Run(releaseName)
-	if err == driver.ErrReleaseNotFound {
-		return nil, ErrNoRelease
-	} else if err != nil {
-		return nil, err
-	}
-
-	for _, release := range releases {
-		if release.Name == releaseName && release.Version == version && release.Namespace == namespace {
-			return release, nil
-		}
-	}
-
-	return nil, ErrNoRelease
-}
-
-func (h *Helm) EnsureInstalled(bundleID, resourcesID string) (bool, error) {
-	releaseName, version, namespace, err := getReleaseNameVersionAndNamespace(bundleID, resourcesID)
-	if err != nil {
-		return false, err
-	}
-
-	if _, err := h.getRelease(releaseName, namespace, version); err == ErrNoRelease {
-		return false, nil
-	} else if err != nil {
-		return false, err
-	}
-	return true, nil
-}
-
-// Resources returns the resources from the helm release history
-func (h *Helm) Resources(bundleID, resourcesID string) (*Resources, error) {
-	releaseName, version, namespace, err := getReleaseNameVersionAndNamespace(bundleID, resourcesID)
-	if err != nil {
-		return &Resources{}, err
-	}
-
-	release, err := h.getRelease(releaseName, namespace, version)
-	if err == ErrNoRelease {
-		return &Resources{}, nil
-	} else if err != nil {
-		return nil, err
-	}
-	return releaseToResources(release)
-}
-
-func (h *Helm) ResourcesFromPreviousReleaseVersion(bundleID, resourcesID string) (*Resources, error) {
-	releaseName, version, namespace, err := getReleaseNameVersionAndNamespace(bundleID, resourcesID)
-	if err != nil {
-		return &Resources{}, err
-	}
-
-	release, err := h.getRelease(releaseName, namespace, version-1)
-	if err == ErrNoRelease {
-		return &Resources{}, nil
-	} else if err != nil {
-		return nil, err
-	}
-	return releaseToResources(release)
-}
-
-// Delete the release for the given bundleID. releaseName is a key in the
-// format "namespace/name". If releaseName is empty, search for a matching
-// release.
-func (h *Helm) Delete(ctx context.Context, bundleID, releaseName string) error {
-	keepResources := false
-	deployments, err := h.ListDeployments()
-	if err != nil {
-		return err
-	}
-	for _, deployment := range deployments {
-		if deployment.BundleID == bundleID {
-			if releaseName == "" {
-				releaseName = deployment.ReleaseName
-			}
-			keepResources = deployment.KeepResources
-			break
-		}
-	}
-	if releaseName == "" {
-		// Never found anything to delete
-		return nil
-	}
-	return h.deleteByRelease(ctx, bundleID, releaseName, keepResources)
-}
-
-func (h *Helm) deleteByRelease(ctx context.Context, bundleID, releaseName string, keepResources bool) error {
-	logger := log.FromContext(ctx).WithName("deleteByRelease").WithValues("releaseName", releaseName, "keepResources", keepResources)
-	releaseNamespace, releaseName := kv.Split(releaseName, "/")
-	rels, err := h.globalCfg.Releases.List(func(r *release.Release) bool {
-		return r.Namespace == releaseNamespace &&
-			r.Name == releaseName &&
-			r.Chart.Metadata.Annotations[BundleIDAnnotation] == bundleID &&
-			r.Chart.Metadata.Annotations[AgentNamespaceAnnotation] == h.agentNamespace
-	})
-	if err != nil {
-		return nil
-	}
-	if len(rels) == 0 {
-		return nil
-	}
-
-	var (
-		serviceAccountName string
-	)
-	for _, rel := range rels {
-		serviceAccountName = rel.Chart.Metadata.Annotations[ServiceAccountNameAnnotation]
-		if serviceAccountName != "" {
-			break
-		}
-	}
-
-	cfg, err := h.getCfg(ctx, releaseNamespace, serviceAccountName)
-	if err != nil {
-		return err
-	}
-
-	if strings.HasPrefix(bundleID, "fleet-agent") {
-		// Never uninstall the fleet-agent, just "forget" it
-		return deleteHistory(cfg, logger, bundleID)
-	}
-
-	if keepResources {
-		// don't delete resources, just delete the helm release secrets
-		return deleteHistory(cfg, logger, bundleID)
-	}
-
-	u := action.NewUninstall(&cfg)
-	_, err = u.Run(releaseName)
-	return err
-}
-
-func (h *Helm) delete(ctx context.Context, bundleID string, options fleet.BundleDeploymentOptions, dryRun bool) error {
-	logger := log.FromContext(ctx).WithName("HelmDeployer").WithName("delete").WithValues("dryRun", dryRun)
-	timeout, _, releaseName := h.getOpts(bundleID, options)
-
-	r, err := h.globalCfg.Releases.Last(releaseName)
-	if err != nil {
-		return nil
-	}
-
-	if r.Chart.Metadata.Annotations[BundleIDAnnotation] != bundleID {
-		rels, err := h.globalCfg.Releases.History(releaseName)
-		if err != nil {
-			return nil
-		}
-		r = nil
-		for _, rel := range rels {
-			if rel.Chart.Metadata.Annotations[BundleIDAnnotation] == bundleID {
-				r = rel
-				break
-			}
-		}
-		if r == nil {
-			return fmt.Errorf("failed to find helm release to delete for %s", bundleID)
-		}
-	}
-
-	serviceAccountName := r.Chart.Metadata.Annotations[ServiceAccountNameAnnotation]
-	cfg, err := h.getCfg(ctx, r.Namespace, serviceAccountName)
-	if err != nil {
-		return err
-	}
-
-	if strings.HasPrefix(bundleID, "fleet-agent") {
-		// Never uninstall the fleet-agent, just "forget" it
-		return deleteHistory(cfg, logger, bundleID)
-	}
-
-	u := action.NewUninstall(&cfg)
-	u.DryRun = dryRun
-	u.Timeout = timeout
-
-	if !dryRun {
-		logger.Info("Helm: Uninstalling")
-	}
-	_, err = u.Run(releaseName)
-	return err
-}
-
 func (h *Helm) createCfg(ctx context.Context, namespace string) (action.Configuration, error) {
 	logger := log.FromContext(ctx).WithName("helmSDK")
 	info := func(format string, v ...interface{}) {
@@ -796,142 +197,3 @@ func (h *Helm) createCfg(ctx context.Context, namespace string) (action.Configur
 		Log:              info,
 	}, nil
 }
-
-func deleteHistory(cfg action.Configuration, logger logr.Logger, bundleID string) error {
-	releases, err := cfg.Releases.List(func(r *release.Release) bool {
-		return r.Name == bundleID && r.Chart.Metadata.Annotations[BundleIDAnnotation] == bundleID
-	})
-	if err != nil {
-		return err
-	}
-	for _, release := range releases {
-		logger.Info("Helm: Deleting release", "releaseVersion", release.Version)
-		if _, err := cfg.Releases.Delete(release.Name, release.Version); err != nil {
-			return err
-		}
-	}
-	return nil
-}
-
-func valuesFromSecret(name, namespace, key string, secret *corev1.Secret) (map[string]interface{}, error) {
-	var m map[string]interface{}
-	if secret == nil {
-		return m, nil
-	}
-
-	values, ok := secret.Data[key]
-	if !ok {
-		return nil, fmt.Errorf("key %s is missing from secret %s/%s, can't use it in valuesFrom", key, namespace, name)
-	}
-	if err := yaml.Unmarshal(values, &m); err != nil {
-		return nil, err
-	}
-	return m, nil
-}
-
-func valuesFromConfigMap(name, namespace, key string, configMap *corev1.ConfigMap) (map[string]interface{}, error) {
-	var m map[string]interface{}
-	if configMap == nil {
-		return m, nil
-	}
-
-	values, ok := configMap.Data[key]
-	if !ok {
-		return nil, fmt.Errorf("key %s is missing from configmap %s/%s, can't use it in valuesFrom", key, namespace, name)
-	}
-	if err := yaml.Unmarshal([]byte(values), &m); err != nil {
-		return nil, err
-	}
-	return m, nil
-}
-
-func mergeMaps(base, other map[string]string) map[string]string {
-	result := map[string]string{}
-	for k, v := range base {
-		result[k] = v
-	}
-	for k, v := range other {
-		result[k] = v
-	}
-	return result
-}
-
-// mergeValues merges source and destination map, preferring values over maps
-// from the source values. This is slightly adapted from:
-// https://github.com/helm/helm/blob/2332b480c9cb70a0d8a85247992d6155fbe82416/cmd/helm/install.go#L359
-func mergeValues(dest, src map[string]interface{}) map[string]interface{} {
-	for k, v := range src {
-		// If the key doesn't exist already, then just set the key to that value
-		if _, exists := dest[k]; !exists {
-			// new key
-			dest[k] = v
-			continue
-		}
-		nextMap, ok := v.(map[string]interface{})
-		// If it isn't another map, overwrite the value
-		if !ok {
-			// new key is not a map, overwrite existing key as we prefer values over maps
-			dest[k] = v
-			continue
-		}
-		// Edge case: If the key exists in the destination, but isn't a map
-		destMap, isMap := dest[k].(map[string]interface{})
-		// If the source map has a map for this key, prefer it
-		if !isMap {
-			dest[k] = v
-			continue
-		}
-		// If we got to this point, it is a map in both, so merge them
-		dest[k] = mergeValues(destMap, nextMap)
-	}
-	return dest
-}
-
-func releaseToResources(release *release.Release) (*Resources, error) {
-	var (
-		err error
-	)
-	resources := &Resources{
-		DefaultNamespace: release.Namespace,
-		ID:               fmt.Sprintf("%s/%s:%d", release.Namespace, release.Name, release.Version),
-	}
-
-	resources.Objects, err = yaml.ToObjects(bytes.NewBufferString(release.Manifest))
-	return resources, err
-}
-
-// GetSetID constructs a identifier from the provided args, bundleID "fleet-agent" is special
-func GetSetID(bundleID, labelPrefix, labelSuffix string) string {
-	// bundle is fleet-agent bundle, we need to use setID fleet-agent-bootstrap since it was applied with import controller
-	if strings.HasPrefix(bundleID, "fleet-agent") {
-		if labelSuffix == "" {
-			return config.AgentBootstrapConfigName
-		}
-		return name.SafeConcatName(config.AgentBootstrapConfigName, labelSuffix)
-	}
-	if labelSuffix != "" {
-		return name.SafeConcatName(labelPrefix, bundleID, labelSuffix)
-	}
-	return name.SafeConcatName(labelPrefix, bundleID)
-}
-
-func removeFailedRollback(cfg action.Configuration, currentRelease *release.Release, err error) error {
-	failedRelease, errRel := cfg.Releases.Last(currentRelease.Name)
-	if errRel != nil {
-		return errors.Wrap(err, errRel.Error())
-	}
-	if failedRelease.Version == currentRelease.Version+1 &&
-		failedRelease.Info.Status == release.StatusFailed &&
-		strings.HasPrefix(failedRelease.Info.Description, "Rollback") {
-		_, errDel := cfg.Releases.Delete(failedRelease.Name, failedRelease.Version)
-		if errDel != nil {
-			return errors.Wrap(err, errDel.Error())
-		}
-		errUpdate := cfg.Releases.Update(currentRelease)
-		if errUpdate != nil {
-			return errors.Wrap(err, errUpdate.Error())
-		}
-	}
-
-	return err
-}
diff --git a/internal/helmdeployer/history.go b/internal/helmdeployer/history.go
new file mode 100644
index 00000000..b11ae51f
--- /dev/null
+++ b/internal/helmdeployer/history.go
@@ -0,0 +1,108 @@
+package helmdeployer
+
+import (
+	"bytes"
+	"fmt"
+	"strconv"
+
+	"helm.sh/helm/v3/pkg/action"
+	"helm.sh/helm/v3/pkg/release"
+	"helm.sh/helm/v3/pkg/storage/driver"
+
+	"github.com/rancher/wrangler/v2/pkg/kv"
+	"github.com/rancher/wrangler/v2/pkg/yaml"
+)
+
+func (h *Helm) EnsureInstalled(bundleID, resourcesID string) (bool, error) {
+	releaseName, version, namespace, err := getReleaseNameVersionAndNamespace(bundleID, resourcesID)
+	if err != nil {
+		return false, err
+	}
+
+	if _, err := h.getRelease(releaseName, namespace, version); err == ErrNoRelease {
+		return false, nil
+	} else if err != nil {
+		return false, err
+	}
+	return true, nil
+}
+
+// Resources returns the resources from the helm release history
+func (h *Helm) Resources(bundleID, resourcesID string) (*Resources, error) {
+	releaseName, version, namespace, err := getReleaseNameVersionAndNamespace(bundleID, resourcesID)
+	if err != nil {
+		return &Resources{}, err
+	}
+
+	release, err := h.getRelease(releaseName, namespace, version)
+	if err == ErrNoRelease {
+		return &Resources{}, nil
+	} else if err != nil {
+		return nil, err
+	}
+	return releaseToResources(release)
+}
+
+func (h *Helm) ResourcesFromPreviousReleaseVersion(bundleID, resourcesID string) (*Resources, error) {
+	releaseName, version, namespace, err := getReleaseNameVersionAndNamespace(bundleID, resourcesID)
+	if err != nil {
+		return &Resources{}, err
+	}
+
+	release, err := h.getRelease(releaseName, namespace, version-1)
+	if err == ErrNoRelease {
+		return &Resources{}, nil
+	} else if err != nil {
+		return nil, err
+	}
+	return releaseToResources(release)
+}
+
+func getReleaseNameVersionAndNamespace(bundleID, resourcesID string) (string, int, string, error) {
+	// When a bundle is installed a resourcesID is generated. If there is no
+	// resourcesID then there isn't anything to lookup.
+	if resourcesID == "" {
+		return "", 0, "", ErrNoResourceID
+	}
+	namespace, name := kv.Split(resourcesID, "/")
+	releaseName, versionStr := kv.Split(name, ":")
+	version, _ := strconv.Atoi(versionStr)
+
+	if releaseName == "" {
+		releaseName = bundleID
+	}
+
+	return releaseName, version, namespace, nil
+}
+
+func (h *Helm) getRelease(releaseName, namespace string, version int) (*release.Release, error) {
+	hist := action.NewHistory(&h.globalCfg)
+
+	releases, err := hist.Run(releaseName)
+	if err == driver.ErrReleaseNotFound {
+		return nil, ErrNoRelease
+	} else if err != nil {
+		return nil, err
+	}
+
+	for _, release := range releases {
+		if release.Name == releaseName && release.Version == version && release.Namespace == namespace {
+			return release, nil
+		}
+	}
+
+	return nil, ErrNoRelease
+}
+
+func releaseToResources(release *release.Release) (*Resources, error) {
+	var (
+		err error
+	)
+	resources := &Resources{
+		DefaultNamespace: release.Namespace,
+		ID:               fmt.Sprintf("%s/%s:%d", release.Namespace, release.Name, release.Version),
+	}
+
+	resources.Objects, err = yaml.ToObjects(bytes.NewBufferString(release.Manifest))
+	return resources, err
+}
diff --git a/internal/helmdeployer/install.go b/internal/helmdeployer/install.go
new file mode 100644
index 00000000..f24000cd
--- /dev/null
+++ b/internal/helmdeployer/install.go
@@ -0,0 +1,352 @@
+package helmdeployer
+
+import (
+	"context"
+	"fmt"
+	"strconv"
+	"strings"
+
+	"helm.sh/helm/v3/pkg/action"
+	"helm.sh/helm/v3/pkg/chart"
+	"helm.sh/helm/v3/pkg/chart/loader"
+	"helm.sh/helm/v3/pkg/release"
+
+	"github.com/rancher/fleet/internal/helmdeployer/render"
+	"github.com/rancher/fleet/internal/manifest"
+	fleet "github.com/rancher/fleet/pkg/apis/fleet.cattle.io/v1alpha1"
+
+	"github.com/rancher/wrangler/v2/pkg/yaml"
+
+	corev1 "k8s.io/api/core/v1"
+	"k8s.io/apimachinery/pkg/types"
+	"sigs.k8s.io/controller-runtime/pkg/log"
+)
+
+// Deploy deploys an unpacked content resource with helm. bundleID is the name of the bundledeployment.
+func (h *Helm) Deploy(ctx context.Context, bundleID string, manifest *manifest.Manifest, options fleet.BundleDeploymentOptions) (*Resources, error) {
+	if options.Helm == nil {
+		options.Helm = &fleet.HelmOptions{}
+	}
+	if options.Kustomize == nil {
+		options.Kustomize = &fleet.KustomizeOptions{}
+	}
+
+	tar, err := render.HelmChart(bundleID, manifest, options)
+	if err != nil {
+		return nil, err
+	}
+
+	chart, err := loader.LoadArchive(tar)
+	if err != nil {
+		return nil, err
+	}
+
+	if chart.Metadata.Annotations == nil {
+		chart.Metadata.Annotations = map[string]string{}
+	}
+	chart.Metadata.Annotations[ServiceAccountNameAnnotation] = options.ServiceAccount
+	chart.Metadata.Annotations[BundleIDAnnotation] = bundleID
+	chart.Metadata.Annotations[AgentNamespaceAnnotation] = h.agentNamespace
+	chart.Metadata.Annotations[KeepResourcesAnnotation] = strconv.FormatBool(options.KeepResources)
+
+	if manifest.Commit != "" {
+		chart.Metadata.Annotations[CommitAnnotation] = manifest.Commit
+	}
+
+	if options.Helm.SkipSchemaValidation {
+		// TODO: instead of manipulating the chart object, use helm's own functionality when it's available:
+		//       https://github.com/helm/helm/pull/11510
+		chart.Schema = nil
+	}
+
+	if resources, err := h.install(ctx, bundleID, manifest, chart, options, true); err != nil {
+		return nil, err
+	} else if h.template {
+		return releaseToResources(resources)
+	}
+
+	release, err := h.install(ctx, bundleID, manifest, chart, options, false)
+	if err != nil {
+		return nil, err
+	}
+
+	return releaseToResources(release)
+}
+
+// install runs helm install or upgrade and supports dry running the action. Will run helm rollback in case of a failed upgrade.
+func (h *Helm) install(ctx context.Context, bundleID string, manifest *manifest.Manifest, chart *chart.Chart, options fleet.BundleDeploymentOptions, dryRun bool) (*release.Release, error) {
+	logger := log.FromContext(ctx).WithName("HelmDeployer").WithName("install").WithValues("commit", manifest.Commit, "dryRun", dryRun)
+	timeout, defaultNamespace, releaseName := h.getOpts(bundleID, options)
+
+	values, err := h.getValues(ctx, options, defaultNamespace)
+	if err != nil {
+		return nil, err
+	}
+
+	cfg, err := h.getCfg(ctx, defaultNamespace, options.ServiceAccount)
+	if err != nil {
+		return nil, err
+	}
+
+	uninstall, err := h.mustUninstall(&cfg, releaseName)
+	if err != nil {
+		return nil, err
+	}
+
+	if uninstall {
+		logger.Info("Uninstalling helm release first")
+		if err := h.delete(ctx, bundleID, options, dryRun); err != nil {
+			return nil, err
+		}
+		if dryRun {
+			return nil, nil
+		}
+	}
+
+	install, err := h.mustInstall(&cfg, releaseName)
+	if err != nil {
+		return nil, err
+	}
+
+	pr := &postRender{
+		labelPrefix: h.labelPrefix,
+		labelSuffix: h.labelSuffix,
+		bundleID:    bundleID,
+		manifest:    manifest,
+		opts:        options,
+		chart:       chart,
+	}
+
+	if !h.useGlobalCfg {
+		mapper, err := cfg.RESTClientGetter.ToRESTMapper()
+		if err != nil {
+			return nil, err
+		}
+		pr.mapper = mapper
+	}
+
+	if install {
+		u := action.NewInstall(&cfg)
+		u.ClientOnly = h.template || dryRun
+		if cfg.Capabilities != nil {
+			if cfg.Capabilities.KubeVersion.Version != "" {
+				u.KubeVersion = &cfg.Capabilities.KubeVersion
+			}
+			if cfg.Capabilities.APIVersions != nil {
+				u.APIVersions = cfg.Capabilities.APIVersions
+			}
+		}
+		u.ForceAdopt = options.Helm.TakeOwnership
+		u.EnableDNS = !options.Helm.DisableDNS
+		u.Replace = true
+		u.ReleaseName = releaseName
+		u.CreateNamespace = true
+		u.Namespace = defaultNamespace
+		u.Timeout = timeout
+		u.DryRun = dryRun
+		u.PostRenderer = pr
+		u.WaitForJobs = options.Helm.WaitForJobs
+		if u.Timeout > 0 {
+			u.Wait = true
+		}
+		if !dryRun {
+			logger.Info("Installing helm release")
+		}
+		return u.Run(chart, values)
+	}
+
+	u := action.NewUpgrade(&cfg)
+	u.Adopt = true
+	u.EnableDNS = !options.Helm.DisableDNS
+	u.Force = options.Helm.Force
+	u.Atomic = options.Helm.Atomic
+	u.MaxHistory = options.Helm.MaxHistory
+	if u.MaxHistory == 0 {
+		u.MaxHistory = MaxHelmHistory
+	}
+	u.Namespace = defaultNamespace
+	u.Timeout = timeout
+	u.DryRun = dryRun
+	u.DisableOpenAPIValidation = h.template || dryRun
+	u.PostRenderer = pr
+	u.WaitForJobs = options.Helm.WaitForJobs
+	if u.Timeout > 0 {
+		u.Wait = true
+	}
+	if !dryRun {
+		logger.Info("Upgrading helm release")
+	}
+	rel, err := u.Run(releaseName, chart, values)
+	if err != nil && err.Error() == HelmUpgradeInterruptedError {
+		logger.Info("Helm doing a rollback", "error", HelmUpgradeInterruptedError)
+		r := action.NewRollback(&cfg)
+		err = r.Run(releaseName)
+		if err != nil {
+			return nil, err
+		}
+		logger.V(1).Info("Retrying upgrade after rollback")
+
+		return u.Run(releaseName, chart, values)
+	}
+
+	return rel, err
+}
+
+func (h *Helm) mustUninstall(cfg *action.Configuration, releaseName string) (bool, error) {
+	r, err := cfg.Releases.Last(releaseName)
+	if err != nil {
+		return false, nil
+	}
+	return r.Info.Status == release.StatusUninstalling, err
+}
+
+func (h *Helm) mustInstall(cfg *action.Configuration, releaseName string) (bool, error) {
+	_, err := cfg.Releases.Deployed(releaseName)
+	if err != nil && strings.Contains(err.Error(), "has no deployed releases") {
+		return true, nil
+	}
+	return false, err
+}
+
+func (h *Helm) getValues(ctx context.Context, options fleet.BundleDeploymentOptions, defaultNamespace string) (map[string]interface{}, error) {
+	if options.Helm == nil {
+		return nil, nil
+	}
+
+	var values map[string]interface{}
+	if options.Helm.Values != nil {
+		values = options.Helm.Values.Data
+	}
+
+	// do not run this when using template
+	if !h.template {
+		for _, valuesFrom := range options.Helm.ValuesFrom {
+			var tempValues map[string]interface{}
+			if valuesFrom.ConfigMapKeyRef != nil {
+				name := valuesFrom.ConfigMapKeyRef.Name
+				namespace := valuesFrom.ConfigMapKeyRef.Namespace
+				if namespace == "" {
+					namespace = defaultNamespace
+				}
+				key := valuesFrom.ConfigMapKeyRef.Key
+				if key == "" {
+					key = DefaultKey
+				}
+				configMap := &corev1.ConfigMap{}
+				err := h.client.Get(ctx, types.NamespacedName{Name: name, Namespace: namespace}, configMap)
+				if err != nil {
+					return nil, err
+				}
+				tempValues, err = valuesFromConfigMap(name, namespace, key, configMap)
+				if err != nil {
+					return nil, err
+				}
+			}
+			if tempValues != nil {
+				values = mergeValues(values, tempValues)
+				tempValues = nil
+			}
+
+			// merge secret last to be compatible with fleet <= 0.6.0
+			if valuesFrom.SecretKeyRef != nil {
+				name := valuesFrom.SecretKeyRef.Name
+				namespace := valuesFrom.SecretKeyRef.Namespace
+				if namespace == "" {
+					namespace = defaultNamespace
+				}
+				key := valuesFrom.SecretKeyRef.Key
+				if key == "" {
+					key = DefaultKey
+				}
+				secret := &corev1.Secret{}
+				err := h.client.Get(ctx, types.NamespacedName{Namespace: namespace, Name: name}, secret)
+				if err != nil {
+					return nil, err
+				}
+				tempValues, err = valuesFromSecret(name, namespace, key, secret)
+				if err != nil {
+					return nil, err
+				}
+			}
+			if tempValues != nil {
+				values = mergeValues(values, tempValues)
+			}
+		}
+	}
+
+	return values, nil
+}
+
+func valuesFromSecret(name, namespace, key string, secret *corev1.Secret) (map[string]interface{}, error) {
+	var m map[string]interface{}
+	if secret == nil {
+		return m, nil
+	}
+
+	values, ok := secret.Data[key]
+	if !ok {
+		return nil, fmt.Errorf("key %s is missing from secret %s/%s, can't use it in valuesFrom", key, namespace, name)
+	}
+	if err := yaml.Unmarshal(values, &m); err != nil {
+		return nil, err
+	}
+	return m, nil
+}
+
+func valuesFromConfigMap(name, namespace, key string, configMap *corev1.ConfigMap) (map[string]interface{}, error) {
+	var m map[string]interface{}
+	if configMap == nil {
+		return m, nil
+	}
+
+	values, ok := configMap.Data[key]
+	if !ok {
+		return nil, fmt.Errorf("key %s is missing from configmap %s/%s, can't use it in valuesFrom", key, namespace, name)
+	}
+	if err := yaml.Unmarshal([]byte(values), &m); err != nil {
+		return nil, err
+	}
+	return m, nil
+}
+
+func mergeMaps(base, other map[string]string) map[string]string {
+	result := map[string]string{}
+	for k, v := range base {
+		result[k] = v
+	}
+	for k, v := range other {
+		result[k] = v
+	}
+	return result
+}
+
+// mergeValues merges source and destination map, preferring values over maps
+// from the source values. This is slightly adapted from:
+// https://github.com/helm/helm/blob/2332b480c9cb70a0d8a85247992d6155fbe82416/cmd/helm/install.go#L359
+func mergeValues(dest, src map[string]interface{}) map[string]interface{} {
+	for k, v := range src {
+		// If the key doesn't exist already, then just set the key to that value
+		if _, exists := dest[k]; !exists {
+			// new key
+			dest[k] = v
+			continue
+		}
+		nextMap, ok := v.(map[string]interface{})
+		// If it isn't another map, overwrite the value
+		if !ok {
+			// new key is not a map, overwrite existing key as we prefer values over maps
+			dest[k] = v
+			continue
+		}
+		// Edge case: If the key exists in the destination, but isn't a map
+		destMap, isMap := dest[k].(map[string]interface{})
+		// If the source map has a map for this key, prefer it
+		if !isMap {
+			dest[k] = v
+			continue
+		}
+		// If we got to this point, it is a map in both, so merge them
+		dest[k] = mergeValues(destMap, nextMap)
+	}
+	return dest
+}
diff --git a/internal/helmdeployer/deployer_test.go b/internal/helmdeployer/install_test.go
similarity index 100%
rename from internal/helmdeployer/deployer_test.go
rename to internal/helmdeployer/install_test.go
diff --git a/internal/helmdeployer/list.go b/internal/helmdeployer/list.go
new file mode 100644
index 00000000..815d6746
--- /dev/null
+++ b/internal/helmdeployer/list.go
@@ -0,0 +1,59 @@
+package helmdeployer
+
+import (
+	"strconv"
+
+	"helm.sh/helm/v3/pkg/action"
+	"helm.sh/helm/v3/pkg/release"
+)
+
+type ListAction interface {
+	Run() ([]*release.Release, error)
+}
+
+func (h *Helm) NewListAction() *action.List {
+	list := action.NewList(&h.globalCfg)
+	list.All = true
+	return list
+}
+
+// ListDeployments returns a list of deployedBundles by listing all helm releases via
+// helm's storage driver (secrets)
+// It only returns deployedBundles for helm releases which have the
+// "fleet.cattle.io/bundle-id" annotation.
+func (h *Helm) ListDeployments(list ListAction) ([]DeployedBundle, error) {
+	releases, err := list.Run()
+	if err != nil {
+		return nil, err
+	}
+
+	var (
+		result []DeployedBundle
+	)
+
+	for _, release := range releases {
+		// skip releases that don't have the bundleID annotation
+		d := release.Chart.Metadata.Annotations[BundleIDAnnotation]
+		if d == "" {
+			continue
+		}
+		ns := release.Chart.Metadata.Annotations[AgentNamespaceAnnotation]
+		// skip releases that don't have the agentNamespace annotation
+		if ns == "" {
+			continue
+		}
+		// skip releases from other agents
+		if ns != h.agentNamespace {
+			continue
+		}
+		// ignore error as keepResources should be false if annotation not found
+		keepResources, _ := strconv.ParseBool(release.Chart.Metadata.Annotations[KeepResourcesAnnotation])
+		result = append(result, DeployedBundle{
+			BundleID:      d,
+			ReleaseName:   release.Namespace + "/" + release.Name,
+			KeepResources: keepResources,
+		})
+	}
+
+	return result, nil
+}
diff --git a/internal/helmdeployer/postrender.go b/internal/helmdeployer/postrender.go
new file mode 100644
index 00000000..a1596bad
--- /dev/null
+++ b/internal/helmdeployer/postrender.go
@@ -0,0 +1,104 @@
+package helmdeployer
+
+import (
+	"bytes"
+	"fmt"
+	"strings"
+
+	"helm.sh/helm/v3/pkg/chart"
+
+	"github.com/rancher/fleet/internal/helmdeployer/kustomize"
+	"github.com/rancher/fleet/internal/helmdeployer/rawyaml"
+	"github.com/rancher/fleet/internal/manifest"
+	fleet "github.com/rancher/fleet/pkg/apis/fleet.cattle.io/v1alpha1"
+
+	"github.com/rancher/wrangler/v2/pkg/apply"
+	"github.com/rancher/wrangler/v2/pkg/yaml"
+
+	"k8s.io/apimachinery/pkg/api/meta"
+)
+
+type postRender struct {
+	labelPrefix string
+	labelSuffix string
+	bundleID    string
+	manifest    *manifest.Manifest
+	chart       *chart.Chart
+	mapper      meta.RESTMapper
+	opts        fleet.BundleDeploymentOptions
+}
+
+func (p *postRender) Run(renderedManifests *bytes.Buffer) (modifiedManifests *bytes.Buffer, err error) {
+	data := renderedManifests.Bytes()
+
+	objs, err := yaml.ToObjects(bytes.NewBuffer(data))
+	if err != nil {
+		return nil, err
+	}
+
+	if len(objs) == 0 {
+		data = nil
+	}
+
+	// Kustomize applies some restrictions fleet does not have, like a regular expression, which checks for valid file
+	// names. If no instructions for kustomize are found in the manifests, then kustomize shouldn't be called at all
+	// to prevent causing issues with these restrictions.
+	kustomizable := false
+	for _, resource := range p.manifest.Resources {
+		if strings.HasSuffix(resource.Name, "kustomization.yaml") ||
+			strings.HasSuffix(resource.Name, "kustomization.yml") ||
+			strings.HasSuffix(resource.Name, "Kustomization") {
+			kustomizable = true
+			break
+		}
+	}
+	if kustomizable {
+		newObjs, processed, err := kustomize.Process(p.manifest, data, p.opts.Kustomize.Dir)
+		if err != nil {
+			return nil, err
+		}
+		if processed {
+			objs = newObjs
+		}
+	}
+
+	yamlObjs, err := rawyaml.ToObjects(p.chart)
+	if err != nil {
+		return nil, err
+	}
+	objs = append(objs, yamlObjs...)
+
+	setID := GetSetID(p.bundleID, p.labelPrefix, p.labelSuffix)
+	labels, annotations, err := apply.GetLabelsAndAnnotations(setID, nil)
+	if err != nil {
+		return nil, err
+	}
+
+	for _, obj := range objs {
+		m, err := meta.Accessor(obj)
+		if err != nil {
+			return nil, err
+		}
+		m.SetLabels(mergeMaps(m.GetLabels(), labels))
+		m.SetAnnotations(mergeMaps(m.GetAnnotations(), annotations))
+
+		if p.opts.TargetNamespace != "" {
+			if p.mapper != nil {
+				gvk := obj.GetObjectKind().GroupVersionKind()
+				mapping, err := p.mapper.RESTMapping(gvk.GroupKind(), gvk.Version)
+				if err != nil {
+					return nil, err
+				}
+				if mapping.Scope.Name() == meta.RESTScopeNameRoot {
+					apiVersion, kind := gvk.ToAPIVersionAndKind()
+					return nil, fmt.Errorf("invalid cluster scoped object [name=%s kind=%v apiVersion=%s] found, consider using \"defaultNamespace\", not \"namespace\" in fleet.yaml", m.GetName(),
+						kind, apiVersion)
+				}
+			}
+			m.SetNamespace(p.opts.TargetNamespace)
+		}
+	}
+
+	data, err = yaml.ToBytes(objs)
+	return bytes.NewBuffer(data), err
+}
diff --git a/internal/helmdeployer/rollback.go b/internal/helmdeployer/rollback.go
new file mode 100644
index 00000000..167a54fe
--- /dev/null
+++ b/internal/helmdeployer/rollback.go
@@ -0,0 +1,63 @@
+package helmdeployer
+
+import (
+	"context"
+	"strings"
+
+	"github.com/pkg/errors"
+	"helm.sh/helm/v3/pkg/action"
+	"helm.sh/helm/v3/pkg/release"
+
+	fleet "github.com/rancher/fleet/pkg/apis/fleet.cattle.io/v1alpha1"
+
+	"sigs.k8s.io/controller-runtime/pkg/log"
+)
+
+// RemoveExternalChanges does a helm rollback to remove changes made outside of fleet.
+// It removes the helm history entry if the rollback fails.
+func (h *Helm) RemoveExternalChanges(ctx context.Context, bd *fleet.BundleDeployment) error {
+	log.FromContext(ctx).WithName("RemoveExternalChanges").Info("Drift correction: rollback")
+
+	_, defaultNamespace, releaseName := h.getOpts(bd.Name, bd.Spec.Options)
+	cfg, err := h.getCfg(ctx, defaultNamespace, bd.Spec.Options.ServiceAccount)
+	if err != nil {
+		return err
+	}
+	currentRelease, err := cfg.Releases.Last(releaseName)
+	if err != nil {
+		return err
+	}
+
+	r := action.NewRollback(&cfg)
+	r.Version = currentRelease.Version
+	if bd.Spec.CorrectDrift.Force {
+		r.Force = true
+	}
+	err = r.Run(releaseName)
+	if err != nil && !bd.Spec.CorrectDrift.KeepFailHistory {
+		return removeFailedRollback(cfg, currentRelease, err)
+	}
+
+	return err
+}
+
+func removeFailedRollback(cfg action.Configuration, currentRelease *release.Release, err error) error {
+	failedRelease, errRel := cfg.Releases.Last(currentRelease.Name)
+	if errRel != nil {
+		return errors.Wrap(err, errRel.Error())
+	}
+	if failedRelease.Version == currentRelease.Version+1 &&
+		failedRelease.Info.Status == release.StatusFailed &&
+		strings.HasPrefix(failedRelease.Info.Description, "Rollback") {
+		_, errDel := cfg.Releases.Delete(failedRelease.Name, failedRelease.Version)
+		if errDel != nil {
+			return errors.Wrap(err, errDel.Error())
+		}
+		errUpdate := cfg.Releases.Update(currentRelease)
+		if errUpdate != nil {
+			return errors.Wrap(err, errUpdate.Error())
+		}
+	}
+
+	return err
+}
diff --git a/pkg/apis/fleet.cattle.io/v1alpha1/bundle_types.go b/pkg/apis/fleet.cattle.io/v1alpha1/bundle_types.go
index 01613fa5..0cd069b8 100644
--- a/pkg/apis/fleet.cattle.io/v1alpha1/bundle_types.go
+++ b/pkg/apis/fleet.cattle.io/v1alpha1/bundle_types.go
@@ -10,7 +10,7 @@ func init() {
 	SchemeBuilder.Register(&Bundle{}, &BundleList{})
 }
 
-var (
+const (
 	// Ready: Bundles have been deployed and all resources are ready.
 	Ready BundleState = "Ready"
 	// NotReady: Bundles have been deployed and some resources are not
@@ -32,7 +32,9 @@ var (
 	// but there are some changes that were not made from the Git
 	// Repository.
 	Modified BundleState = "Modified"
+)
 
+var (
 	// StateRank ranks the state, e.g. so the highest ranked non-ready
 	// state can be reported in a summary.
 	StateRank = map[BundleState]int{
@@ -274,7 +276,7 @@ type NonReadyResource struct {
 	NonReadyStatus []NonReadyStatus `json:"nonReadyStatus,omitempty"`
 }
 
-var (
+const (
 	// BundleConditionReady is unused. A "Ready" condition on a bundle
 	// indicates that its resources are ready and the dependencies are
 	// fulfilled.


Test patch:
diff --git a/.github/scripts/setup-latest-rancher.sh b/.github/scripts/setup-latest-rancher.sh
index 3462d83b..1cdc3b22 100755
--- a/.github/scripts/setup-latest-rancher.sh
+++ b/.github/scripts/setup-latest-rancher.sh
@@ -2,7 +2,7 @@
 
 set -euxo pipefail
 
-external_ip="${external_ip-172.18.0.1.omg.howdoi.website}"
+public_hostname="${public_hostname-172.18.0.1.omg.howdoi.website}"
 cluster="${cluster-k3d-upstream}"
 
 kubectl config use-context "$cluster"
@@ -16,10 +16,10 @@ helm upgrade rancher rancher-latest/rancher \
   --create-namespace \
   --namespace cattle-system \
   --set replicas=1 \
-  --set hostname="$external_ip" \
+  --set hostname="$public_hostname" \
   --set bootstrapPassword=admin \
   --set "extraEnv[0].name=CATTLE_SERVER_URL" \
-  --set "extraEnv[0].value=https://$external_ip" \
+  --set "extraEnv[0].value=https://$public_hostname" \
   --set "extraEnv[1].name=CATTLE_BOOTSTRAP_PASSWORD" \
   --set "extraEnv[1].value=rancherpassword"
 
diff --git a/e2e/assets/helm/chartmuseum_deployment.yaml b/e2e/assets/helm/chartmuseum_deployment.yaml
index 326e816a..a8b6581a 100644
--- a/e2e/assets/helm/chartmuseum_deployment.yaml
+++ b/e2e/assets/helm/chartmuseum_deployment.yaml
@@ -30,9 +30,9 @@ spec:
             - name: TLS_KEY
               value: /etc/chartmuseum/certs/tls.key
             - name: BASIC_AUTH_USER
-              value: fleet-ci
+              value: {{.User}}
             - name: BASIC_AUTH_PASS
-              value: foo
+              value: {{.Password}}
             - name: STORAGE
               value: local
           volumeMounts:
diff --git a/e2e/assets/helm/zot_secret.yaml b/e2e/assets/helm/zot_secret.yaml
index 3f45e535..0f58b614 100644
--- a/e2e/assets/helm/zot_secret.yaml
+++ b/e2e/assets/helm/zot_secret.yaml
@@ -8,5 +8,4 @@ metadata:
     meta.helm.sh/release-name: "zot"
       #meta.helm.sh/release-namespace: "default"
 stringData:
-  htpasswd: |-
-    fleet-ci:$2y$05$0WcEGGqsUKcyPhBFU7l07uJ3ND121p/FQCY90Q.dcsZjTkr.b45Lm
+  htpasswd: "{{.HTTPPasswd}}"
diff --git a/e2e/testenv/githelper/git.go b/e2e/testenv/githelper/git.go
index 79918e53..11c2a2a0 100644
--- a/e2e/testenv/githelper/git.go
+++ b/e2e/testenv/githelper/git.go
@@ -290,6 +290,10 @@ func BuildGitHostname(ns string) (string, error) {
 // GetExternalRepoAddr retrieves the external URL where our local git server can be reached, based on the provided port
 // and repo name.
 func GetExternalRepoAddr(env *testenv.Env, port int, repoName string) (string, error) {
+	if v := os.Getenv("CI_GIT_REPO_URL"); v != "" {
+		return fmt.Sprintf("%s/%s", v, repoName), nil
+	}
+
 	systemk := env.Kubectl.Namespace(env.Namespace)
 
 	externalIP, err := systemk.Get("service", "git-service", "-o", "jsonpath={.status.loadBalancer.ingress[0].ip}")
diff --git a/e2e/testenv/infra/cmd/setup.go b/e2e/testenv/infra/cmd/setup.go
index a6c41b7c..0fe8dbf5 100644
--- a/e2e/testenv/infra/cmd/setup.go
+++ b/e2e/testenv/infra/cmd/setup.go
@@ -16,6 +16,7 @@ import (
 	"github.com/rancher/fleet/e2e/testenv"
 	"github.com/rancher/fleet/e2e/testenv/kubectl"
 	"github.com/spf13/cobra"
+	"golang.org/x/crypto/bcrypt"
 	"helm.sh/helm/v3/pkg/registry"
 )
 
@@ -140,9 +141,7 @@ var setupCmd = &cobra.Command{
 			}
 
 			if externalIP == "" {
-				externalIP = eventually(func() (string, error) {
-					return k.Get("service", "zot-service", "-o", "jsonpath={.status.loadBalancer.ingress[0].ip}")
-				})
+				externalIP = waitForLoadbalancer(k, "zot-service")
 			}
 			OCIHost := fmt.Sprintf("%s:5000", externalIP)
 
@@ -150,7 +149,7 @@ var setupCmd = &cobra.Command{
 			_ = eventually(func() (string, error) {
 				err := OCIClient.Login(
 					OCIHost,
-					registry.LoginOptBasicAuth("fleet-ci", "foo"),
+					registry.LoginOptBasicAuth(os.Getenv("CI_OCI_USERNAME"), os.Getenv("CI_OCI_PASSWORD")),
 					registry.LoginOptInsecure(true),
 				)
 				if err != nil {
@@ -194,9 +193,7 @@ var setupCmd = &cobra.Command{
 			wgHelm.Wait()
 
 			if externalIP == "" {
-				externalIP = eventually(func() (string, error) {
-					return k.Get("service", "chartmuseum-service", "-o", "jsonpath={.status.loadBalancer.ingress[0].ip}")
-				})
+				externalIP = waitForLoadbalancer(k, "chartmuseum-service")
 			}
 
 			_ = eventually(func() (string, error) {
@@ -257,6 +254,7 @@ func spinUpGitServer(k kubectl.Command, wg *sync.WaitGroup) {
 	}
 
 	waitForPodReady(k, "git-server")
+	waitForLoadbalancer(k, "git-service")
 
 	fmt.Println("git server up.")
 }
@@ -268,12 +266,22 @@ func spinUpOCIRegistry(k kubectl.Command, wg *sync.WaitGroup) {
 		fail(fmt.Errorf("spin up OCI registry: %v", err))
 	}
 
-	out, err := k.Apply("-f", testenv.AssetPath("helm/zot_secret.yaml"))
+	var err error
+	htpasswd := "fleet-ci:$2y$05$0WcEGGqsUKcyPhBFU7l07uJ3ND121p/FQCY90Q.dcsZjTkr.b45Lm"
+	if os.Getenv("CI_OCI_USERNAME") != "" && os.Getenv("CI_OCI_PASSWORD") != "" {
+		p, err := bcrypt.GenerateFromPassword([]byte(os.Getenv("CI_OCI_PASSWORD")), bcrypt.MinCost)
+		if err != nil {
+			fail(fmt.Errorf("generate bcrypt password from env var: %v", err))
+		}
+		htpasswd = fmt.Sprintf("%s:%s", os.Getenv("CI_OCI_USERNAME"), string(p))
+	}
+
+	err = testenv.ApplyTemplate(k, "helm/zot_secret.yaml", struct{ HTTPPasswd string }{htpasswd})
 	if err != nil {
-		failOCI(fmt.Errorf("create Zot htpasswd secret: %s with error %v", out, err))
+		failOCI(fmt.Errorf("create Zot htpasswd secret: %v", err))
 	}
 
-	out, err = k.Apply("-f", testenv.AssetPath("helm/zot_configmap.yaml"))
+	out, err := k.Apply("-f", testenv.AssetPath("helm/zot_configmap.yaml"))
 	if err != nil {
 		failOCI(fmt.Errorf("apply Zot config map: %s with error %v", out, err))
 	}
@@ -300,12 +308,20 @@ func spinUpHelmRegistry(k kubectl.Command, wg *sync.WaitGroup) {
 		fail(fmt.Errorf("spin up ChartMuseum: %v", err))
 	}
 
-	out, err := k.Apply("-f", testenv.AssetPath("helm/chartmuseum_deployment.yaml"))
+	err := testenv.ApplyTemplate(k, "helm/chartmuseum_deployment.yaml",
+		struct {
+			User     string
+			Password string
+		}{
+			os.Getenv("CI_OCI_USERNAME"),
+			os.Getenv("CI_OCI_PASSWORD"),
+		},
+	)
 	if err != nil {
-		failChartMuseum(fmt.Errorf("apply ChartMuseum deployment: %s with error %v", out, err))
+		failChartMuseum(fmt.Errorf("apply ChartMuseum deployment: %v", err))
 	}
 
-	out, err = k.Apply("-f", testenv.AssetPath("helm/chartmuseum_service.yaml"))
+	out, err := k.Apply("-f", testenv.AssetPath("helm/chartmuseum_service.yaml"))
 	if err != nil {
 		failChartMuseum(fmt.Errorf("apply ChartMuseum service: %s with error %v", out, err))
 	}
@@ -348,6 +364,13 @@ func waitForPodReady(k kubectl.Command, appName string) {
 	})
 }
 
+func waitForLoadbalancer(k kubectl.Command, name string) string {
+	ip := eventually(func() (string, error) {
+		return k.Get("service", name, "-o", "jsonpath={.status.loadBalancer.ingress[0].ip}")
+	})
+	return ip
+}
+
 // fail prints err and exits.
 func fail(err error) {
 	fmt.Println(err.Error())
diff --git a/e2e/testenv/infra/go.mod b/e2e/testenv/infra/go.mod
index e79806fa..7f712393 100644
--- a/e2e/testenv/infra/go.mod
+++ b/e2e/testenv/infra/go.mod
@@ -13,6 +13,7 @@ replace k8s.io/client-go => k8s.io/client-go v0.28.3
 require (
 	github.com/rancher/fleet v0.0.0
 	github.com/spf13/cobra v1.8.0
+	golang.org/x/crypto v0.14.0
 	helm.sh/helm/v3 v3.13.2
 )
 
@@ -53,7 +54,6 @@ require (
 	github.com/prometheus/client_model v0.5.0 // indirect
 	github.com/prometheus/common v0.45.0 // indirect
 	github.com/prometheus/procfs v0.12.0 // indirect
-	github.com/rancher/gitjob v0.1.108 // indirect
 	github.com/rogpeppe/go-internal v1.11.0 // indirect
 	github.com/sirupsen/logrus v1.9.3 // indirect
 	github.com/spf13/pflag v1.0.5 // indirect
diff --git a/e2e/testenv/infra/go.sum b/e2e/testenv/infra/go.sum
index b0204d19..53dddef1 100644
--- a/e2e/testenv/infra/go.sum
+++ b/e2e/testenv/infra/go.sum
@@ -168,8 +168,6 @@ github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsT
 github.com/prometheus/procfs v0.0.3/go.mod h1:4A/X28fw3Fc593LaREMrKMqOKvUAntwMDaekg4FpcdQ=
 github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
 github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
-github.com/rancher/gitjob v0.1.108 h1:5uQgF/wwzr831TnAJw0lBLp5DM78o3gk86hxnyP0q8s=
-github.com/rancher/gitjob v0.1.108/go.mod h1:j8JcZCiFhB1YOJrZB6Ne15JUi7STqAunwSGFG9ydAEE=
 github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
 github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
@@ -269,7 +267,6 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo=
 gotest.tools/v3 v3.4.0 h1:ZazjZUfuVeZGLAmlKKuyv3IKP5orXcwtOwDQH6YVr6o=
 gotest.tools/v3 v3.4.0/go.mod h1:CtbdzLSsqVhDgMtKsx03ird5YTGB3ar27v0u/yKBW5g=
 helm.sh/helm/v3 v3.13.2 h1:IcO9NgmmpetJODLZhR3f3q+6zzyXVKlRizKFwbi7K8w=
diff --git a/e2e/testenv/template.go b/e2e/testenv/template.go
index 520014ab..29743698 100644
--- a/e2e/testenv/template.go
+++ b/e2e/testenv/template.go
@@ -10,7 +10,6 @@ import (
 	"strings"
 
 	ginkgo "github.com/onsi/ginkgo/v2"
-	"github.com/rancher/gitjob/e2e/testenv"
 
 	"github.com/rancher/fleet/e2e/testenv/kubectl"
 )
@@ -42,7 +41,7 @@ func ApplyTemplate(k kubectl.Command, asset string, data interface{}) error {
 	defer os.RemoveAll(tmpdir)
 
 	output := path.Join(tmpdir, RandomFilename(asset, rand.New(rand.NewSource(ginkgo.GinkgoRandomSeed())))) // nolint:gosec // test code
-	if err := Template(output, testenv.AssetPath(asset), data); err != nil {
+	if err := Template(output, AssetPath(asset), data); err != nil {
 		return err
 	}
 	out, err := k.Apply("-f", output)
diff --git a/integrationtests/agent/suite_test.go b/integrationtests/agent/suite_test.go
index 59ab29a7..baa37850 100644
--- a/integrationtests/agent/suite_test.go
+++ b/integrationtests/agent/suite_test.go
@@ -135,15 +135,13 @@ func newReconciler(ctx context.Context, mgr manager.Manager, lookup *lookup) *co
 	disc := memory.NewMemCacheClient(d)
 	mapper := restmapper.NewDeferredDiscoveryRESTMapper(disc)
 	getter := &restClientGetter{cfg: cfg, discovery: disc, restMapper: mapper}
-	helmDeployer, _ := helmdeployer.NewHelm(
-		ctx,
-		localClient,
+	helmDeployer := helmdeployer.New(
 		systemNamespace,
 		defaultNamespace,
 		defaultNamespace,
 		agentScope,
-		getter,
 	)
+	_ = helmDeployer.Setup(ctx, localClient, getter)
 
 	// Build the deployer that the bundledeployment reconciler will use
 	deployer := deployer.New(
diff --git a/internal/helmdeployer/list_test.go b/internal/helmdeployer/list_test.go
new file mode 100644
index 00000000..2fe64d7d
--- /dev/null
+++ b/internal/helmdeployer/list_test.go
@@ -0,0 +1,100 @@
+package helmdeployer_test
+
+import (
+	"testing"
+
+	"github.com/rancher/fleet/internal/helmdeployer"
+
+	"github.com/stretchr/testify/assert"
+
+	"helm.sh/helm/v3/pkg/chart"
+	"helm.sh/helm/v3/pkg/release"
+)
+
+type fakeList struct {
+	releases []*release.Release
+}
+
+func (f fakeList) Run() ([]*release.Release, error) {
+	return f.releases, nil
+}
+
+func newRelease(name string, namespace string, annotations map[string]string) *release.Release {
+	return &release.Release{
+		Name:      name,
+		Namespace: namespace,
+		Chart: &chart.Chart{
+			Metadata: &chart.Metadata{
+				Annotations: annotations,
+			},
+		},
+	}
+}
+
+func TestListDeployments(t *testing.T) {
+	r := assert.New(t)
+
+	const (
+		bundleIDAnnotation = "fleet.cattle.io/bundle-id"
+		agentNSAnnotation  = "fleet.cattle.io/agent-namespace"
+	)
+
+	h := helmdeployer.New("cattle-fleet-test", "", "", "")
+
+	tests := map[string]struct {
+		releases             []*release.Release
+		expectedBundleIDs    []string
+		expectedReleaseNames []string
+	}{
+		"no chart has fleet annotations": {
+			releases: []*release.Release{
+				newRelease("test0", "any", map[string]string{}),
+				newRelease("test1", "any", map[string]string{
+					bundleIDAnnotation: "any",
+					agentNSAnnotation:  "any",
+				}),
+			},
+			expectedBundleIDs:    []string{},
+			expectedReleaseNames: []string{},
+		},
+		"finds charts with fleet annotations": {
+			releases: []*release.Release{
+				newRelease("test1", "any", nil),
+				newRelease("test2", "namespace", map[string]string{
+					bundleIDAnnotation: "testID",
+					agentNSAnnotation:  "cattle-fleet-test",
+				}),
+				newRelease("test3", "cattle-fleet-namespace", map[string]string{
+					bundleIDAnnotation: "test3-id",
+					agentNSAnnotation:  "cattle-fleet-test",
+				}),
+			},
+			expectedBundleIDs:    []string{"testID", "test3-id"},
+			expectedReleaseNames: []string{"namespace/test2", "cattle-fleet-namespace/test3"},
+		},
+		"only finds own charts": {
+			releases: []*release.Release{
+				newRelease("test2", "cattle-fleet-test", map[string]string{
+					bundleIDAnnotation: "any",
+					agentNSAnnotation:  "cattle-fleet-SYSTEM",
+				}),
+			},
+			expectedBundleIDs:    []string{},
+			expectedReleaseNames: []string{},
+		},
+	}
+
+	for name, test := range tests {
+		t.Run(name, func(t *testing.T) {
+			listAction := &fakeList{releases: test.releases}
+			result, err := h.ListDeployments(listAction)
+			r.NoError(err)
+
+			r.Len(result, len(test.expectedBundleIDs))
+			for _, deployedBundle := range result {
+				r.Contains(test.expectedBundleIDs, deployedBundle.BundleID)
+				r.Contains(test.expectedReleaseNames, deployedBundle.ReleaseName)
+			}
+		})
+	}
+}