Repository: rancher/fleet
Pull request #3528 (2025-04-09 12:25:39)

[v2.11] Add Lifecycle Secrets
Each Bundle and BundleDeployment can have a secret to store options in. The secrets have the same name and namespace as the resource. For now they only store the helm values.



Patch:
diff --git a/charts/fleet-crd/templates/crds.yaml b/charts/fleet-crd/templates/crds.yaml
index bb0a6598..2691bc36 100644
--- a/charts/fleet-crd/templates/crds.yaml
+++ b/charts/fleet-crd/templates/crds.yaml
@@ -907,6 +907,11 @@ spec:
                           type: array
                       type: object
                   type: object
+                valuesHash:
+                  description: ValuesHash is the hash of the values used to deploy
+                    the bundle.
+                  nullable: true
+                  type: string
               type: object
             status:
               properties:
@@ -2701,6 +2706,15 @@ spec:
                         type: object
                     type: object
                   type: array
+                valuesHash:
+                  description: 'ValuesHash is the hash of the values used to render
+                    the Helm chart.
+
+                    It changes when any values from fleet.yaml, values from ValuesFiles
+                    or values from target
+
+                    customization changes.'
+                  type: string
                 yaml:
                   description: 'YAML options, if using raw YAML these are names that
                     map to
@@ -8516,6 +8530,15 @@ spec:
                         type: object
                     type: object
                   type: array
+                valuesHash:
+                  description: 'ValuesHash is the hash of the values used to render
+                    the Helm chart.
+
+                    It changes when any values from fleet.yaml, values from ValuesFiles
+                    or values from target
+
+                    customization changes.'
+                  type: string
                 yaml:
                   description: 'YAML options, if using raw YAML these are names that
                     map to
diff --git a/internal/cmd/agent/controller/bundledeployment_controller.go b/internal/cmd/agent/controller/bundledeployment_controller.go
index ea33e789..fb99f273 100644
--- a/internal/cmd/agent/controller/bundledeployment_controller.go
+++ b/internal/cmd/agent/controller/bundledeployment_controller.go
@@ -9,8 +9,10 @@ import (
 	"github.com/rancher/fleet/internal/cmd/agent/deployer/cleanup"
 	"github.com/rancher/fleet/internal/cmd/agent/deployer/driftdetect"
 	"github.com/rancher/fleet/internal/cmd/agent/deployer/monitor"
+	"github.com/rancher/fleet/internal/helmvalues"
 	fleetv1 "github.com/rancher/fleet/pkg/apis/fleet.cattle.io/v1alpha1"
 
+	corev1 "k8s.io/api/core/v1"
 	apierrors "k8s.io/apimachinery/pkg/api/errors"
 	"k8s.io/apimachinery/pkg/runtime"
 	errutil "k8s.io/apimachinery/pkg/util/errors"
@@ -27,6 +29,8 @@ import (
 // deploying the bundle as a helm release.
 type BundleDeploymentReconciler struct {
 	client.Client
+	Reader client.Reader
+
 	Scheme *runtime.Scheme
 
 	// LocalClient is the client for the cluster the agent is running on.
@@ -123,6 +127,23 @@ func (r *BundleDeploymentReconciler) Reconcile(ctx context.Context, req ctrl.Req
 		return ctrl.Result{}, err
 	}
 
+	// load the bundledeployment options from the secret, if present
+	if bd.Spec.ValuesHash != "" {
+		secret := &corev1.Secret{}
+		if err := r.Reader.Get(ctx, client.ObjectKey{Namespace: bd.Namespace, Name: bd.Name}, secret); err != nil {
+			return ctrl.Result{}, err
+		}
+
+		h := helmvalues.HashOptions(secret.Data[helmvalues.ValuesKey], secret.Data[helmvalues.StagedValuesKey])
+		if h != bd.Spec.ValuesHash {
+			return ctrl.Result{}, fmt.Errorf("retrying, hash mismatch between secret and bundledeployment: actual %s != expected %s", h, bd.Spec.ValuesHash)
+		}
+
+		if err := helmvalues.SetOptions(bd, secret.Data); err != nil {
+			return ctrl.Result{}, err
+		}
+	}
+
 	var merr []error
 
 	// helm deploy the bundledeployment
diff --git a/internal/cmd/agent/operator.go b/internal/cmd/agent/operator.go
index ee15bbc7..9585c3b5 100644
--- a/internal/cmd/agent/operator.go
+++ b/internal/cmd/agent/operator.go
@@ -264,6 +264,7 @@ func newReconciler(
 
 	return &controller.BundleDeploymentReconciler{
 		Client: upstreamClient,
+		Reader: mgr.GetAPIReader(),
 
 		Scheme:      mgr.GetScheme(),
 		LocalClient: localClient,
diff --git a/internal/cmd/cli/apply/apply.go b/internal/cmd/cli/apply/apply.go
index 9da3c824..17a13f1d 100644
--- a/internal/cmd/cli/apply/apply.go
+++ b/internal/cmd/cli/apply/apply.go
@@ -17,6 +17,7 @@ import (
 	"github.com/rancher/fleet/internal/bundlereader"
 	"github.com/rancher/fleet/internal/client"
 	"github.com/rancher/fleet/internal/fleetyaml"
+	"github.com/rancher/fleet/internal/helmvalues"
 	"github.com/rancher/fleet/internal/manifest"
 	"github.com/rancher/fleet/internal/names"
 	"github.com/rancher/fleet/internal/ociwrapper"
@@ -245,12 +246,37 @@ func Dir(ctx context.Context, client Getter, name, baseDir string, opts *Options
 		if err != nil {
 			return err
 		}
+
+		h, data, err := helmvalues.ExtractValues(bundle)
+		if err != nil {
+			return err
+		}
+
+		// If values were found in the bundle the hash is not empty, we
+		// remove the values from the bundle. Also, delete any old
+		// secret if the values are empty.
+		if h != "" {
+			helmvalues.ClearValues(bundle)
+		} else if err := c.Core.Secret().Delete(bundle.Namespace, bundle.Name, &metav1.DeleteOptions{}); err != nil && !apierrors.IsNotFound(err) {
+			return err
+		}
+		bundle.Spec.ValuesHash = h
+
 		if opts.OCIRegistry.Reference == "" {
-			if err := save(c, bundle); err != nil {
+			if bundle, err = save(c, bundle); err != nil {
 				return err
 			}
 		} else {
-			if err := saveOCIBundle(ctx, c, bundle, opts); err != nil {
+			if bundle, err = saveOCIBundle(ctx, c, bundle, opts); err != nil {
+				return err
+			}
+		}
+
+		// Saves the Helm values as a secret. The secret is owned by
+		// the bundle. It will not create a secret if the values are
+		// empty.
+		if len(data) > 0 {
+			if err := createOrUpdate(c, newValuesSecret(bundle, data)); err != nil {
 				return err
 			}
 		}
@@ -286,15 +312,15 @@ func pushOCIManifest(ctx context.Context, bundle *fleet.Bundle, opts *Options) (
 	return manifestID, nil
 }
 
-func save(c *client.Client, bundle *fleet.Bundle) error {
+func save(c *client.Client, bundle *fleet.Bundle) (*fleet.Bundle, error) {
 	obj, err := c.Fleet.Bundle().Get(bundle.Namespace, bundle.Name, metav1.GetOptions{})
 	if err != nil && !apierrors.IsNotFound(err) {
-		return err
+		return bundle, err
 	}
 
 	if apierrors.IsNotFound(err) {
-		if _, err := c.Fleet.Bundle().Create(bundle); err != nil {
-			return err
+		if obj, err = c.Fleet.Bundle().Create(bundle); err != nil {
+			return bundle, err
 		}
 		logrus.Infof("created (bundle): %s/%s", bundle.Namespace, bundle.Name)
 	} else {
@@ -302,13 +328,13 @@ func save(c *client.Client, bundle *fleet.Bundle) error {
 		obj.Annotations = bundle.Annotations
 		obj.Labels = bundle.Labels
 
-		if _, err := c.Fleet.Bundle().Update(obj); err != nil {
-			return err
+		if obj, err = c.Fleet.Bundle().Update(obj); err != nil {
+			return bundle, err
 		}
 		logrus.Infof("updated (bundle): %s/%s", obj.Namespace, obj.Name)
 	}
 
-	return nil
+	return obj, nil
 }
 
 func saveImageScans(c *client.Client, bundle *fleet.Bundle, scans []*fleet.ImageScan) error {
@@ -338,16 +364,16 @@ func saveImageScans(c *client.Client, bundle *fleet.Bundle, scans []*fleet.Image
 	return nil
 }
 
-func saveOCIBundle(ctx context.Context, c *client.Client, bundle *fleet.Bundle, opts *Options) error {
+func saveOCIBundle(ctx context.Context, c *client.Client, bundle *fleet.Bundle, opts *Options) (*fleet.Bundle, error) {
 	manifestID, err := pushOCIManifest(ctx, bundle, opts)
 	if err != nil {
-		return err
+		return bundle, err
 	}
 	logrus.Infof("OCI artifact stored successfully: %s %s", bundle.Name, manifestID)
 
 	obj, err := c.Fleet.Bundle().Get(bundle.Namespace, bundle.Name, metav1.GetOptions{})
 	if err != nil && !apierrors.IsNotFound(err) {
-		return err
+		return bundle, err
 	}
 
 	if apierrors.IsNotFound(err) {
@@ -357,11 +383,11 @@ func saveOCIBundle(ctx context.Context, c *client.Client, bundle *fleet.Bundle,
 		bundle.Spec.ContentsID = manifestID
 		obj, err = c.Fleet.Bundle().Create(bundle)
 		if err != nil {
-			return err
+			return bundle, err
 		}
 
 		if err := createOrUpdate(c, newOCISecret(manifestID, obj, opts)); err != nil {
-			return err
+			return bundle, err
 		}
 		logrus.Infof("createOrUpdate (oci secret): %s/%s", obj.Namespace, obj.Name)
 	} else {
@@ -372,16 +398,16 @@ func saveOCIBundle(ctx context.Context, c *client.Client, bundle *fleet.Bundle,
 		obj.Spec.ContentsID = manifestID
 		obj, err = c.Fleet.Bundle().Update(obj)
 		if err != nil {
-			return err
+			return bundle, err
 		}
 
 		if err := createOrUpdate(c, newOCISecret(manifestID, obj, opts)); err != nil {
-			return err
+			return bundle, err
 		}
 		logrus.Infof("createOrUpdate (oci secret): %s/%s", obj.Namespace, obj.Name)
 	}
 
-	return nil
+	return obj, nil
 }
 
 // when using the OCI registry manifestID won't be empty
@@ -414,6 +440,29 @@ func newOCISecret(manifestID string, bundle *fleet.Bundle, opts *Options) *corev
 	}
 }
 
+func newValuesSecret(bundle *fleet.Bundle, data map[string][]byte) *corev1.Secret {
+	return &corev1.Secret{
+		Type: fleet.SecretTypeBundleValues,
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      bundle.Name,
+			Namespace: bundle.Namespace,
+			OwnerReferences: []metav1.OwnerReference{
+				{
+					APIVersion:         fleet.SchemeGroupVersion.String(),
+					Kind:               "Bundle",
+					Name:               bundle.Name,
+					UID:                bundle.GetUID(),
+					BlockOwnerDeletion: ptr.To(true),
+					Controller:         ptr.To(true),
+				},
+			},
+			Labels: bundle.Labels,
+		},
+
+		Data: data,
+	}
+}
+
 // shouldCreateBundleForThisPath returns true if a bundle should be created for this path. This happens when:
 // 1) Root path contains resources in the root directory or any subdirectory without a fleet.yaml.
 // 2) Or it is a subdirectory with a fleet.yaml
diff --git a/internal/cmd/cli/cleanup.go b/internal/cmd/cli/cleanup.go
index e7057e1d..3b30e162 100644
--- a/internal/cmd/cli/cleanup.go
+++ b/internal/cmd/cli/cleanup.go
@@ -119,7 +119,7 @@ func (a *ClusterRegistration) Run(cmd *cobra.Command, args []string) error {
 	ctx := log.IntoContext(cmd.Context(), ctrl.Log)
 
 	cfg := ctrl.GetConfigOrDie()
-	client, err := newClient(ctx, cfg)
+	client, _, err := newClient(ctx, cfg)
 	if err != nil {
 		return err
 	}
@@ -151,7 +151,7 @@ func (r *Gitjob) Run(cmd *cobra.Command, args []string) error {
 	ctx := log.IntoContext(cmd.Context(), ctrl.Log)
 
 	cfg := ctrl.GetConfigOrDie()
-	client, err := newClient(ctx, cfg)
+	client, _, err := newClient(ctx, cfg)
 	if err != nil {
 		return err
 	}
diff --git a/internal/cmd/cli/deploy.go b/internal/cmd/cli/deploy.go
index 7cb87e42..7aecdea2 100644
--- a/internal/cmd/cli/deploy.go
+++ b/internal/cmd/cli/deploy.go
@@ -127,7 +127,7 @@ func (d *Deploy) Run(cmd *cobra.Command, args []string) error {
 	}
 
 	cfg := ctrl.GetConfigOrDie()
-	client, err := newClient(ctx, cfg)
+	client, _, err := newClient(ctx, cfg)
 	if err != nil {
 		return err
 	}
diff --git a/internal/cmd/cli/target.go b/internal/cmd/cli/target.go
index 1467d5ea..23148601 100644
--- a/internal/cmd/cli/target.go
+++ b/internal/cmd/cli/target.go
@@ -96,12 +96,12 @@ func (t *Target) Run(cmd *cobra.Command, args []string) error {
 	}
 
 	cfg := ctrl.GetConfigOrDie()
-	client, err := newClient(ctx, cfg)
+	client, reader, err := newClient(ctx, cfg)
 	if err != nil {
 		return err
 	}
 
-	builder := target.New(client)
+	builder := target.New(client, reader)
 	matchedTargets, err := builder.Targets(ctx, bundle, manifestID)
 	if err != nil {
 		return err
@@ -179,13 +179,13 @@ func (t *Target) Run(cmd *cobra.Command, args []string) error {
 	return nil
 }
 
-func newClient(ctx context.Context, config *rest.Config) (client.Client, error) {
+func newClient(ctx context.Context, config *rest.Config) (client.Client, client.Reader, error) {
 	cluster, err := cluster.New(config, func(clusterOptions *cluster.Options) {
 		clusterOptions.Scheme = scheme
 		clusterOptions.Logger = log.FromContext(ctx)
 	})
 	if err != nil {
-		return nil, err
+		return nil, nil, err
 	}
 	go func() {
 		err := cluster.GetCache().Start(ctx)
@@ -195,5 +195,5 @@ func newClient(ctx context.Context, config *rest.Config) (client.Client, error)
 	}()
 	cluster.GetCache().WaitForCacheSync(ctx)
 
-	return cluster.GetClient(), nil
+	return cluster.GetClient(), cluster.GetAPIReader(), nil
 }
diff --git a/internal/cmd/controller/gitops/reconciler/gitjob.go b/internal/cmd/controller/gitops/reconciler/gitjob.go
index 1618041b..3bd21680 100644
--- a/internal/cmd/controller/gitops/reconciler/gitjob.go
+++ b/internal/cmd/controller/gitops/reconciler/gitjob.go
@@ -48,7 +48,7 @@ func (r *GitJobReconciler) createOrUpdateRole(ctx context.Context, gitRepo *v1al
 				Resources: []string{"gitrepos"},
 			},
 			{
-				Verbs:     []string{"get", "create", "update"},
+				Verbs:     []string{"get", "create", "update", "delete"},
 				APIGroups: []string{""},
 				Resources: []string{"secrets"},
 			},
diff --git a/internal/cmd/controller/operator.go b/internal/cmd/controller/operator.go
index 73881c62..18b8fdc4 100644
--- a/internal/cmd/controller/operator.go
+++ b/internal/cmd/controller/operator.go
@@ -90,7 +90,7 @@ func start(
 
 	// bundle related controllers
 	store := manifest.NewStore(mgr.GetClient())
-	builder := target.New(mgr.GetClient())
+	builder := target.New(mgr.GetClient(), mgr.GetAPIReader())
 
 	if err = (&reconciler.ClusterReconciler{
 		Client: mgr.GetClient(),
diff --git a/internal/cmd/controller/reconciler/bundle_controller.go b/internal/cmd/controller/reconciler/bundle_controller.go
index caef037b..135e5b18 100644
--- a/internal/cmd/controller/reconciler/bundle_controller.go
+++ b/internal/cmd/controller/reconciler/bundle_controller.go
@@ -14,6 +14,7 @@ import (
 	"github.com/rancher/fleet/internal/cmd/controller/finalize"
 	"github.com/rancher/fleet/internal/cmd/controller/summary"
 	"github.com/rancher/fleet/internal/cmd/controller/target"
+	"github.com/rancher/fleet/internal/helmvalues"
 	"github.com/rancher/fleet/internal/manifest"
 	"github.com/rancher/fleet/internal/metrics"
 	"github.com/rancher/fleet/internal/ociwrapper"
@@ -155,6 +156,14 @@ func (r *BundleReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctr
 		bundle.Status.ObservedGeneration,
 	)
 
+	// The values secret is optional, e.g. for non-helm type bundles.
+	// This sets the values on the bundle, which is safe as we don't update bundle, just its status
+	if bundle.Spec.ValuesHash != "" {
+		if err := loadBundleValues(ctx, r.Client, bundle); err != nil {
+			return ctrl.Result{}, err
+		}
+	}
+
 	// if the bundle has the helmops options set but the experimental flag is not
 	// set we don't deploy the bundle.
 	// This is to avoid intentional or accidental deployment of bundles with no
@@ -224,7 +233,7 @@ func (r *BundleReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctr
 		return ctrl.Result{}, err
 	}
 
-	// this will add the defaults for a new bundledeployment
+	// this will add the defaults for a new bundledeployment. It propagates stagedOptions to options.
 	if err := target.UpdatePartitions(&bundle.Status, matchedTargets); err != nil {
 		return ctrl.Result{}, err
 	}
@@ -267,6 +276,16 @@ func (r *BundleReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctr
 		bd.Spec.OCIContents = contentsInOCI
 		bd.Spec.HelmChartOptions = bundle.Spec.HelmAppOptions
 
+		h, options, stagedOptions, err := helmvalues.ExtractOptions(bd)
+		if err != nil {
+			return ctrl.Result{}, err
+		}
+		// We need a checksum to trigger on value change, rely on later code in
+		// the reconciler to update the status
+		bd.Spec.ValuesHash = h
+
+		helmvalues.ClearOptions(bd)
+
 		bd, err = r.createBundleDeployment(
 			ctx,
 			logger,
@@ -278,7 +297,20 @@ func (r *BundleReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctr
 			return ctrl.Result{}, err
 		}
 
-		if err := r.handleDeploymentSecret(ctx, bundle, bd); err != nil {
+		if bd.Spec.ValuesHash != "" {
+			if err := r.createOptionsSecret(ctx, bd, options, stagedOptions); err != nil {
+				return ctrl.Result{}, err
+			}
+		} else {
+			// No values to store, delete the secret if it exists
+			if err := r.Delete(ctx, &corev1.Secret{
+				ObjectMeta: metav1.ObjectMeta{Name: bd.Name, Namespace: bd.Namespace},
+			}); err != nil && !apierrors.IsNotFound(err) {
+				return ctrl.Result{}, err
+			}
+		}
+
+		if err := r.handleContentAccessSecrets(ctx, bundle, bd); err != nil {
 			return ctrl.Result{}, err
 		}
 	}
@@ -407,31 +439,48 @@ func (r *BundleReconciler) createBundleDeployment(
 	return bd, nil
 }
 
-func (r *BundleReconciler) createDeploymentSecret(ctx context.Context, secretName string, bundle *fleet.Bundle, bd *fleet.BundleDeployment) error {
-	namespacedName := types.NamespacedName{
-		Namespace: bundle.Namespace,
-		Name:      secretName,
+// loadBundleValues loads the values from the secret and sets them in the bundle spec
+func loadBundleValues(ctx context.Context, c client.Client, bundle *fleet.Bundle) error {
+	secret := &corev1.Secret{}
+	if err := c.Get(ctx, types.NamespacedName{Name: bundle.Name, Namespace: bundle.Namespace}, secret); err != nil {
+		return fmt.Errorf("failed to get values secret for bundle %q, this is likely temporary: %w", bundle.Name, err)
 	}
-	var secret corev1.Secret
-	if err := r.Get(ctx, namespacedName, &secret); err != nil {
-		return err
+	hash, err := helmvalues.HashValuesSecret(secret.Data)
+	if err != nil {
+		return fmt.Errorf("failed to hash values secret %q: %w", secret.Name, err)
 	}
-	// clone the secret, and just change the namespace so it's in the target's namespace
-	targetSecret := &corev1.Secret{
+	if bundle.Spec.ValuesHash != hash {
+		return fmt.Errorf("bundle values secret has changed, requeuing")
+	}
+
+	if err := helmvalues.SetValues(bundle, secret.Data); err != nil {
+		return fmt.Errorf("failed load values secret %q: %w", secret.Name, err)
+	}
+
+	return nil
+}
+
+func (r *BundleReconciler) createOptionsSecret(ctx context.Context, bd *fleet.BundleDeployment, options []byte, stagedOptions []byte) error {
+	secret := &corev1.Secret{
+		Type: fleet.SecretTypeBundleDeploymentOptions,
 		ObjectMeta: metav1.ObjectMeta{
-			Name:      secret.Name,
+			Name:      bd.Name,
 			Namespace: bd.Namespace,
 		},
-		Data: secret.Data,
 	}
 
-	if err := controllerutil.SetControllerReference(bd, targetSecret, r.Scheme); err != nil {
+	if err := controllerutil.SetControllerReference(bd, secret, r.Scheme); err != nil {
 		return err
 	}
-	if err := r.Create(ctx, targetSecret); err != nil {
-		if !apierrors.IsAlreadyExists(err) {
-			return err
+
+	if _, err := controllerutil.CreateOrUpdate(ctx, r.Client, secret, func() error {
+		secret.Data = map[string][]byte{
+			helmvalues.ValuesKey:       options,
+			helmvalues.StagedValuesKey: stagedOptions,
 		}
+		return nil
+	}); err != nil {
+		return err
 	}
 
 	return nil
@@ -457,18 +506,48 @@ func (r *BundleReconciler) getOCIReference(ctx context.Context, bundle *fleet.Bu
 	return fmt.Sprintf("oci://%s/%s:latest", string(ref), bundle.Spec.ContentsID), nil
 }
 
-func (r *BundleReconciler) handleDeploymentSecret(ctx context.Context, bundle *fleet.Bundle, bd *fleet.BundleDeployment) error {
-	if bd == nil {
-		return nil
+// cloneSecret clones a secret, identified by the provided secretName and
+// namespace, to the namespace of the provided bundle deployment bd. This makes
+// the secret available to agents when deploying bd to downstream clusters.
+func (r *BundleReconciler) cloneSecret(ctx context.Context, namespace string, secretName string, bd *fleet.BundleDeployment) error {
+	namespacedName := types.NamespacedName{
+		Namespace: namespace,
+		Name:      secretName,
+	}
+	var secret corev1.Secret
+	if err := r.Get(ctx, namespacedName, &secret); err != nil {
+		return fmt.Errorf("failed to load source secret, cannot clone into %q: %w", namespace, err)
+	}
+	// clone the secret, and just change the namespace so it's in the target's namespace
+	targetSecret := &corev1.Secret{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      secret.Name,
+			Namespace: bd.Namespace,
+		},
+		Data: secret.Data,
+	}
+
+	if err := controllerutil.SetControllerReference(bd, targetSecret, r.Scheme); err != nil {
+		return err
+	}
+	if err := r.Create(ctx, targetSecret); err != nil {
+		if !apierrors.IsAlreadyExists(err) {
+			return err
+		}
 	}
+
+	return nil
+}
+
+func (r *BundleReconciler) handleContentAccessSecrets(ctx context.Context, bundle *fleet.Bundle, bd *fleet.BundleDeployment) error {
 	contentsInOCI := bundle.Spec.ContentsID != "" && ociwrapper.ExperimentalOCIIsEnabled()
 	contentsInHelmChart := bundle.Spec.HelmAppOptions != nil && experimentalHelmOpsEnabled()
 
 	if contentsInOCI {
-		return r.createDeploymentSecret(ctx, bundle.Spec.ContentsID, bundle, bd)
+		return r.cloneSecret(ctx, bundle.Namespace, bundle.Spec.ContentsID, bd)
 	}
 	if contentsInHelmChart && bundle.Spec.HelmAppOptions.SecretName != "" {
-		return r.createDeploymentSecret(ctx, bundle.Spec.HelmAppOptions.SecretName, bundle, bd)
+		return r.cloneSecret(ctx, bundle.Namespace, bundle.Spec.HelmAppOptions.SecretName, bd)
 	}
 	return nil
 }
diff --git a/internal/cmd/controller/target/builder.go b/internal/cmd/controller/target/builder.go
index 866cea5a..fb6fbf1f 100644
--- a/internal/cmd/controller/target/builder.go
+++ b/internal/cmd/controller/target/builder.go
@@ -15,10 +15,12 @@ import (
 
 	"github.com/rancher/fleet/internal/cmd/controller/options"
 	"github.com/rancher/fleet/internal/cmd/controller/target/matcher"
+	"github.com/rancher/fleet/internal/helmvalues"
 	fleet "github.com/rancher/fleet/pkg/apis/fleet.cattle.io/v1alpha1"
 
 	"github.com/rancher/wrangler/v3/pkg/yaml"
 
+	corev1 "k8s.io/api/core/v1"
 	"k8s.io/apimachinery/pkg/util/sets"
 	"sigs.k8s.io/controller-runtime/pkg/client"
 	"sigs.k8s.io/controller-runtime/pkg/log"
@@ -27,10 +29,11 @@ import (
 
 type Manager struct {
 	client client.Client
+	reader client.Reader
 }
 
-func New(client client.Client) *Manager {
-	return &Manager{client: client}
+func New(client client.Client, reader client.Reader) *Manager {
+	return &Manager{client: client, reader: reader}
 }
 
 // Targets returns all targets for a bundle, so we can create bundledeployments for each.
@@ -119,7 +122,24 @@ func (m *Manager) Targets(ctx context.Context, bundle *fleet.Bundle, manifestID
 
 	byNamespace := map[string]*fleet.BundleDeployment{}
 	for _, bd := range bundleDeployments.Items {
-		byNamespace[bd.Namespace] = bd.DeepCopy()
+		// and set their options
+		if bd.Spec.ValuesHash != "" {
+			secret := &corev1.Secret{}
+			if err := m.reader.Get(ctx, client.ObjectKey{Namespace: bd.Namespace, Name: bd.Name}, secret); err != nil {
+				return nil, fmt.Errorf("failed to get options secret for bundledeployment %s/%s, this is likely temporary: %w", bd.Namespace, bd.Name, err)
+			}
+
+			h := helmvalues.HashOptions(secret.Data[helmvalues.ValuesKey], secret.Data[helmvalues.StagedValuesKey])
+			if h != bd.Spec.ValuesHash {
+				return nil, fmt.Errorf("retrying, hash mismatch between secret and bundledeployment: actual %s != expected %s", h, bd.Spec.ValuesHash)
+			}
+
+			if err := helmvalues.SetOptions(&bd, secret.Data); err != nil {
+				return nil, err
+			}
+		}
+
+		byNamespace[bd.Namespace] = &bd
 	}
 
 	for _, target := range targets {
diff --git a/internal/helmvalues/extract.go b/internal/helmvalues/extract.go
new file mode 100644
index 00000000..228d6f95
--- /dev/null
+++ b/internal/helmvalues/extract.go
@@ -0,0 +1,111 @@
+package helmvalues
+
+import (
+	"fmt"
+
+	fleet "github.com/rancher/fleet/pkg/apis/fleet.cattle.io/v1alpha1"
+)
+
+// ExtractOptions extracts the values from options in a bundle deployment.
+func ExtractOptions(bd *fleet.BundleDeployment) (string, []byte, []byte, error) {
+	var options []byte
+	if bd.Spec.Options.Helm != nil && bd.Spec.Options.Helm.Values != nil {
+		var err error
+		options, err = bd.Spec.Options.Helm.Values.MarshalJSON()
+		if err != nil {
+			err = fmt.Errorf("failed to marshal values: %w", err)
+			return "", []byte{}, []byte{}, err
+		}
+		if string(options) == "null" || string(options) == "{}" {
+			options = []byte{}
+		}
+	}
+
+	var staged []byte
+	if bd.Spec.StagedOptions.Helm != nil && bd.Spec.StagedOptions.Helm.Values != nil {
+		var err error
+		staged, err = bd.Spec.StagedOptions.Helm.Values.MarshalJSON()
+		if err != nil {
+			err = fmt.Errorf("failed to marshal staged values: %w", err)
+			return "", []byte{}, []byte{}, err
+		}
+		if string(staged) == "null" || string(staged) == "{}" {
+			staged = []byte{}
+		}
+	}
+
+	var hash string
+	if len(options) > 0 || len(staged) > 0 {
+		hash = HashOptions(options, staged)
+	}
+
+	return hash, options, staged, nil
+}
+
+// ClearOptions removes values from the new bundle deployment
+func ClearOptions(bd *fleet.BundleDeployment) {
+	if bd.Spec.Options.Helm != nil {
+		bd.Spec.Options.Helm.Values = nil
+	}
+	if bd.Spec.StagedOptions.Helm != nil {
+		bd.Spec.StagedOptions.Helm.Values = nil
+	}
+}
+
+// ExtractValues extracts the values from the bundle and returns the values and
+// a hash.
+func ExtractValues(bundle *fleet.Bundle) (string, map[string][]byte, error) {
+	data := map[string][]byte{}
+	spec := bundle.Spec
+
+	if spec.Helm != nil && spec.Helm.Values != nil && len(spec.Helm.Values.Data) > 0 {
+		v, err := spec.Helm.Values.MarshalJSON()
+		if err != nil {
+			return "", data, err
+		}
+		data["values.yaml"] = v
+	}
+
+	for _, target := range spec.Targets {
+		if target.Helm == nil || target.Helm.Values == nil || len(target.Helm.Values.Data) == 0 {
+			continue
+		}
+
+		if target.Name == "" {
+			return "", data, fmt.Errorf("target name is required")
+		}
+
+		v, err := target.Helm.Values.MarshalJSON()
+		if err != nil {
+			return "", data, err
+		}
+		data[target.Name] = v
+	}
+
+	if len(data) == 0 {
+		return "", data, nil
+	}
+
+	// Note: we assume json.Marshal is stable for maps
+	hash, err := HashValuesSecret(data)
+	if err != nil {
+		return "", data, err
+	}
+
+	return hash, data, nil
+}
+
+// ClearValues removes the values from a bundle. It mutates the bundle.
+func ClearValues(bundle *fleet.Bundle) {
+	if bundle.Spec.Helm != nil {
+		bundle.Spec.Helm.Values = nil
+		bundle.Spec.Helm.ValuesFiles = nil
+	}
+	for i := range bundle.Spec.Targets {
+		if bundle.Spec.Targets[i].Helm == nil {
+			continue
+		}
+		bundle.Spec.Targets[i].Helm.Values = nil
+		bundle.Spec.Targets[i].Helm.ValuesFiles = nil
+	}
+}
diff --git a/internal/helmvalues/hash.go b/internal/helmvalues/hash.go
new file mode 100644
index 00000000..52603494
--- /dev/null
+++ b/internal/helmvalues/hash.go
@@ -0,0 +1,34 @@
+package helmvalues
+
+import (
+	"crypto/sha256"
+	"encoding/json"
+	"fmt"
+)
+
+const (
+	ValuesKey       = "values"
+	StagedValuesKey = "stagedValues"
+)
+
+// HashValuesSecret hashes the data of a secret. This is used for the bundle
+// values secret created by fleet apply to detect changes and trigger updates.
+func HashValuesSecret(data map[string][]byte) (string, error) {
+	hasher := sha256.New()
+	b, err := json.Marshal(data)
+	if err != nil {
+		return "", err
+	}
+	hasher.Write(b)
+	return fmt.Sprintf("%x", hasher.Sum(nil)), nil
+}
+
+// HashOptions hashes the bytes passed in. This is used to create a hash of the
+// bundledeployment's helm options and staged helm options.
+func HashOptions(bytes ...[]byte) string {
+	hasher := sha256.New()
+	for _, b := range bytes {
+		hasher.Write(b)
+	}
+	return fmt.Sprintf("%x", hasher.Sum(nil))
+}
diff --git a/internal/helmvalues/set.go b/internal/helmvalues/set.go
new file mode 100644
index 00000000..00739bb0
--- /dev/null
+++ b/internal/helmvalues/set.go
@@ -0,0 +1,65 @@
+package helmvalues
+
+import (
+	"fmt"
+
+	fleet "github.com/rancher/fleet/pkg/apis/fleet.cattle.io/v1alpha1"
+)
+
+// SetValues sets the values in the bundle from the data map. It mutates the bundle.
+func SetValues(bundle *fleet.Bundle, data map[string][]byte) error {
+	if v, ok := data["values.yaml"]; ok && string(v) != "" {
+		gm := fleet.GenericMap{}
+		if err := gm.UnmarshalJSON(v); err != nil {
+			return fmt.Errorf("failed to unmarshal values.yaml: %w", err)
+		}
+		if bundle.Spec.Helm == nil {
+			bundle.Spec.Helm = &fleet.HelmOptions{}
+		}
+		bundle.Spec.Helm.Values = &gm
+	}
+
+	for i, target := range bundle.Spec.Targets {
+		if v, ok := data[target.Name]; ok && string(v) != "" {
+			gm := fleet.GenericMap{}
+			if err := gm.UnmarshalJSON(v); err != nil {
+				return fmt.Errorf("failed to unmarshal values for target %q: %w", target.Name, err)
+			}
+			if bundle.Spec.Targets[i].Helm == nil {
+				bundle.Spec.Targets[i].Helm = &fleet.HelmOptions{}
+			}
+			bundle.Spec.Targets[i].Helm.Values = &gm
+		}
+	}
+
+	return nil
+}
+
+// SetOptions sets the values in the options of the bundle deployment from the
+// data map. It mutates the bundle deployment.
+// It sets the staged options, however they are not used by the agent.
+func SetOptions(bd *fleet.BundleDeployment, data map[string][]byte) error {
+	if v, ok := data[ValuesKey]; ok && string(v) != "" {
+		gm := fleet.GenericMap{}
+		if err := gm.UnmarshalJSON(v); err != nil {
+			return fmt.Errorf("failed to unmarshal values: %w", err)
+		}
+		if bd.Spec.Options.Helm == nil {
+			bd.Spec.Options.Helm = &fleet.HelmOptions{}
+		}
+		bd.Spec.Options.Helm.Values = &gm
+	}
+
+	if v, ok := data[StagedValuesKey]; ok && string(v) != "" {
+		gm := fleet.GenericMap{}
+		if err := gm.UnmarshalJSON(v); err != nil {
+			return fmt.Errorf("failed to unmarshal values: %w", err)
+		}
+		if bd.Spec.StagedOptions.Helm == nil {
+			bd.Spec.StagedOptions.Helm = &fleet.HelmOptions{}
+		}
+		bd.Spec.StagedOptions.Helm.Values = &gm
+	}
+
+	return nil
+}
diff --git a/pkg/apis/fleet.cattle.io/v1alpha1/bundle_types.go b/pkg/apis/fleet.cattle.io/v1alpha1/bundle_types.go
index 23407fd2..98a81384 100644
--- a/pkg/apis/fleet.cattle.io/v1alpha1/bundle_types.go
+++ b/pkg/apis/fleet.cattle.io/v1alpha1/bundle_types.go
@@ -32,6 +32,9 @@ const (
 	// but there are some changes that were not made from the Git
 	// Repository.
 	Modified BundleState = "Modified"
+
+	// SecretTypeBundleValues is the secret type used to store the helm values
+	SecretTypeBundleValues = "fleet.cattle.io/bundle-values/v1alpha1"
 )
 
 var (
@@ -121,6 +124,11 @@ type BundleSpec struct {
 	// not a git repository.
 	// +nullable
 	HelmAppOptions *BundleHelmOptions `json:"helmAppOptions,omitempty"`
+
+	// ValuesHash is the hash of the values used to render the Helm chart.
+	// It changes when any values from fleet.yaml, values from ValuesFiles or values from target
+	// customization changes.
+	ValuesHash string `json:"valuesHash,omitempty"`
 }
 
 type BundleRef struct {
diff --git a/pkg/apis/fleet.cattle.io/v1alpha1/bundledeployment_types.go b/pkg/apis/fleet.cattle.io/v1alpha1/bundledeployment_types.go
index 6eb32102..25005af1 100644
--- a/pkg/apis/fleet.cattle.io/v1alpha1/bundledeployment_types.go
+++ b/pkg/apis/fleet.cattle.io/v1alpha1/bundledeployment_types.go
@@ -12,15 +12,20 @@ import (
 	"github.com/rancher/wrangler/v3/pkg/genericcondition"
 )
 
-const BundleDeploymentResourceNamePlural = "bundledeployments"
-
 func init() {
 	InternalSchemeBuilder.Register(&BundleDeployment{}, &BundleDeploymentList{})
 }
 
-// MaxHelmReleaseNameLen is the maximum length of a Helm release name.
-// See https://github.com/helm/helm/blob/293b50c65d4d56187cd4e2f390f0ada46b4c4737/pkg/chartutil/validate_name.go#L54-L61
-const MaxHelmReleaseNameLen = 53
+const (
+	BundleDeploymentResourceNamePlural = "bundledeployments"
+
+	// MaxHelmReleaseNameLen is the maximum length of a Helm release name.
+	// See https://github.com/helm/helm/blob/293b50c65d4d56187cd4e2f390f0ada46b4c4737/pkg/chartutil/validate_name.go#L54-L61
+	MaxHelmReleaseNameLen = 53
+
+	// SecretTypeBundleDeploymentOptions is the type of the secret that stores the deployment values options.
+	SecretTypeBundleDeploymentOptions = "fleet.cattle.io/bundle-deployment/v1alpha1"
+)
 
 const IgnoreOp = "ignore"
 
@@ -333,6 +338,9 @@ type BundleDeploymentSpec struct {
 	// HelmChartOptions is not nil and has the helm chart config details when contents
 	// should be downloaded from a helm chart
 	HelmChartOptions *BundleHelmOptions `json:"helmChartOptions,omitempty"`
+	// ValuesHash is the hash of the values used to deploy the bundle.
+	// +nullable
+	ValuesHash string `json:"valuesHash,omitempty"`
 }
 
 // BundleDeploymentResource contains the metadata of a deployed resource.


Test patch:
diff --git a/e2e/single-cluster/values_from_test.go b/e2e/single-cluster/values_from_test.go
index 87281f09..9b2c6c45 100644
--- a/e2e/single-cluster/values_from_test.go
+++ b/e2e/single-cluster/values_from_test.go
@@ -1,11 +1,13 @@
 package singlecluster_test
 
 import (
+	"encoding/base64"
 	"encoding/json"
 	"math/rand"
 
 	. "github.com/onsi/ginkgo/v2"
 	. "github.com/onsi/gomega"
+	yaml "sigs.k8s.io/yaml"
 
 	"github.com/rancher/fleet/e2e/testenv"
 	"github.com/rancher/fleet/e2e/testenv/kubectl"
@@ -71,6 +73,27 @@ var _ = Describe("ValuesFrom", func() {
 				Expect(result).To(HaveKeyWithValue("englishname", "secret override"))
 				Expect(result).To(HaveKeyWithValue("optionconfigmap", "configmap option"))
 				Expect(result).To(HaveKeyWithValue("optionsecret", "secret option"))
+
+				By("checking other values are persisted to the values secrets", func() {
+					out, err := k.Get("secret", "values-from-helm-values-from", "-o", "jsonpath={.data}")
+					Expect(err).ToNot(HaveOccurred(), out)
+					Expect(out).To(MatchRegexp(`{"values.yaml":"\w+"}`))
+
+					data := map[string]string{}
+					err = yaml.Unmarshal([]byte(out), &data)
+					Expect(err).ToNot(HaveOccurred())
+					content := data["values.yaml"]
+					b, err := base64.StdEncoding.DecodeString(content)
+					Expect(err).ToNot(HaveOccurred())
+					values := map[string]interface{}{}
+					err = yaml.Unmarshal(b, &values)
+					Expect(err).ToNot(HaveOccurred())
+
+					Expect(values).To(HaveLen(3))
+					Expect(values).To(HaveKeyWithValue("fleetyaml", "from fleet.yaml"))
+					Expect(values).To(HaveKeyWithValue("name", "name from fleet.yaml"))
+					Expect(values).To(HaveKeyWithValue("options", map[string]interface{}{"english": map[string]interface{}{"name": "english name from fleet.yaml"}}))
+				})
 			})
 		})
 	})
diff --git a/integrationtests/cli/apply/apply_online_test.go b/integrationtests/cli/apply/apply_online_test.go
index d867380f..d12654b8 100644
--- a/integrationtests/cli/apply/apply_online_test.go
+++ b/integrationtests/cli/apply/apply_online_test.go
@@ -1,5 +1,6 @@
 //go:generate mockgen --build_flags=--mod=mod -destination=../../mocks/getter_mock.go -package=mocks github.com/rancher/fleet/internal/cmd/cli/apply Getter
 //go:generate mockgen --build_flags=--mod=mod -destination=../../mocks/fleet_controller_mock.go -package=mocks -mock_names=Interface=FleetInterface github.com/rancher/fleet/pkg/generated/controllers/fleet.cattle.io/v1alpha1 Interface
+//go:generate mockgen --build_flags=--mod=mod -destination=../../mocks/core_controller_mock.go -package=mocks -mock_names=Interface=CoreInterface github.com/rancher/wrangler/v3/pkg/generated/controllers/core/v1 Interface
 
 package apply
 
@@ -17,6 +18,7 @@ import (
 
 	"github.com/rancher/wrangler/v3/pkg/generic/fake"
 
+	corev1 "k8s.io/api/core/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 )
 
@@ -41,15 +43,20 @@ var _ = Describe("Fleet apply online", Label("online"), func() {
 		getter = mocks.NewMockGetter(ctrl)
 		c = client.Client{
 			Fleet:     mocks.NewFleetInterface(ctrl),
+			Core:      mocks.NewCoreInterface(ctrl),
 			Namespace: "foo",
 		}
 		bundleController = fake.NewMockControllerInterface[*fleet.Bundle, *fleet.BundleList](ctrl)
+		secretController := fake.NewMockControllerInterface[*corev1.Secret, *corev1.SecretList](ctrl)
 		fleetMock = c.Fleet.(*mocks.FleetInterface)
+		coreMock := c.Core.(*mocks.CoreInterface)
 		getter.EXPECT().GetNamespace().Return("foo").AnyTimes()
 		getter.EXPECT().Get().Return(&c, nil).AnyTimes()
 		fleetMock.EXPECT().Bundle().Return(bundleController).AnyTimes()
+		coreMock.EXPECT().Secret().Return(secretController).AnyTimes()
 		bundleController.EXPECT().Get("foo", gomock.Any(), gomock.Any()).Return(oldBundle, nil).AnyTimes()
 		bundleController.EXPECT().List(gomock.Any(), gomock.Any()).Return(&fleet.BundleList{}, nil).AnyTimes()
+		secretController.EXPECT().Delete(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
 	})
 
 	When("We want to delete a label in the bundle from the cluster", func() {
@@ -102,6 +109,7 @@ data:
 		It("deletes labels present on the bundle but not in fleet.yaml", func() {
 			// Update is called with the actual output of the `apply` command here, hence we validate that its argument is what we expect.
 			bundleController.EXPECT().Update(newBundle).Return(newBundle, nil).AnyTimes()
+			bundleController.EXPECT().UpdateStatus(newBundle).Return(newBundle, nil).AnyTimes()
 			err := fleetApplyOnline(getter, name, dirs, options)
 			Expect(err).NotTo(HaveOccurred())
 		})
diff --git a/integrationtests/controller/bundle/bundle_targets_test.go b/integrationtests/controller/bundle/bundle_targets_test.go
index 8e47d1aa..fb3853cf 100644
--- a/integrationtests/controller/bundle/bundle_targets_test.go
+++ b/integrationtests/controller/bundle/bundle_targets_test.go
@@ -5,11 +5,13 @@ import (
 
 	. "github.com/onsi/ginkgo/v2"
 	. "github.com/onsi/gomega"
+	"gopkg.in/yaml.v2"
 
 	"github.com/rancher/fleet/integrationtests/utils"
 	"github.com/rancher/fleet/pkg/apis/fleet.cattle.io/v1alpha1"
 
 	corev1 "k8s.io/api/core/v1"
+	apierrors "k8s.io/apimachinery/pkg/api/errors"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/labels"
 	"k8s.io/apimachinery/pkg/types"
@@ -40,6 +42,24 @@ var _ = Describe("Bundle targets", Ordered, func() {
 		expectedNumberOfBundleDeployments int
 	)
 
+	loadValues := func(bd v1alpha1.BundleDeployment) (map[string]any, map[string]any) {
+		values := make(map[string]any)
+		staged := make(map[string]any)
+		secret := corev1.Secret{}
+		Eventually(func(g Gomega) {
+			err := k8sClient.Get(ctx, types.NamespacedName{Name: bd.Name, Namespace: bd.Namespace}, &secret)
+			g.Expect(err).ToNot(HaveOccurred())
+
+			err = yaml.Unmarshal(secret.Data["stagedValues"], &staged)
+			g.Expect(err).ToNot(HaveOccurred())
+
+			err = yaml.Unmarshal(secret.Data["values"], &values)
+			g.Expect(err).ToNot(HaveOccurred())
+		}).Should(Succeed())
+
+		return values, staged
+	}
+
 	JustBeforeEach(func() {
 		bundle, err := utils.CreateBundle(ctx, k8sClient, bundleName, namespace, targets, targetRestrictions)
 		Expect(err).NotTo(HaveOccurred())
@@ -83,6 +103,10 @@ var _ = Describe("Bundle targets", Ordered, func() {
 			By("and BundleDeployments don't have values from customizations")
 			for _, bd := range bdList.Items {
 				Expect(bd.Spec.Options.Helm.Values).To(BeNil())
+				err := k8sClient.Get(ctx, types.NamespacedName{Name: bd.Name, Namespace: bd.Namespace}, &corev1.Secret{})
+				Expect(err).To(HaveOccurred())
+				Expect(apierrors.IsNotFound(err)).To(BeTrue())
+
 			}
 		})
 	})
@@ -126,7 +150,9 @@ var _ = Describe("Bundle targets", Ordered, func() {
 			var bdList = verifyBundlesDeploymentsAreCreated(expectedNumberOfBundleDeployments, bdLabels, bundleName)
 			By("and BundleDeployments have values from customizations")
 			for _, bd := range bdList.Items {
-				Expect(bd.Spec.Options.Helm.Values.Data).To(Equal(map[string]interface{}{"replicas": "3"}))
+				Expect(bd.Spec.Options.Helm.Values).To(BeNil())
+				values, _ := loadValues(bd)
+				Expect(values).To(HaveKeyWithValue("replicas", "3"))
 			}
 		})
 	})
@@ -178,12 +204,13 @@ var _ = Describe("Bundle targets", Ordered, func() {
 			var bdList = verifyBundlesDeploymentsAreCreated(expectedNumberOfBundleDeployments, bdLabels, bundleName)
 			By("and just BundleDeployment from cluster one and two are customized")
 			for _, bd := range bdList.Items {
-				if strings.Contains(bd.ObjectMeta.Namespace, "cluster-one") {
-					Expect(bd.Spec.Options.Helm.Values.Data).To(Equal(map[string]interface{}{"replicas": "1"}))
+				values, _ := loadValues(bd)
+				if strings.Contains(bd.Namespace, "cluster-one") {
+					Expect(values).To(HaveKeyWithValue("replicas", "1"))
 				} else if strings.Contains(bd.ObjectMeta.Namespace, "cluster-two") {
-					Expect(bd.Spec.Options.Helm.Values.Data).To(Equal(map[string]interface{}{"replicas": "2"}))
+					Expect(values).To(HaveKeyWithValue("replicas", "2"))
 				} else {
-					Expect(bd.Spec.Options.Helm.Values.Data).To(Equal(map[string]interface{}{"replicas": "4"}))
+					Expect(values).To(HaveKeyWithValue("replicas", "4"))
 				}
 			}
 		})
@@ -236,10 +263,11 @@ var _ = Describe("Bundle targets", Ordered, func() {
 			var bdList = verifyBundlesDeploymentsAreCreated(expectedNumberOfBundleDeployments, bdLabels, bundleName)
 			By("and just BundleDeployment from cluster one is customized")
 			for _, bd := range bdList.Items {
-				if strings.Contains(bd.ObjectMeta.Namespace, "cluster-one") {
-					Expect(bd.Spec.Options.Helm.Values.Data).To(Equal(map[string]interface{}{"replicas": "1"}))
+				values, _ := loadValues(bd)
+				if strings.Contains(bd.Namespace, "cluster-one") {
+					Expect(values).To(HaveKeyWithValue("replicas", "1"))
 				} else {
-					Expect(bd.Spec.Options.Helm.Values.Data).To(Equal(map[string]interface{}{"replicas": "4"}))
+					Expect(values).To(HaveKeyWithValue("replicas", "4"))
 				}
 			}
 		})
@@ -284,7 +312,9 @@ var _ = Describe("Bundle targets", Ordered, func() {
 			var bdList = verifyBundlesDeploymentsAreCreated(expectedNumberOfBundleDeployments, bdLabels, bundleName)
 			By("and the BundleDeployment is customized")
 			for _, bd := range bdList.Items {
-				Expect(bd.Spec.Options.Helm.Values.Data).To(Equal(map[string]interface{}{"replicas": "2"}))
+				values, staged := loadValues(bd)
+				Expect(staged).To(HaveKeyWithValue("replicas", "2"))
+				Expect(values).To(HaveKeyWithValue("replicas", "2"))
 			}
 		})
 	})
@@ -441,12 +471,13 @@ var _ = Describe("Bundle targets", Ordered, func() {
 			var bdList = verifyBundlesDeploymentsAreCreated(expectedNumberOfBundleDeployments, bdLabels, bundleName)
 			By("checking that all bundle deployments are customized as expected")
 			for _, bd := range bdList.Items {
+				values, _ := loadValues(bd)
 				if strings.Contains(bd.Namespace, "cluster-one") {
-					Expect(bd.Spec.Options.Helm.Values.Data).To(Equal(map[string]interface{}{"replicas": "1"}))
+					Expect(values).To(HaveKeyWithValue("replicas", "1"))
 				} else if strings.Contains(bd.Namespace, "cluster-two") {
-					Expect(bd.Spec.Options.Helm.Values.Data).To(Equal(map[string]interface{}{"replicas": "2"}))
+					Expect(values).To(HaveKeyWithValue("replicas", "2"))
 				} else {
-					Expect(bd.Spec.Options.Helm.Values.Data).To(Equal(map[string]interface{}{"replicas": "4"}))
+					Expect(values).To(HaveKeyWithValue("replicas", "4"))
 				}
 			}
 			By("setting the customization for cluster two as do not deploy")
@@ -466,10 +497,11 @@ var _ = Describe("Bundle targets", Ordered, func() {
 			bdList = verifyBundlesDeploymentsAreCreated(2, bdLabels, bundleName)
 			for _, bd := range bdList.Items {
 				Expect(bd.Namespace).ToNot(ContainSubstring("cluster-two"))
+				values, _ := loadValues(bd)
 				if strings.Contains(bd.Namespace, "cluster-one") {
-					Expect(bd.Spec.Options.Helm.Values.Data).To(Equal(map[string]interface{}{"replicas": "1"}))
+					Expect(values).To(HaveKeyWithValue("replicas", "1"))
 				} else if strings.Contains(bd.Namespace, "cluster-three") {
-					Expect(bd.Spec.Options.Helm.Values.Data).To(Equal(map[string]interface{}{"replicas": "4"}))
+					Expect(values).To(HaveKeyWithValue("replicas", "4"))
 				}
 			}
 		})
diff --git a/integrationtests/controller/bundle/suite_test.go b/integrationtests/controller/bundle/suite_test.go
index 745d1169..5d901cd2 100644
--- a/integrationtests/controller/bundle/suite_test.go
+++ b/integrationtests/controller/bundle/suite_test.go
@@ -57,7 +57,7 @@ var _ = BeforeSuite(func() {
 
 	// Set up the bundle reconciler
 	store := manifest.NewStore(mgr.GetClient())
-	builder := target.New(mgr.GetClient())
+	builder := target.New(mgr.GetClient(), mgr.GetAPIReader())
 
 	err = (&reconciler.BundleReconciler{
 		Client:  mgr.GetClient(),
diff --git a/integrationtests/controller/cluster/suite_test.go b/integrationtests/controller/cluster/suite_test.go
index 331558f5..f922a03a 100644
--- a/integrationtests/controller/cluster/suite_test.go
+++ b/integrationtests/controller/cluster/suite_test.go
@@ -60,7 +60,7 @@ var _ = BeforeSuite(func() {
 	Expect(err).ToNot(HaveOccurred(), "failed to set up manager")
 
 	store := manifest.NewStore(mgr.GetClient())
-	builder := target.New(mgr.GetClient())
+	builder := target.New(mgr.GetClient(), mgr.GetAPIReader())
 
 	err = (&reconciler.BundleReconciler{
 		Client:  mgr.GetClient(),
diff --git a/integrationtests/gitjob/controller/suite_test.go b/integrationtests/gitjob/controller/suite_test.go
index 8a759852..1891945d 100644
--- a/integrationtests/gitjob/controller/suite_test.go
+++ b/integrationtests/gitjob/controller/suite_test.go
@@ -162,7 +162,7 @@ var _ = BeforeSuite(func() {
 	Expect(err).ToNot(HaveOccurred())
 
 	store := manifest.NewStore(mgr.GetClient())
-	builder := target.New(mgr.GetClient())
+	builder := target.New(mgr.GetClient(), mgr.GetAPIReader())
 	err = (&ctrlreconciler.BundleReconciler{
 		Client:  mgr.GetClient(),
 		Scheme:  mgr.GetScheme(),
diff --git a/integrationtests/helmops/controller/suite_test.go b/integrationtests/helmops/controller/suite_test.go
index d5fcfb8d..d2fa018b 100644
--- a/integrationtests/helmops/controller/suite_test.go
+++ b/integrationtests/helmops/controller/suite_test.go
@@ -89,7 +89,7 @@ var _ = BeforeSuite(func() {
 	Expect(err).ToNot(HaveOccurred())
 
 	store := manifest.NewStore(mgr.GetClient())
-	builder := target.New(mgr.GetClient())
+	builder := target.New(mgr.GetClient(), mgr.GetAPIReader())
 	err = (&ctrlreconciler.BundleReconciler{
 		Client:  mgr.GetClient(),
 		Scheme:  mgr.GetScheme(),
diff --git a/integrationtests/mocks/core_controller_mock.go b/integrationtests/mocks/core_controller_mock.go
new file mode 100644
index 00000000..f7b13797
--- /dev/null
+++ b/integrationtests/mocks/core_controller_mock.go
@@ -0,0 +1,194 @@
+// Code generated by MockGen. DO NOT EDIT.
+// Source: github.com/rancher/wrangler/v3/pkg/generated/controllers/core/v1 (interfaces: Interface)
+//
+// Generated by this command:
+//
+//	mockgen --build_flags=--mod=mod -destination=integrationtests/mocks/core_controller_mock.go -package=mocks -mock_names=Interface=CoreInterface github.com/rancher/wrangler/v3/pkg/generated/controllers/core/v1 Interface
+//
+
+// Package mocks is a generated GoMock package.
+package mocks
+
+import (
+	reflect "reflect"
+
+	v1 "github.com/rancher/wrangler/v3/pkg/generated/controllers/core/v1"
+	gomock "go.uber.org/mock/gomock"
+)
+
+// CoreInterface is a mock of Interface interface.
+type CoreInterface struct {
+	ctrl     *gomock.Controller
+	recorder *CoreInterfaceMockRecorder
+}
+
+// CoreInterfaceMockRecorder is the mock recorder for CoreInterface.
+type CoreInterfaceMockRecorder struct {
+	mock *CoreInterface
+}
+
+// NewCoreInterface creates a new mock instance.
+func NewCoreInterface(ctrl *gomock.Controller) *CoreInterface {
+	mock := &CoreInterface{ctrl: ctrl}
+	mock.recorder = &CoreInterfaceMockRecorder{mock}
+	return mock
+}
+
+// EXPECT returns an object that allows the caller to indicate expected use.
+func (m *CoreInterface) EXPECT() *CoreInterfaceMockRecorder {
+	return m.recorder
+}
+
+// ConfigMap mocks base method.
+func (m *CoreInterface) ConfigMap() v1.ConfigMapController {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "ConfigMap")
+	ret0, _ := ret[0].(v1.ConfigMapController)
+	return ret0
+}
+
+// ConfigMap indicates an expected call of ConfigMap.
+func (mr *CoreInterfaceMockRecorder) ConfigMap() *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ConfigMap", reflect.TypeOf((*CoreInterface)(nil).ConfigMap))
+}
+
+// Endpoints mocks base method.
+func (m *CoreInterface) Endpoints() v1.EndpointsController {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "Endpoints")
+	ret0, _ := ret[0].(v1.EndpointsController)
+	return ret0
+}
+
+// Endpoints indicates an expected call of Endpoints.
+func (mr *CoreInterfaceMockRecorder) Endpoints() *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Endpoints", reflect.TypeOf((*CoreInterface)(nil).Endpoints))
+}
+
+// Event mocks base method.
+func (m *CoreInterface) Event() v1.EventController {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "Event")
+	ret0, _ := ret[0].(v1.EventController)
+	return ret0
+}
+
+// Event indicates an expected call of Event.
+func (mr *CoreInterfaceMockRecorder) Event() *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Event", reflect.TypeOf((*CoreInterface)(nil).Event))
+}
+
+// Namespace mocks base method.
+func (m *CoreInterface) Namespace() v1.NamespaceController {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "Namespace")
+	ret0, _ := ret[0].(v1.NamespaceController)
+	return ret0
+}
+
+// Namespace indicates an expected call of Namespace.
+func (mr *CoreInterfaceMockRecorder) Namespace() *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Namespace", reflect.TypeOf((*CoreInterface)(nil).Namespace))
+}
+
+// Node mocks base method.
+func (m *CoreInterface) Node() v1.NodeController {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "Node")
+	ret0, _ := ret[0].(v1.NodeController)
+	return ret0
+}
+
+// Node indicates an expected call of Node.
+func (mr *CoreInterfaceMockRecorder) Node() *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Node", reflect.TypeOf((*CoreInterface)(nil).Node))
+}
+
+// PersistentVolume mocks base method.
+func (m *CoreInterface) PersistentVolume() v1.PersistentVolumeController {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "PersistentVolume")
+	ret0, _ := ret[0].(v1.PersistentVolumeController)
+	return ret0
+}
+
+// PersistentVolume indicates an expected call of PersistentVolume.
+func (mr *CoreInterfaceMockRecorder) PersistentVolume() *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PersistentVolume", reflect.TypeOf((*CoreInterface)(nil).PersistentVolume))
+}
+
+// PersistentVolumeClaim mocks base method.
+func (m *CoreInterface) PersistentVolumeClaim() v1.PersistentVolumeClaimController {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "PersistentVolumeClaim")
+	ret0, _ := ret[0].(v1.PersistentVolumeClaimController)
+	return ret0
+}
+
+// PersistentVolumeClaim indicates an expected call of PersistentVolumeClaim.
+func (mr *CoreInterfaceMockRecorder) PersistentVolumeClaim() *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PersistentVolumeClaim", reflect.TypeOf((*CoreInterface)(nil).PersistentVolumeClaim))
+}
+
+// Pod mocks base method.
+func (m *CoreInterface) Pod() v1.PodController {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "Pod")
+	ret0, _ := ret[0].(v1.PodController)
+	return ret0
+}
+
+// Pod indicates an expected call of Pod.
+func (mr *CoreInterfaceMockRecorder) Pod() *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Pod", reflect.TypeOf((*CoreInterface)(nil).Pod))
+}
+
+// Secret mocks base method.
+func (m *CoreInterface) Secret() v1.SecretController {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "Secret")
+	ret0, _ := ret[0].(v1.SecretController)
+	return ret0
+}
+
+// Secret indicates an expected call of Secret.
+func (mr *CoreInterfaceMockRecorder) Secret() *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Secret", reflect.TypeOf((*CoreInterface)(nil).Secret))
+}
+
+// Service mocks base method.
+func (m *CoreInterface) Service() v1.ServiceController {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "Service")
+	ret0, _ := ret[0].(v1.ServiceController)
+	return ret0
+}
+
+// Service indicates an expected call of Service.
+func (mr *CoreInterfaceMockRecorder) Service() *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Service", reflect.TypeOf((*CoreInterface)(nil).Service))
+}
+
+// ServiceAccount mocks base method.
+func (m *CoreInterface) ServiceAccount() v1.ServiceAccountController {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "ServiceAccount")
+	ret0, _ := ret[0].(v1.ServiceAccountController)
+	return ret0
+}
+
+// ServiceAccount indicates an expected call of ServiceAccount.
+func (mr *CoreInterfaceMockRecorder) ServiceAccount() *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ServiceAccount", reflect.TypeOf((*CoreInterface)(nil).ServiceAccount))
+}
diff --git a/internal/helmvalues/extract_test.go b/internal/helmvalues/extract_test.go
new file mode 100644
index 00000000..3c54f2ff
--- /dev/null
+++ b/internal/helmvalues/extract_test.go
@@ -0,0 +1,308 @@
+package helmvalues_test
+
+import (
+	"testing"
+
+	"github.com/rancher/fleet/internal/helmvalues"
+	fleet "github.com/rancher/fleet/pkg/apis/fleet.cattle.io/v1alpha1"
+)
+
+func TestExtractValues(t *testing.T) {
+	type args struct {
+		bundle *fleet.Bundle
+	}
+	tests := []struct {
+		name          string
+		args          args
+		wantHash      string
+		wantData      map[string][]byte
+		wantErr       bool
+		wantErrString string
+	}{
+		{
+			name: "nil helm options",
+			args: args{
+				bundle: &fleet.Bundle{Spec: fleet.BundleSpec{
+					BundleDeploymentOptions: fleet.BundleDeploymentOptions{
+						Helm: nil,
+					},
+					Targets: []fleet.BundleTarget{
+						{
+							Name: "target",
+							BundleDeploymentOptions: fleet.BundleDeploymentOptions{
+								Helm: nil,
+							},
+						},
+					},
+				}},
+			},
+			wantHash: "",
+			wantData: map[string][]byte{},
+			wantErr:  false,
+		},
+		{
+			name: "nil values",
+			args: args{
+				bundle: &fleet.Bundle{Spec: fleet.BundleSpec{
+					BundleDeploymentOptions: fleet.BundleDeploymentOptions{
+						Helm: &fleet.HelmOptions{
+							Values: nil,
+						},
+					},
+					Targets: []fleet.BundleTarget{
+						{
+							Name: "target",
+							BundleDeploymentOptions: fleet.BundleDeploymentOptions{
+								Helm: &fleet.HelmOptions{
+									Values: nil,
+								},
+							},
+						},
+					},
+				}},
+			},
+			wantHash: "",
+			wantData: map[string][]byte{},
+			wantErr:  false,
+		},
+		{
+			name: "empty values",
+			args: args{
+				bundle: &fleet.Bundle{Spec: fleet.BundleSpec{
+					BundleDeploymentOptions: fleet.BundleDeploymentOptions{
+						Helm: &fleet.HelmOptions{
+							Values: &fleet.GenericMap{},
+						},
+					},
+					Targets: []fleet.BundleTarget{
+						{
+							Name: "target",
+							BundleDeploymentOptions: fleet.BundleDeploymentOptions{
+								Helm: &fleet.HelmOptions{
+									Values: &fleet.GenericMap{},
+								},
+							},
+						},
+					},
+				}},
+			},
+			wantHash: "",
+			wantData: map[string][]byte{},
+			wantErr:  false,
+		},
+		{
+			name: "values present",
+			args: args{
+				bundle: &fleet.Bundle{Spec: fleet.BundleSpec{
+					BundleDeploymentOptions: fleet.BundleDeploymentOptions{
+						Helm: &fleet.HelmOptions{
+							Values: &fleet.GenericMap{
+								Data: map[string]interface{}{"key": "value"},
+							},
+						},
+					},
+					Targets: []fleet.BundleTarget{
+						{
+							Name: "target",
+							BundleDeploymentOptions: fleet.BundleDeploymentOptions{
+								Helm: &fleet.HelmOptions{
+									Values: &fleet.GenericMap{
+										Data: map[string]interface{}{"newkey": "value"},
+									},
+								},
+							},
+						},
+					},
+				}},
+			},
+
+			wantHash: "17e05a97acde825d03ca37c2b8fc1aecf3f8f9fde28c21702492c56d4d4a68f1",
+			wantData: map[string][]byte{
+				"values.yaml": []byte(`{"key":"value"}`),
+				"target":      []byte(`{"newkey":"value"}`)},
+			wantErr: false,
+		},
+	}
+
+	for _, tt := range tests {
+		h, d, err := helmvalues.ExtractValues(tt.args.bundle)
+		if (err != nil) != tt.wantErr {
+			t.Errorf("%s: error = %v, wantErr %v", tt.name, err, tt.wantErr)
+			return
+		}
+		if err != nil && err.Error() != tt.wantErrString {
+			t.Errorf("%s: error = %s, wantErrString %v", tt.name, err, tt.wantErrString)
+			return
+		}
+		if h != tt.wantHash {
+			t.Errorf("%s: hash = %s, want %s", tt.name, h, tt.wantHash)
+		}
+
+		if len(d) != len(tt.wantData) {
+			t.Errorf("%s: data = %v, want %v", tt.name, d, tt.wantData)
+		}
+		for k, v := range d {
+			if string(v) != string(tt.wantData[k]) {
+				t.Errorf("%s: data[%s] = %s, want %s", tt.name, k, v, tt.wantData[k])
+			}
+		}
+	}
+}
+
+func TestExtractOptions(t *testing.T) {
+	type args struct {
+		bd *fleet.BundleDeployment
+	}
+	var nullMap *fleet.GenericMap
+	tests := []struct {
+		name          string
+		args          args
+		wantOptions   []byte
+		wantStaged    []byte
+		wantHash      string
+		wantErr       bool
+		wantErrString string
+	}{
+		{
+			name: "nil helm options",
+			args: args{
+				bd: &fleet.BundleDeployment{Spec: fleet.BundleDeploymentSpec{
+					Options: fleet.BundleDeploymentOptions{
+						Helm: nil,
+					},
+				}},
+			},
+			wantOptions: []byte{},
+			wantStaged:  []byte{},
+			wantHash:    "",
+			wantErr:     false,
+		},
+		{
+			name: "nil values",
+			args: args{
+				bd: &fleet.BundleDeployment{Spec: fleet.BundleDeploymentSpec{
+					Options: fleet.BundleDeploymentOptions{
+						Helm: &fleet.HelmOptions{},
+					},
+					StagedOptions: fleet.BundleDeploymentOptions{
+						Helm: &fleet.HelmOptions{
+							TakeOwnership: true,
+						},
+					},
+				}},
+			},
+			wantOptions: []byte{},
+			wantStaged:  []byte{},
+			wantHash:    "",
+			wantErr:     false,
+		},
+		{
+			name: "null values",
+			args: args{
+				bd: &fleet.BundleDeployment{Spec: fleet.BundleDeploymentSpec{
+					Options: fleet.BundleDeploymentOptions{
+						Helm: &fleet.HelmOptions{
+							Values: nullMap,
+						},
+					},
+					StagedOptions: fleet.BundleDeploymentOptions{
+						Helm: &fleet.HelmOptions{
+							TakeOwnership: true,
+							Values:        &fleet.GenericMap{},
+						},
+					},
+				}},
+			},
+			wantOptions: []byte{},
+			wantStaged:  []byte{},
+			wantHash:    "",
+			wantErr:     false,
+		},
+		{
+			name: "empty values",
+			args: args{
+				bd: &fleet.BundleDeployment{Spec: fleet.BundleDeploymentSpec{
+					Options: fleet.BundleDeploymentOptions{
+						Helm: &fleet.HelmOptions{
+							Values: &fleet.GenericMap{Data: map[string]interface{}{}},
+						},
+					},
+					StagedOptions: fleet.BundleDeploymentOptions{
+						Helm: &fleet.HelmOptions{
+							TakeOwnership: true,
+							Values:        &fleet.GenericMap{Data: map[string]interface{}{}},
+						},
+					},
+				}},
+			},
+			wantOptions: []byte(""),
+			wantStaged:  []byte(""),
+			wantHash:    "",
+			wantErr:     false,
+		},
+		{
+			name: "values present",
+			args: args{
+				bd: &fleet.BundleDeployment{Spec: fleet.BundleDeploymentSpec{
+					Options: fleet.BundleDeploymentOptions{
+						Helm: &fleet.HelmOptions{
+							Values: &fleet.GenericMap{
+								Data: map[string]interface{}{"key": "value"},
+							},
+						},
+					},
+				}},
+			},
+			wantOptions: []byte(`{"key":"value"}`),
+			wantStaged:  []byte{},
+			wantHash:    "e43abcf3375244839c012f9633f95862d232a95b00d5bc7348b3098b9fed7f32",
+			wantErr:     false,
+		},
+		{
+			name: "values and staged present",
+			args: args{
+				bd: &fleet.BundleDeployment{Spec: fleet.BundleDeploymentSpec{
+					Options: fleet.BundleDeploymentOptions{
+						Helm: &fleet.HelmOptions{
+							Values: &fleet.GenericMap{
+								Data: map[string]interface{}{"key": "value"},
+							},
+						},
+					},
+					StagedOptions: fleet.BundleDeploymentOptions{
+						Helm: &fleet.HelmOptions{
+							Values: &fleet.GenericMap{
+								Data: map[string]interface{}{"newkey": "value"},
+							},
+						},
+					},
+				}},
+			},
+			wantOptions: []byte(`{"key":"value"}`),
+			wantStaged:  []byte(`{"newkey":"value"}`),
+			wantHash:    "01c44d8a446abccb870503db292e07cb2b8da135b6fec52b21048bdab8c84a7c",
+			wantErr:     false,
+		},
+	}
+
+	for _, tt := range tests {
+		h, o, s, err := helmvalues.ExtractOptions(tt.args.bd)
+		if (err != nil) != tt.wantErr {
+			t.Errorf("%s: error = %v, wantErr %v", tt.name, err, tt.wantErr)
+			return
+		}
+		if err != nil && err.Error() != tt.wantErrString {
+			t.Errorf("%s: error = %s, wantErrString %v", tt.name, err, tt.wantErrString)
+			return
+		}
+		if string(o) != string(tt.wantOptions) {
+			t.Errorf("%s: options = %q, want %q", tt.name, o, tt.wantOptions)
+		}
+		if string(s) != string(tt.wantStaged) {
+			t.Errorf("%s: staged = %q, want %q", tt.name, s, tt.wantStaged)
+		}
+		if h != tt.wantHash {
+			t.Errorf("%s: hash = %q, want %q", tt.name, h, tt.wantHash)
+		}
+	}
+}
diff --git a/internal/helmvalues/hash_test.go b/internal/helmvalues/hash_test.go
new file mode 100644
index 00000000..ed051a23
--- /dev/null
+++ b/internal/helmvalues/hash_test.go
@@ -0,0 +1,77 @@
+package helmvalues_test
+
+import (
+	"testing"
+
+	"github.com/rancher/fleet/internal/helmvalues"
+)
+
+func TestHashOptions(t *testing.T) {
+	type args struct {
+		bytes [][]byte
+	}
+	tests := []struct {
+		name string
+		args args
+		want string
+	}{
+		{
+			name: "empty",
+			args: args{
+				bytes: [][]byte{},
+			},
+			want: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
+		},
+		{
+			name: "empty string",
+			args: args{
+				bytes: [][]byte{[]byte("")},
+			},
+			want: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
+		},
+		{
+			name: "single word",
+			args: args{
+				bytes: [][]byte{
+					[]byte("test"),
+				},
+			},
+			want: "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
+		},
+		{
+			name: "empty options",
+			args: args{
+				bytes: [][]byte{
+					[]byte(""), []byte(""),
+				},
+			},
+			want: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
+		},
+		{
+			name: "empty option",
+			args: args{
+				bytes: [][]byte{
+					[]byte("{}"),
+				},
+			},
+			want: "44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a",
+		},
+		{
+			name: "empty options",
+			args: args{
+				bytes: [][]byte{
+					[]byte("{}"), []byte("{}"),
+				},
+			},
+			want: "b51f08b698d88d8027a935d9db649774949f5fb41a0c559bfee6a9a13225c72d",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if got := helmvalues.HashOptions(tt.args.bytes...); got != tt.want {
+				t.Errorf("HashOptions() = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}