Repository: rancher/fleet
Pull request #1976 (2023-11-28 17:48:33)

Agent Bundle/Release Cleanup Ignores Non-Fleet Releases
This is a follow-up to https://github.com/rancher/fleet/pull/1772



There are a lot of `Deleting orphan bundle ID k3s, release kube-system/traefik` messages in the logs. The clean up only works on helm charts with the `fleet.cattle.io/bundle-id` annotation.

Turns out k3s patches this annotation into all its charts: https://github.com/k3s-io/rke2-v2/commit/ba9e82c11bcb8f4c9360d3a251dff30889dbaefc#diff-ae6fb519767e6661e6cfd6bbebbf3b8c432aee7a0166fa70d35851cc6d55ca38R27

The only reason fleet-agent doesn’t delete, for example, traefik, is that at a later time it also checks for the namespace annotation.
The logic seems weird, calling ListDeployments multiple times when deleting a release. Finally another call to `globalCfg.Releases.List`, which filters for the namespace annotation `fleet.cattle.io/agent-namespace`. At this point the k3s charts are skipped and not deleted.



This PR checks for the second annotation much earlier and avoids the confusing log message.

manno: > LGTM; any tests showing that the confusing log messages no longer appear?

I didn't keep any logs from manual testing. However, this is a good opportunity for some refactoring and adding a basic unit test.

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/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/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)
+			}
+		})
+	}
+}