Repository: rancher/fleet
Pull request #2109 (2024-01-31 09:12:29)

Add gitjob end-to-end tests with SSH key
This commit ensures that gitjob end-to-end tests run in all CI workflows which run end-to-end tests requiring secrets. This entails setting the gitjob-specific SSH key in all such workflows.

This fixes failures for:
- Nighly E2E Fleet ([before](https://github.com/rancher/fleet/actions/runs/7722443912/job/21050630477)/[after](https://github.com/rancher/fleet/actions/runs/7723663716/job/21054482926))
- CI GKE ([before](https://github.com/rancher/fleet/actions/runs/7719988713/job/21044174369)/[after](https://github.com/rancher/fleet/actions/runs/7724550665/job/21056923633))
- CI AKS ([before](https://github.com/rancher/fleet/actions/runs/7709609929/job/21011176521)/[after](https://github.com/rancher/fleet/actions/runs/7724557547/job/21056942936))

Refers to #2008


Patch:
diff --git a/.github/scripts/deploy-fleet.sh b/.github/scripts/deploy-fleet.sh
index 97fe5da7..81351762 100755
--- a/.github/scripts/deploy-fleet.sh
+++ b/.github/scripts/deploy-fleet.sh
@@ -10,16 +10,20 @@ function eventually {
 }
 
 # usage: ./deploy-fleet.sh ghcr.io/rancher/fleet:sha-49f6f81 ghcr.io/rancher/fleet-agent:1h
-if [ $# -ge 2 ] && [ -n "$1" ] && [ -n "$2" ]; then
+if [ $# -ge 3 ] && [ -n "$1" ] && [ -n "$2" ] && [ -n "$3" ]; then
   fleetRepo="${1%:*}"
   fleetTag="${1#*:}"
   agentRepo="${2%:*}"
   agentTag="${2#*:}"
+  gitjobRepo="${3%:*}"
+  gitjobTag="${3#*:}"
 else
   fleetRepo="rancher/fleet"
   fleetTag="dev"
   agentRepo="rancher/fleet-agent"
   agentTag="dev"
+  gitjobRepo="rancher/fleet-gitjob"
+  gitjobTag="dev"
 fi
 
 eventually helm upgrade --install fleet-crd charts/fleet-crd \
@@ -34,7 +38,9 @@ eventually helm upgrade --install fleet charts/fleet \
   --set image.tag="$fleetTag" \
   --set agentImage.repository="$agentRepo" \
   --set agentImage.tag="$agentTag" \
-  --set agentImage.imagePullPolicy=IfNotPresent
+  --set agentImage.imagePullPolicy=IfNotPresent \
+  --set gitjob.repository="$gitjobRepo" \
+  --set gitjob.tag="$gitjobTag"
 
 # wait for controller and agent rollout
 kubectl -n cattle-fleet-system rollout status deploy/fleet-controller
diff --git a/.github/workflows/aks.yml b/.github/workflows/aks.yml
index 6d2776a7..caba7cfb 100644
--- a/.github/workflows/aks.yml
+++ b/.github/workflows/aks.yml
@@ -121,12 +121,29 @@ jobs:
           push: true
           tags: ${{ steps.meta-fleet-agent.outputs.tags }}
           labels: ${{ steps.meta-fleet-agent.outputs.labels }}
+      -
+        id: meta-fleet-gitjob
+        uses: docker/metadata-action@v5
+        with:
+          images: |
+            ttl.sh/rancher/fleet-gitjob-${{ steps.uuid.outputs.uuid }}
+          tags: type=raw,value=1h
+      -
+        uses: docker/build-push-action@v5
+        with:
+          context: .
+          file: package/Dockerfile.gitjob
+          build-args: |
+            ARCH=${{ env.GOARCH }}
+          push: true
+          tags: ${{ steps.meta-fleet-gitjob.outputs.tags }}
+          labels: ${{ steps.meta-fleet-gitjob.outputs.labels }}
       -
         name: Deploy Fleet
         run: |
           export KUBECONFIG="$GITHUB_WORKSPACE/kubeconfig-fleet-ci"
           echo "${{ steps.meta-fleet.outputs.tags }} ${{ steps.meta-fleet-agent.outputs.tags }}"
-          ./.github/scripts/deploy-fleet.sh ${{ steps.meta-fleet.outputs.tags }} ${{ steps.meta-fleet-agent.outputs.tags }}
+          ./.github/scripts/deploy-fleet.sh ${{ steps.meta-fleet.outputs.tags }} ${{ steps.meta-fleet-agent.outputs.tags }} ${{ steps.meta-fleet-gitjob.outputs.tags }}
       -
         name: Fleet E2E Tests
         env:
@@ -159,6 +176,13 @@ jobs:
           echo "${{ secrets.CI_AKS_SSH_PUBKEY }}" > "$GIT_SSH_PUBKEY"
 
           ginkgo e2e/require-secrets
+
+          export GIT_GITJOB_SSH_KEY="$GITHUB_WORKSPACE/id_ed25519"
+          export GIT_GITJOB_SSH_PUBKEY="$GITHUB_WORKSPACE/id_ed25519.pub"
+          echo "${{ secrets.CI_GITJOB_SSH_KEY }}" > "$GIT_GITJOB_SSH_KEY"
+          echo "${{ secrets.CI_GITJOB_SSH_PUBKEY }}" > "$GIT_GITJOB_SSH_PUBKEY"
+
+          ginkgo e2e/gitjob
       -
         name: Delete AKS cluster
         # We always tear down the cluster, to avoid costs. Except when running
diff --git a/.github/workflows/gke.yml b/.github/workflows/gke.yml
index 5865c5e4..8d5a0c4f 100644
--- a/.github/workflows/gke.yml
+++ b/.github/workflows/gke.yml
@@ -126,11 +126,28 @@ jobs:
           push: true
           tags: ${{ steps.meta-fleet-agent.outputs.tags }}
           labels: ${{ steps.meta-fleet-agent.outputs.labels }}
+      -
+        id: meta-fleet-gitjob
+        uses: docker/metadata-action@v5
+        with:
+          images: |
+            ttl.sh/rancher/fleet-gitjob-${{ steps.uuid.outputs.uuid }}
+          tags: type=raw,value=1h
+      -
+        uses: docker/build-push-action@v5
+        with:
+          context: .
+          file: package/Dockerfile.gitjob
+          build-args: |
+            ARCH=${{ env.GOARCH }}
+          push: true
+          tags: ${{ steps.meta-fleet-gitjob.outputs.tags }}
+          labels: ${{ steps.meta-fleet-gitjob.outputs.labels }}
       -
         name: Deploy Fleet
         run: |
           echo "${{ steps.meta-fleet.outputs.tags }} ${{ steps.meta-fleet-agent.outputs.tags }}"
-          ./.github/scripts/deploy-fleet.sh ${{ steps.meta-fleet.outputs.tags }} ${{ steps.meta-fleet-agent.outputs.tags }}
+          ./.github/scripts/deploy-fleet.sh ${{ steps.meta-fleet.outputs.tags }} ${{ steps.meta-fleet-agent.outputs.tags }} ${{ steps.meta-fleet-gitjob.outputs.tags }}
       -
         name: Fleet E2E Tests
         env:
@@ -160,6 +177,13 @@ jobs:
           echo "${{ secrets.CI_GKE_SSH_PUBKEY }}" > "$GIT_SSH_PUBKEY"
 
           ginkgo e2e/require-secrets
+
+          export GIT_GITJOB_SSH_KEY="$GITHUB_WORKSPACE/id_ed25519"
+          export GIT_GITJOB_SSH_PUBKEY="$GITHUB_WORKSPACE/id_ed25519.pub"
+          echo "${{ secrets.CI_GITJOB_SSH_KEY }}" > "$GIT_GITJOB_SSH_KEY"
+          echo "${{ secrets.CI_GITJOB_SSH_PUBKEY }}" > "$GIT_GITJOB_SSH_PUBKEY"
+
+          ginkgo e2e/gitjob
       -
         name: Delete GKE cluster
         if: ${{ always() && github.event.inputs.keep_cluster != 'yes' }}
diff --git a/.github/workflows/nightly-ci.yml b/.github/workflows/nightly-ci.yml
index 0f872b91..046bcd67 100644
--- a/.github/workflows/nightly-ci.yml
+++ b/.github/workflows/nightly-ci.yml
@@ -150,6 +150,12 @@ jobs:
           echo "${{ secrets.CI_SSH_PUBKEY }}" > "$GIT_SSH_PUBKEY"
 
           ginkgo e2e/require-secrets
+
+          export GIT_GITJOB_SSH_KEY="$GITHUB_WORKSPACE/id_ed25519"
+          export GIT_GITJOB_SSH_PUBKEY="$GITHUB_WORKSPACE/id_ed25519.pub"
+          echo "${{ secrets.CI_GITJOB_SSH_KEY }}" > "$GIT_GITJOB_SSH_KEY"
+          echo "${{ secrets.CI_GITJOB_SSH_PUBKEY }}" > "$GIT_GITJOB_SSH_PUBKEY"
+
           ginkgo e2e/gitjob
       -
         name: Dump Failed Environment
diff --git a/internal/cmd/agent/deployer/deployer.go b/internal/cmd/agent/deployer/deployer.go
index 5dab555a..4cf181f6 100644
--- a/internal/cmd/agent/deployer/deployer.go
+++ b/internal/cmd/agent/deployer/deployer.go
@@ -166,7 +166,7 @@ func (d *Deployer) fetchNamespace(ctx context.Context, releaseID string) (*corev
 	namespace := strings.Split(releaseID, "/")[0]
 	list := &corev1.NamespaceList{}
 	err := d.client.List(ctx, list, client.MatchingLabels{
-		"name": namespace,
+		corev1.LabelMetadataName: namespace,
 	})
 	if err != nil {
 		return nil, err
@@ -177,16 +177,16 @@ func (d *Deployer) fetchNamespace(ctx context.Context, releaseID string) (*corev
 	return &list.Items[0], nil
 }
 
-// addLabelsFromOptions updates nsLabels so that it only contains all labels specified in optLabels, plus the `name` labels added by Helm when creating the namespace.
+// addLabelsFromOptions updates nsLabels so that it only contains all labels specified in optLabels, plus the `kubernetes.io/metadata.name` labels added by kubernetes when creating the namespace.
 func addLabelsFromOptions(nsLabels map[string]string, optLabels map[string]string) {
 	for k, v := range optLabels {
 		nsLabels[k] = v
 	}
 
 	// Delete labels not defined in the options.
-	// Keep the name label as it is added by helm when creating the namespace.
+	// Keep the `kubernetes.io/metadata.name` label as it is added by kubernetes when creating the namespace.
 	for k := range nsLabels {
-		if _, ok := optLabels[k]; k != "name" && !ok {
+		if _, ok := optLabels[k]; k != corev1.LabelMetadataName && !ok {
 			delete(nsLabels, k)
 		}
 	}
diff --git a/internal/cmd/agent/operator.go b/internal/cmd/agent/operator.go
index da4e821f..c2288f98 100644
--- a/internal/cmd/agent/operator.go
+++ b/internal/cmd/agent/operator.go
@@ -2,6 +2,7 @@ package agent
 
 import (
 	"context"
+	"flag"
 	"os"
 
 	"github.com/rancher/fleet/internal/cmd/agent/controller"
@@ -151,6 +152,11 @@ func newReconciler(ctx, localCtx context.Context, mgr manager.Manager, localConf
 	}
 	localClient := localCluster.GetClient()
 
+	if kubeconfig := flag.Lookup("kubeconfig").Value.String(); kubeconfig != "" {
+		// set KUBECONFIG env var so helm can find it
+		os.Setenv("KUBECONFIG", kubeconfig)
+	}
+
 	// Build the helm deployer, which uses a getter for local cluster's client-go client for helm SDK
 	helmDeployer := helmdeployer.New(
 		systemNamespace,
diff --git a/internal/cmd/cli/apply.go b/internal/cmd/cli/apply.go
index 3520c932..e21d0029 100644
--- a/internal/cmd/cli/apply.go
+++ b/internal/cmd/cli/apply.go
@@ -10,6 +10,7 @@ import (
 	"github.com/spf13/cobra"
 
 	"github.com/rancher/fleet/internal/bundlereader"
+	"github.com/rancher/fleet/internal/client"
 	command "github.com/rancher/fleet/internal/cmd"
 	"github.com/rancher/fleet/internal/cmd/cli/apply"
 	"github.com/rancher/fleet/internal/cmd/cli/writer"
@@ -27,6 +28,7 @@ func NewApply() *cobra.Command {
 }
 
 type Apply struct {
+	FleetClient
 	BundleInputArgs
 	OutputArgsNoDefault
 	Label                       map[string]string `usage:"Labels to apply to created bundles" short:"l"`
@@ -49,6 +51,14 @@ type Apply struct {
 	CorrectDriftKeepFailHistory bool              `usage:"Keep helm history for failed rollbacks" name:"correct-drift-keep-fail-history"`
 }
 
+func (r *Apply) PersistentPre(_ *cobra.Command, _ []string) error {
+	if err := r.SetupDebug(); err != nil {
+		return fmt.Errorf("failed to set up debug logging: %w", err)
+	}
+	Client = client.NewGetter(r.Kubeconfig, r.Context, r.Namespace)
+	return nil
+}
+
 func (a *Apply) Run(cmd *cobra.Command, args []string) error {
 	labels := a.Label
 	if a.Commit == "" {
@@ -106,7 +116,7 @@ func (a *Apply) Run(cmd *cobra.Command, args []string) error {
 		args = args[1:]
 	}
 
-	return apply.Apply(cmd.Context(), Client, name, args, opts)
+	return apply.CreateBundles(cmd.Context(), Client, name, args, opts)
 }
 
 // addAuthToOpts adds auth if provided as arguments. It will look first for HelmCredentialsByPathFile. If HelmCredentialsByPathFile
diff --git a/internal/cmd/cli/apply/apply.go b/internal/cmd/cli/apply/apply.go
index f7f55f22..5b8a3258 100644
--- a/internal/cmd/cli/apply/apply.go
+++ b/internal/cmd/cli/apply/apply.go
@@ -72,10 +72,10 @@ func globDirs(baseDir string) (result []string, err error) {
 	return
 }
 
-// Apply creates bundles from the baseDirs, their names are prefixed with
+// CreateBundles creates bundles from the baseDirs, their names are prefixed with
 // repoName. Depending on opts.Output the bundles are created in the cluster or
 // printed to stdout, ...
-func Apply(ctx context.Context, client Getter, repoName string, baseDirs []string, opts Options) error {
+func CreateBundles(ctx context.Context, client Getter, repoName string, baseDirs []string, opts Options) error {
 	if len(baseDirs) == 0 {
 		baseDirs = []string{"."}
 	}
diff --git a/internal/cmd/cli/cleanup.go b/internal/cmd/cli/cleanup.go
index cad43953..ea1c7896 100644
--- a/internal/cmd/cli/cleanup.go
+++ b/internal/cmd/cli/cleanup.go
@@ -9,6 +9,7 @@ import (
 
 	"github.com/spf13/cobra"
 
+	"github.com/rancher/fleet/internal/client"
 	command "github.com/rancher/fleet/internal/cmd"
 	"github.com/rancher/fleet/internal/cmd/cli/cleanup"
 )
@@ -21,11 +22,20 @@ func NewCleanUp() *cobra.Command {
 }
 
 type CleanUp struct {
+	FleetClient
 	Min    string `usage:"Minimum delay between deletes (default: 10ms)" name:"min"`
 	Max    string `usage:"Maximum delay between deletes (default: 5s)" name:"max"`
 	Factor string `usage:"Factor to increase delay between deletes (default: 1.1)" name:"factor"`
 }
 
+func (r *CleanUp) PersistentPre(_ *cobra.Command, _ []string) error {
+	if err := r.SetupDebug(); err != nil {
+		return fmt.Errorf("failed to set up debug logging: %w", err)
+	}
+	Client = client.NewGetter(r.Kubeconfig, r.Context, r.Namespace)
+	return nil
+}
+
 func (a *CleanUp) Run(cmd *cobra.Command, args []string) error {
 	var err error
 	min := 10 * time.Millisecond
diff --git a/internal/cmd/cli/root.go b/internal/cmd/cli/root.go
index e678f47f..0de87613 100644
--- a/internal/cmd/cli/root.go
+++ b/internal/cmd/cli/root.go
@@ -2,8 +2,6 @@
 package cli
 
 import (
-	"fmt"
-
 	"github.com/spf13/cobra"
 
 	"github.com/rancher/fleet/internal/client"
@@ -37,23 +35,17 @@ func App() *cobra.Command {
 }
 
 type Fleet struct {
-	command.DebugConfig
-	SystemNamespace string `usage:"System namespace of the controller" default:"cattle-fleet-system"`
-	Namespace       string `usage:"namespace" env:"NAMESPACE" default:"fleet-local" short:"n"`
-	Kubeconfig      string `usage:"kubeconfig for authentication" short:"k"`
-	Context         string `usage:"kubeconfig context for authentication"`
 }
 
 func (r *Fleet) Run(cmd *cobra.Command, _ []string) error {
 	return cmd.Help()
 }
 
-func (r *Fleet) PersistentPre(_ *cobra.Command, _ []string) error {
-	if err := r.SetupDebug(); err != nil {
-		return fmt.Errorf("failed to setup debug logging: %w", err)
-	}
-	Client = client.NewGetter(r.Kubeconfig, r.Context, r.Namespace)
-	return nil
+type FleetClient struct {
+	command.DebugConfig
+	Namespace  string `usage:"namespace" env:"NAMESPACE" default:"fleet-local" short:"n"`
+	Kubeconfig string `usage:"kubeconfig for authentication" short:"k"`
+	Context    string `usage:"kubeconfig context for authentication"`
 }
 
 type BundleInputArgs struct {
@@ -61,10 +53,6 @@ type BundleInputArgs struct {
 	BundleFile string `usage:"Location of the raw Bundle resource yaml" short:"b"`
 }
 
-type OutputArgs struct {
-	Output string `usage:"Output contents to file or - for stdout"  short:"o" default:"-"`
-}
-
 type OutputArgsNoDefault struct {
 	Output string `usage:"Output contents to file or - for stdout"  short:"o"`
 }
diff --git a/internal/cmd/controller/reconciler/bundle_controller.go b/internal/cmd/controller/reconciler/bundle_controller.go
index 0df76937..072a6f48 100644
--- a/internal/cmd/controller/reconciler/bundle_controller.go
+++ b/internal/cmd/controller/reconciler/bundle_controller.go
@@ -12,7 +12,6 @@ import (
 
 	apierrors "k8s.io/apimachinery/pkg/api/errors"
 	"k8s.io/apimachinery/pkg/api/meta"
-	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/runtime"
 	"k8s.io/apimachinery/pkg/runtime/schema"
 	"k8s.io/apimachinery/pkg/types"
@@ -100,7 +99,8 @@ func (r *BundleReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctr
 		return ctrl.Result{}, err
 	}
 
-	if err := updatePartitions(&bundle.Status, matchedTargets); err != nil {
+	// this will add the defaults for a new bundledeployment
+	if err := target.UpdatePartitions(&bundle.Status, matchedTargets); err != nil {
 		updateDisplay(&bundle.Status)
 		return ctrl.Result{}, err
 	}
@@ -129,18 +129,7 @@ func (r *BundleReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctr
 
 		// NOTE we don't use the existing BundleDeployment, we discard annotations, status, etc
 		// copy labels from Bundle as they might have changed
-		bd := &fleet.BundleDeployment{
-			ObjectMeta: metav1.ObjectMeta{
-				Name:      target.Deployment.Name,
-				Namespace: target.Deployment.Namespace,
-				Labels:    target.BundleDeploymentLabels(target.Cluster.Namespace, target.Cluster.Name),
-			},
-			Spec: target.Deployment.Spec,
-		}
-		bd.Spec.Paused = target.IsPaused()
-		bd.Spec.DependsOn = bundle.Spec.DependsOn
-		bd.Spec.CorrectDrift = target.Options.CorrectDrift
-
+		bd := target.BundleDeployment()
 		updated := bd.DeepCopy()
 		op, err := controllerutil.CreateOrUpdate(ctx, r.Client, bd, func() error {
 			bd.Spec = updated.Spec
@@ -250,86 +239,3 @@ func (r *BundleReconciler) SetupWithManager(mgr ctrl.Manager) error {
 		).
 		Complete(r)
 }
-
-// updatePartitions recomputes status, including partitions, from data in allTargets
-// it creates Deployments in allTargets if they are missing
-// it updates Deployments in allTargets if they are out of sync (DeploymentID != StagedDeploymentID)
-func updatePartitions(status *fleet.BundleStatus, allTargets []*target.Target) (err error) {
-	partitions, err := target.Partitions(allTargets)
-	if err != nil {
-		return err
-	}
-
-	status.UnavailablePartitions = 0
-	status.MaxUnavailablePartitions, err = target.MaxUnavailablePartitions(partitions, allTargets)
-	if err != nil {
-		return err
-	}
-
-	for _, partition := range partitions {
-		partition := partition // fix gosec warning regarding "Implicit memory aliasing in for loop"
-		for _, target := range partition.Targets {
-			// for a new bundledeployment, only stage the first maxNew (50) targets
-			if target.Deployment == nil && status.NewlyCreated < status.MaxNew {
-				status.NewlyCreated++
-				target.Deployment = &fleet.BundleDeployment{
-					ObjectMeta: metav1.ObjectMeta{
-						Name:      target.Bundle.Name,
-						Namespace: target.Cluster.Status.Namespace,
-						Labels:    target.BundleDeploymentLabels(target.Cluster.Namespace, target.Cluster.Name),
-					},
-				}
-			}
-			// stage targets that have a Deployment struct
-			if target.Deployment != nil {
-				// NOTE merged options from targets.Targets() are set to be staged
-				target.Deployment.Spec.StagedOptions = target.Options
-				target.Deployment.Spec.StagedDeploymentID = target.DeploymentID
-			}
-		}
-
-		for _, currentTarget := range partition.Targets {
-			// NOTE this will propagate the staged, merged options to the current deployment
-			updateTarget(currentTarget, status, &partition.Status)
-		}
-
-		if target.UpdateStatusUnavailable(&partition.Status, partition.Targets) {
-			status.UnavailablePartitions++
-		}
-
-		if status.UnavailablePartitions > status.MaxUnavailablePartitions {
-			break
-		}
-	}
-
-	for _, partition := range partitions {
-		status.PartitionStatus = append(status.PartitionStatus, partition.Status)
-	}
-
-	return nil
-}
-
-// updateTarget will update DeploymentID and Options for the target to the
-// staging values, if it's in a deployable state
-func updateTarget(t *target.Target, status *fleet.BundleStatus, partitionStatus *fleet.PartitionStatus) {
-	if t.Deployment != nil &&
-		// Not Paused
-		!t.IsPaused() &&
-		// Has been staged
-		t.Deployment.Spec.StagedDeploymentID != "" &&
-		// Is out of sync
-		t.Deployment.Spec.DeploymentID != t.Deployment.Spec.StagedDeploymentID &&
-		// Global max unavailable not reached
-		(status.Unavailable < status.MaxUnavailable || target.IsUnavailable(t.Deployment)) &&
-		// Partition max unavailable not reached
-		(partitionStatus.Unavailable < partitionStatus.MaxUnavailable || target.IsUnavailable(t.Deployment)) {
-
-		if !target.IsUnavailable(t.Deployment) {
-			// If this was previously available, now increment unavailable count. "Upgrading" is treated as unavailable.
-			status.Unavailable++
-			partitionStatus.Unavailable++
-		}
-		t.Deployment.Spec.DeploymentID = t.Deployment.Spec.StagedDeploymentID
-		t.Deployment.Spec.Options = t.Deployment.Spec.StagedOptions
-	}
-}
diff --git a/internal/cmd/controller/target/builder.go b/internal/cmd/controller/target/builder.go
index f7141ff1..6997919c 100644
--- a/internal/cmd/controller/target/builder.go
+++ b/internal/cmd/controller/target/builder.go
@@ -125,7 +125,7 @@ func (m *Manager) getNamespacesForBundle(ctx context.Context, bundle *fleet.Bund
 	for _, mapping := range mappings.Items {
 		logger.V(4).Info("Looking for matching namespaces", "bundleNamespaceMapping", mapping, "bundle", bundle)
 		mapping := mapping // fix gosec warning regarding "Implicit memory aliasing in for loop"
-		matcher, err := NewBundleMapping(&mapping)
+		matcher, err := newBundleMapping(&mapping)
 		if err != nil {
 			logger.Error(err, "invalid BundleNamespaceMapping skipping", "mappingNamespace", mapping.Namespace, "mappingName", mapping.Name)
 			continue
diff --git a/internal/cmd/controller/target/mapping.go b/internal/cmd/controller/target/mapping.go
index 7614c739..5a4c35a4 100644
--- a/internal/cmd/controller/target/mapping.go
+++ b/internal/cmd/controller/target/mapping.go
@@ -21,7 +21,7 @@ type BundleMapping struct {
 	noMatch           bool
 }
 
-func NewBundleMapping(mapping *fleet.BundleNamespaceMapping) (*BundleMapping, error) {
+func newBundleMapping(mapping *fleet.BundleNamespaceMapping) (*BundleMapping, error) {
 	var (
 		result = &BundleMapping{
 			namespace: mapping.Namespace,
diff --git a/internal/cmd/controller/target/partition.go b/internal/cmd/controller/target/partition.go
index 395f4f2c..1c4897d9 100644
--- a/internal/cmd/controller/target/partition.go
+++ b/internal/cmd/controller/target/partition.go
@@ -1,120 +1,101 @@
 package target
 
 import (
-	"fmt"
-
-	"github.com/rancher/fleet/internal/cmd/controller/target/matcher"
 	fleet "github.com/rancher/fleet/pkg/apis/fleet.cattle.io/v1alpha1"
-	"k8s.io/apimachinery/pkg/util/intstr"
+
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 )
 
-type Partition struct {
+type partition struct {
 	Status  fleet.PartitionStatus
 	Targets []*Target
 }
 
-// Partitions distributes targets into partitions based on the rollout strategy (pure function)
-func Partitions(targets []*Target) ([]Partition, error) {
-	rollout := getRollout(targets)
-	if len(rollout.Partitions) == 0 {
-		return autoPartition(rollout, targets)
+// UpdatePartitions recomputes status, including partitions, from data in allTargets.
+// It creates Deployments in allTargets if they are missing.
+// It updates Deployments in allTargets if they are out of sync (DeploymentID != StagedDeploymentID).
+func UpdatePartitions(status *fleet.BundleStatus, allTargets []*Target) (err error) {
+	partitions, err := partitions(allTargets)
+	if err != nil {
+		return err
 	}
 
-	return manualPartition(rollout, targets)
-}
-
-// manualPartition computes a slice of Partition given some targets and rollout strategy that already has partitions (pure function)
-func manualPartition(rollout *fleet.RolloutStrategy, targets []*Target) ([]Partition, error) {
-	var (
-		partitions []Partition
-	)
-
-	for _, partitionDef := range rollout.Partitions {
-		matcher, err := matcher.NewClusterMatcher(partitionDef.ClusterName, partitionDef.ClusterGroup, partitionDef.ClusterGroupSelector, partitionDef.ClusterSelector)
-		if err != nil {
-			return nil, err
-		}
+	status.UnavailablePartitions = 0
+	status.MaxUnavailablePartitions, err = maxUnavailablePartitions(partitions, allTargets)
+	if err != nil {
+		return err
+	}
 
-		var partitionTargets []*Target
-	targetLoop:
-		for _, target := range targets {
-			for _, cg := range target.ClusterGroups {
-				if matcher.Match(target.Cluster.Name, cg.Name, cg.Labels, target.Cluster.Labels) {
-					partitionTargets = append(partitionTargets, target)
-					continue targetLoop
+	for _, partition := range partitions {
+		partition := partition // fix gosec warning regarding "Implicit memory aliasing in for loop"
+		for _, target := range partition.Targets {
+			// for a new bundledeployment, only stage the first maxNew (50) targets
+			if target.Deployment == nil && status.NewlyCreated < status.MaxNew {
+				status.NewlyCreated++
+				target.Deployment = &fleet.BundleDeployment{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:      target.Bundle.Name,
+						Namespace: target.Cluster.Status.Namespace,
+						Labels:    target.BundleDeploymentLabels(target.Cluster.Namespace, target.Cluster.Name),
+					},
 				}
 			}
+			// stage targets that have a Deployment struct
+			if target.Deployment != nil {
+				// NOTE merged options from targets.Targets() are set to be staged
+				target.Deployment.Spec.StagedOptions = target.Options
+				target.Deployment.Spec.StagedDeploymentID = target.DeploymentID
+			}
 		}
 
-		partitions, err = appendPartition(partitions, partitionDef.Name, partitionTargets, partitionDef.MaxUnavailable, rollout.MaxUnavailable)
-		if err != nil {
-			return nil, err
+		for _, currentTarget := range partition.Targets {
+			// NOTE this will propagate the staged, merged options to the current deployment
+			updateTarget(currentTarget, status, &partition.Status)
 		}
-	}
 
-	return partitions, nil
-}
-
-// appendPartition appends a new partition to partitions with partitionTargets as targets (does not mutate partitionTargets)
-func appendPartition(partitions []Partition, name string, partitionTargets []*Target, maxUnavailable ...*intstr.IntOrString) ([]Partition, error) {
-	maxUnavailableValue, err := limit(len(partitionTargets), maxUnavailable...)
-	if err != nil {
-		return nil, err
-	}
-	return append(partitions, Partition{
-		Status: fleet.PartitionStatus{
-			Name:           name,
-			Count:          len(partitionTargets),
-			MaxUnavailable: maxUnavailableValue,
-			Unavailable:    Unavailable(partitionTargets),
-			Summary:        Summary(partitionTargets),
-		},
-		Targets: partitionTargets,
-	}), nil
-}
-
-// autoPartition computes a slice of Partition given some targets and rollout strategy (pure function)
-func autoPartition(rollout *fleet.RolloutStrategy, targets []*Target) ([]Partition, error) {
-	// if auto is disabled
-	if rollout.AutoPartitionSize != nil && rollout.AutoPartitionSize.Type == intstr.Int &&
-		rollout.AutoPartitionSize.IntVal <= 0 {
-		return appendPartition(nil, "All", targets, rollout.MaxUnavailable)
-	}
+		if updateStatusUnavailable(&partition.Status, partition.Targets) {
+			status.UnavailablePartitions++
+		}
 
-	// Also disable if less than 200
-	if len(targets) < 200 {
-		return appendPartition(nil, "All", targets, rollout.MaxUnavailable)
+		if status.UnavailablePartitions > status.MaxUnavailablePartitions {
+			break
+		}
 	}
 
-	maxSize, err := limit(len(targets), rollout.AutoPartitionSize, &defAutoPartitionSize)
-	if err != nil {
-		return nil, err
+	for _, partition := range partitions {
+		status.PartitionStatus = append(status.PartitionStatus, partition.Status)
 	}
 
-	var (
-		partitions []Partition
-		offset     = 0
-	)
-
-	for {
-		if len(targets) == 0 {
-			return partitions, nil
-		}
-		end := maxSize
-		if len(targets) < maxSize {
-			end = len(targets)
-		}
+	return nil
+}
 
-		partitionTargets := targets[:end]
-		name := fmt.Sprintf("Partition %d - %d", offset, offset+end)
+// maxUnavailablePartitions returns the maximum number of unavailable partitions given the targets and partitions (pure function)
+func maxUnavailablePartitions(partitions []partition, targets []*Target) (int, error) {
+	rollout := getRollout(targets)
+	return limit(len(partitions), rollout.MaxUnavailablePartitions, &defMaxUnavailablePartitions)
+}
 
-		partitions, err = appendPartition(partitions, name, partitionTargets, rollout.MaxUnavailable)
-		if err != nil {
-			return nil, err
+// updateTarget will update DeploymentID and Options for the target to the
+// staging values, if it's in a deployable state
+func updateTarget(t *Target, status *fleet.BundleStatus, partitionStatus *fleet.PartitionStatus) {
+	if t.Deployment != nil &&
+		// Not Paused
+		!t.IsPaused() &&
+		// Has been staged
+		t.Deployment.Spec.StagedDeploymentID != "" &&
+		// Is out of sync
+		t.Deployment.Spec.DeploymentID != t.Deployment.Spec.StagedDeploymentID &&
+		// Global max unavailable not reached
+		(status.Unavailable < status.MaxUnavailable || isUnavailable(t.Deployment)) &&
+		// Partition max unavailable not reached
+		(partitionStatus.Unavailable < partitionStatus.MaxUnavailable || isUnavailable(t.Deployment)) {
+
+		if !isUnavailable(t.Deployment) {
+			// If this was previously available, now increment unavailable count. "Upgrading" is treated as unavailable.
+			status.Unavailable++
+			partitionStatus.Unavailable++
 		}
-
-		// setup next loop
-		targets = targets[end:]
-		offset += end
+		t.Deployment.Spec.DeploymentID = t.Deployment.Spec.StagedDeploymentID
+		t.Deployment.Spec.Options = t.Deployment.Spec.StagedOptions
 	}
 }
diff --git a/internal/cmd/controller/target/query.go b/internal/cmd/controller/target/query.go
index 0d8888e5..497a6254 100644
--- a/internal/cmd/controller/target/query.go
+++ b/internal/cmd/controller/target/query.go
@@ -70,7 +70,7 @@ func (m *Manager) getBundlesInScopeForCluster(ctx context.Context, cluster *flee
 
 	for _, mapping := range mappings.Items {
 		mapping := mapping
-		matcher, err := NewBundleMapping(&mapping)
+		matcher, err := newBundleMapping(&mapping)
 		if err != nil {
 			logrus.Errorf("invalid BundleNamespaceMapping %s/%s skipping: %v", mapping.Namespace, mapping.Name, err)
 			continue
diff --git a/internal/cmd/controller/target/rollout.go b/internal/cmd/controller/target/rollout.go
new file mode 100644
index 00000000..c3905b54
--- /dev/null
+++ b/internal/cmd/controller/target/rollout.go
@@ -0,0 +1,127 @@
+package target
+
+import (
+	"fmt"
+
+	"github.com/rancher/fleet/internal/cmd/controller/target/matcher"
+	fleet "github.com/rancher/fleet/pkg/apis/fleet.cattle.io/v1alpha1"
+	"k8s.io/apimachinery/pkg/util/intstr"
+)
+
+// partitions distributes targets into partitions based on the rollout strategy (pure function)
+func partitions(targets []*Target) ([]partition, error) {
+	rollout := getRollout(targets)
+	if len(rollout.Partitions) == 0 {
+		return autoPartition(rollout, targets)
+	}
+
+	return manualPartition(rollout, targets)
+}
+
+// getRollout returns the rollout strategy for the specified targets (pure function)
+func getRollout(targets []*Target) *fleet.RolloutStrategy {
+	var rollout *fleet.RolloutStrategy
+	if len(targets) > 0 {
+		rollout = targets[0].Bundle.Spec.RolloutStrategy
+	}
+	if rollout == nil {
+		rollout = &fleet.RolloutStrategy{}
+	}
+	return rollout
+}
+
+// manualPartition computes a slice of Partition given some targets and rollout strategy that already has partitions (pure function)
+func manualPartition(rollout *fleet.RolloutStrategy, targets []*Target) ([]partition, error) {
+	var (
+		partitions []partition
+	)
+
+	for _, partitionDef := range rollout.Partitions {
+		matcher, err := matcher.NewClusterMatcher(partitionDef.ClusterName, partitionDef.ClusterGroup, partitionDef.ClusterGroupSelector, partitionDef.ClusterSelector)
+		if err != nil {
+			return nil, err
+		}
+
+		var partitionTargets []*Target
+	targetLoop:
+		for _, target := range targets {
+			for _, cg := range target.ClusterGroups {
+				if matcher.Match(target.Cluster.Name, cg.Name, cg.Labels, target.Cluster.Labels) {
+					partitionTargets = append(partitionTargets, target)
+					continue targetLoop
+				}
+			}
+		}
+
+		partitions, err = appendPartition(partitions, partitionDef.Name, partitionTargets, partitionDef.MaxUnavailable, rollout.MaxUnavailable)
+		if err != nil {
+			return nil, err
+		}
+	}
+
+	return partitions, nil
+}
+
+// autoPartition computes a slice of Partition given some targets and rollout strategy (pure function)
+func autoPartition(rollout *fleet.RolloutStrategy, targets []*Target) ([]partition, error) {
+	// if auto is disabled
+	if rollout.AutoPartitionSize != nil && rollout.AutoPartitionSize.Type == intstr.Int &&
+		rollout.AutoPartitionSize.IntVal <= 0 {
+		return appendPartition(nil, "All", targets, rollout.MaxUnavailable)
+	}
+
+	// Also disable if less than 200
+	if len(targets) < 200 {
+		return appendPartition(nil, "All", targets, rollout.MaxUnavailable)
+	}
+
+	maxSize, err := limit(len(targets), rollout.AutoPartitionSize, &defAutoPartitionSize)
+	if err != nil {
+		return nil, err
+	}
+
+	var (
+		partitions []partition
+		offset     = 0
+	)
+
+	for {
+		if len(targets) == 0 {
+			return partitions, nil
+		}
+		end := maxSize
+		if len(targets) < maxSize {
+			end = len(targets)
+		}
+
+		partitionTargets := targets[:end]
+		name := fmt.Sprintf("Partition %d - %d", offset, offset+end)
+
+		partitions, err = appendPartition(partitions, name, partitionTargets, rollout.MaxUnavailable)
+		if err != nil {
+			return nil, err
+		}
+
+		// setup next loop
+		targets = targets[end:]
+		offset += end
+	}
+}
+
+// appendPartition appends a new partition to partitions with partitionTargets as targets (does not mutate partitionTargets)
+func appendPartition(partitions []partition, name string, partitionTargets []*Target, maxUnavailable ...*intstr.IntOrString) ([]partition, error) {
+	maxUnavailableValue, err := limit(len(partitionTargets), maxUnavailable...)
+	if err != nil {
+		return nil, err
+	}
+	return append(partitions, partition{
+		Status: fleet.PartitionStatus{
+			Name:           name,
+			Count:          len(partitionTargets),
+			MaxUnavailable: maxUnavailableValue,
+			Unavailable:    Unavailable(partitionTargets),
+			Summary:        Summary(partitionTargets),
+		},
+		Targets: partitionTargets,
+	}), nil
+}
diff --git a/internal/cmd/controller/target/status.go b/internal/cmd/controller/target/status.go
index a4c64100..545c4672 100644
--- a/internal/cmd/controller/target/status.go
+++ b/internal/cmd/controller/target/status.go
@@ -13,26 +13,44 @@ import (
 	"k8s.io/apimachinery/pkg/util/intstr"
 )
 
+// Summary calculates a fleet.BundleSummary from targets (pure function)
+func Summary(targets []*Target) fleet.BundleSummary {
+	var bundleSummary fleet.BundleSummary
+	for _, currentTarget := range targets {
+		cluster := currentTarget.Cluster.Namespace + "/" + currentTarget.Cluster.Name
+		summary.IncrementState(&bundleSummary, cluster, currentTarget.state(), currentTarget.message(), currentTarget.modified(), currentTarget.nonReady())
+		bundleSummary.DesiredReady++
+	}
+	return bundleSummary
+}
+
 // MaxUnavailable returns the maximum number of unavailable deployments given the targets rollout strategy (pure function)
 func MaxUnavailable(targets []*Target) (int, error) {
 	rollout := getRollout(targets)
 	return limit(len(targets), rollout.MaxUnavailable)
 }
 
-// MaxUnavailablePartitions returns the maximum number of unavailable partitions given the targets and partitions (pure function)
-func MaxUnavailablePartitions(partitions []Partition, targets []*Target) (int, error) {
-	rollout := getRollout(targets)
-	return limit(len(partitions), rollout.MaxUnavailablePartitions, &defMaxUnavailablePartitions)
+// Unavailable counts the number of targets that are not available (pure function)
+func Unavailable(targets []*Target) (count int) {
+	for _, target := range targets {
+		if target.Deployment == nil {
+			continue
+		}
+		if isUnavailable(target.Deployment) {
+			count++
+		}
+	}
+	return
 }
 
-// UpdateStatusUnavailable recomputes and sets the status.Unavailable counter and returns true if the partition
+// updateStatusUnavailable recomputes and sets the status.Unavailable counter and returns true if the partition
 // is unavailable, eg. there are more unavailable targets than the maximum set (does not mutate targets)
-func UpdateStatusUnavailable(status *fleet.PartitionStatus, targets []*Target) bool {
+func updateStatusUnavailable(status *fleet.PartitionStatus, targets []*Target) bool {
 	// Unavailable for a partition is stricter than unavailable for a target.
 	// For a partition a target must be available and update to date.
 	status.Unavailable = 0
 	for _, target := range targets {
-		if !upToDate(target) || IsUnavailable(target.Deployment) {
+		if !upToDate(target) || isUnavailable(target.Deployment) {
 			status.Unavailable++
 		}
 	}
@@ -52,21 +70,8 @@ func upToDate(target *Target) bool {
 	return true
 }
 
-// Unavailable counts the number of targets that are not available (pure function)
-func Unavailable(targets []*Target) (count int) {
-	for _, target := range targets {
-		if target.Deployment == nil {
-			continue
-		}
-		if IsUnavailable(target.Deployment) {
-			count++
-		}
-	}
-	return
-}
-
-// IsUnavailable checks if target is not available (pure function)
-func IsUnavailable(target *fleet.BundleDeployment) bool {
+// isUnavailable checks if target is not available (pure function)
+func isUnavailable(target *fleet.BundleDeployment) bool {
 	if target == nil {
 		return false
 	}
@@ -74,29 +79,6 @@ func IsUnavailable(target *fleet.BundleDeployment) bool {
 		!target.Status.Ready
 }
 
-// Summary calculates a fleet.BundleSummary from targets (pure function)
-func Summary(targets []*Target) fleet.BundleSummary {
-	var bundleSummary fleet.BundleSummary
-	for _, currentTarget := range targets {
-		cluster := currentTarget.Cluster.Namespace + "/" + currentTarget.Cluster.Name
-		summary.IncrementState(&bundleSummary, cluster, currentTarget.state(), currentTarget.message(), currentTarget.modified(), currentTarget.nonReady())
-		bundleSummary.DesiredReady++
-	}
-	return bundleSummary
-}
-
-// getRollout returns the rollout strategy for the specified targets (pure function)
-func getRollout(targets []*Target) *fleet.RolloutStrategy {
-	var rollout *fleet.RolloutStrategy
-	if len(targets) > 0 {
-		rollout = targets[0].Bundle.Spec.RolloutStrategy
-	}
-	if rollout == nil {
-		rollout = &fleet.RolloutStrategy{}
-	}
-	return rollout
-}
-
 func limit(count int, val ...*intstr.IntOrString) (int, error) {
 	if count == 0 {
 		return 1, nil
diff --git a/internal/cmd/controller/target/target.go b/internal/cmd/controller/target/target.go
index 062cb4a6..0bddf053 100644
--- a/internal/cmd/controller/target/target.go
+++ b/internal/cmd/controller/target/target.go
@@ -13,6 +13,7 @@ import (
 
 	"github.com/rancher/wrangler/v2/pkg/yaml"
 
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/util/intstr"
 )
 
@@ -45,6 +46,23 @@ type Target struct {
 	DeploymentID  string
 }
 
+// BundleDeployment returns a new bd, it discards annotations, status, etc.
+// The labels are copied from the Bundle.
+func (t *Target) BundleDeployment() *fleet.BundleDeployment {
+	bd := &fleet.BundleDeployment{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      t.Deployment.Name,
+			Namespace: t.Deployment.Namespace,
+			Labels:    t.BundleDeploymentLabels(t.Cluster.Namespace, t.Cluster.Name),
+		},
+		Spec: t.Deployment.Spec,
+	}
+	bd.Spec.Paused = t.IsPaused()
+	bd.Spec.DependsOn = t.Bundle.Spec.DependsOn
+	bd.Spec.CorrectDrift = t.Options.CorrectDrift
+	return bd
+}
+
 func (t *Target) IsPaused() bool {
 	return t.Cluster.Spec.Paused ||
 		t.Bundle.Spec.Paused
diff --git a/internal/helmdeployer/deployer.go b/internal/helmdeployer/deployer.go
index 04d4f462..f3f2505e 100644
--- a/internal/helmdeployer/deployer.go
+++ b/internal/helmdeployer/deployer.go
@@ -68,11 +68,11 @@ type DeployedBundle struct {
 }
 
 // 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 New(namespace, defaultNamespace, labelPrefix, labelSuffix string) *Helm {
+// * agentNamespace is the system namespace, which is the namespace the agent is running in, e.g. cattle-fleet-system
+func New(agentNamespace, defaultNamespace, labelPrefix, labelSuffix string) *Helm {
 	return &Helm{
+		agentNamespace:   agentNamespace,
 		defaultNamespace: defaultNamespace,
-		agentNamespace:   namespace,
 		labelPrefix:      labelPrefix,
 		labelSuffix:      labelSuffix,
 	}


Test patch:
diff --git a/integrationtests/cli/apply/apply_test.go b/integrationtests/cli/apply/apply_test.go
index 1d9e897c..db77994e 100644
--- a/integrationtests/cli/apply/apply_test.go
+++ b/integrationtests/cli/apply/apply_test.go
@@ -3,7 +3,7 @@ package apply
 import (
 	. "github.com/onsi/ginkgo/v2"
 	. "github.com/onsi/gomega"
-	"github.com/onsi/gomega/gbytes"
+
 	"github.com/rancher/fleet/integrationtests/cli"
 	"github.com/rancher/fleet/internal/cmd/cli/apply"
 )
@@ -24,13 +24,12 @@ var _ = Describe("Fleet apply", Ordered, func() {
 	When("folder contains simple resources", func() {
 		BeforeEach(func() {
 			name = "simple"
-			options = apply.Options{Output: gbytes.NewBuffer()}
 			dirs = []string{cli.AssetsPath + "simple"}
 		})
 
 		It("then a Bundle is created with all the resources and keepResources is false", func() {
 			Eventually(func() bool {
-				bundle, err := cli.GetBundleFromOutput(options.Output)
+				bundle, err := cli.GetBundleFromOutput(buf)
 				Expect(err).NotTo(HaveOccurred())
 				Expect(len(bundle.Spec.Resources)).To(Equal(2))
 				isSvcPresent, err := cli.IsResourcePresentInBundle(cli.AssetsPath+"simple/svc.yaml", bundle.Spec.Resources)
@@ -42,16 +41,16 @@ var _ = Describe("Fleet apply", Ordered, func() {
 			}).Should(BeTrue())
 		})
 	})
+
 	When("simple resources in a nested folder", func() {
 		BeforeEach(func() {
 			name = "nested_simple"
-			options = apply.Options{Output: gbytes.NewBuffer()}
 			dirs = []string{cli.AssetsPath + "nested_simple"}
 		})
 
 		It("then a Bundle is created with all the resources", func() {
 			Eventually(func() bool {
-				bundle, err := cli.GetBundleFromOutput(options.Output)
+				bundle, err := cli.GetBundleFromOutput(buf)
 				Expect(err).NotTo(HaveOccurred())
 				Expect(len(bundle.Spec.Resources)).To(Equal(3))
 				isSvcPresent, err := cli.IsResourcePresentInBundle(cli.AssetsPath+"nested_simple/simple/svc.yaml", bundle.Spec.Resources)
@@ -65,16 +64,16 @@ var _ = Describe("Fleet apply", Ordered, func() {
 			}).Should(BeTrue())
 		})
 	})
+
 	When("simple resources in a nested folder with two levels", func() {
 		BeforeEach(func() {
 			name = "nested_two_levels"
-			options = apply.Options{Output: gbytes.NewBuffer()}
 			dirs = []string{cli.AssetsPath + "nested_two_levels"}
 		})
 
 		It("then a Bundle is created with all the resources", func() {
 			Eventually(func() bool {
-				bundle, err := cli.GetBundleFromOutput(options.Output)
+				bundle, err := cli.GetBundleFromOutput(buf)
 				Expect(err).NotTo(HaveOccurred())
 				Expect(len(bundle.Spec.Resources)).To(Equal(2))
 				isSvcPresent, err := cli.IsResourcePresentInBundle(cli.AssetsPath+"nested_two_levels/nested/svc/svc.yaml", bundle.Spec.Resources)
@@ -86,16 +85,16 @@ var _ = Describe("Fleet apply", Ordered, func() {
 			}).Should(BeTrue())
 		})
 	})
+
 	When("multiple fleet.yaml in a nested folder", func() {
 		BeforeEach(func() {
 			name = "nested_multiple"
-			options = apply.Options{Output: gbytes.NewBuffer()}
 			dirs = []string{cli.AssetsPath + "nested_multiple"}
 		})
 
 		It("then 3 Bundles are created with the relevant resources", func() {
 			Eventually(func() bool {
-				bundle, err := cli.GetBundleListFromOutput(options.Output)
+				bundle, err := cli.GetBundleListFromOutput(buf)
 				Expect(err).NotTo(HaveOccurred())
 				Expect(len(bundle)).To(Equal(3))
 				deploymentA := bundle[0]
@@ -123,16 +122,16 @@ var _ = Describe("Fleet apply", Ordered, func() {
 			}).Should(BeTrue())
 		})
 	})
+
 	When("multiple fleet.yaml mixed with simple resources in a nested folder", func() {
 		BeforeEach(func() {
 			name = "nested_mixed_two_levels"
-			options = apply.Options{Output: gbytes.NewBuffer()}
 			dirs = []string{cli.AssetsPath + "nested_mixed_two_levels"}
 		})
 
 		It("then Bundles are created with all the resources", func() {
 			Eventually(func() bool {
-				bundle, err := cli.GetBundleListFromOutput(options.Output)
+				bundle, err := cli.GetBundleListFromOutput(buf)
 				Expect(err).NotTo(HaveOccurred())
 				Expect(len(bundle)).To(Equal(3))
 				root := bundle[0]
@@ -164,16 +163,16 @@ var _ = Describe("Fleet apply", Ordered, func() {
 			}).Should(BeTrue())
 		})
 	})
+
 	When("containing keepResources in the fleet.yaml", func() {
 		BeforeEach(func() {
 			name = "keep_resources"
-			options = apply.Options{Output: gbytes.NewBuffer()}
 			dirs = []string{cli.AssetsPath + "keep_resources"}
 		})
 
 		It("then a Bundle is created with keepResources", func() {
 			Eventually(func() bool {
-				bundle, err := cli.GetBundleFromOutput(options.Output)
+				bundle, err := cli.GetBundleFromOutput(buf)
 				Expect(err).NotTo(HaveOccurred())
 				return bundle.Spec.KeepResources
 			}).Should(BeTrue())
@@ -184,13 +183,12 @@ var _ = Describe("Fleet apply", Ordered, func() {
 		When("passes along enabled helm options", func() {
 			BeforeEach(func() {
 				name = "helm_options_enabled"
-				options = apply.Options{Output: gbytes.NewBuffer()}
 				dirs = []string{cli.AssetsPath + name}
 			})
 
 			It("publishes the flag in the bundle options", func() {
 				Eventually(func() bool {
-					bundle, err := cli.GetBundleFromOutput(options.Output)
+					bundle, err := cli.GetBundleFromOutput(buf)
 					Expect(err).NotTo(HaveOccurred())
 					return bundle.Spec.Helm.TakeOwnership &&
 						bundle.Spec.Helm.Atomic &&
@@ -205,13 +203,12 @@ var _ = Describe("Fleet apply", Ordered, func() {
 		When("passes along disabled helm options", func() {
 			BeforeEach(func() {
 				name = "helm_options_disabled"
-				options = apply.Options{Output: gbytes.NewBuffer()}
 				dirs = []string{cli.AssetsPath + name}
 			})
 
 			It("publishes the flag in the bundle options", func() {
 				Eventually(func() bool {
-					bundle, err := cli.GetBundleFromOutput(options.Output)
+					bundle, err := cli.GetBundleFromOutput(buf)
 					Expect(err).NotTo(HaveOccurred())
 					return bundle.Spec.Helm.TakeOwnership == false &&
 						bundle.Spec.Helm.Atomic == false &&
@@ -226,13 +223,12 @@ var _ = Describe("Fleet apply", Ordered, func() {
 		When("passes along helm options with a kustomize bundle", func() {
 			BeforeEach(func() {
 				name = "helm_options_kustomize"
-				options = apply.Options{Output: gbytes.NewBuffer()}
 				dirs = []string{cli.AssetsPath + name}
 			})
 
 			It("publishes the flag in the bundle options", func() {
 				Eventually(func() bool {
-					bundle, err := cli.GetBundleFromOutput(options.Output)
+					bundle, err := cli.GetBundleFromOutput(buf)
 					Expect(err).NotTo(HaveOccurred())
 					return bundle.Spec.Helm.TakeOwnership &&
 						bundle.Spec.Helm.ReleaseName == "kustomize"
diff --git a/integrationtests/cli/helm/repository.go b/integrationtests/cli/apply/helm_repository.go
similarity index 99%
rename from integrationtests/cli/helm/repository.go
rename to integrationtests/cli/apply/helm_repository.go
index 756f70d7..56a65360 100644
--- a/integrationtests/cli/helm/repository.go
+++ b/integrationtests/cli/apply/helm_repository.go
@@ -1,4 +1,4 @@
-package helm
+package apply
 
 import (
 	"crypto/subtle"
diff --git a/integrationtests/cli/helm/helm_test.go b/integrationtests/cli/apply/helm_test.go
similarity index 85%
rename from integrationtests/cli/helm/helm_test.go
rename to integrationtests/cli/apply/helm_test.go
index 16ab0e18..b48e15fb 100644
--- a/integrationtests/cli/helm/helm_test.go
+++ b/integrationtests/cli/apply/helm_test.go
@@ -1,4 +1,4 @@
-package helm
+package apply
 
 import (
 	"io/fs"
@@ -29,17 +29,20 @@ var _ = Describe("Fleet apply helm release", Serial, func() {
 		var repo = repository{
 			port: port,
 		}
+
 		BeforeEach(func() {
 			repo.startRepository(true)
 		})
+
 		AfterEach(func() {
 			err := repo.stopRepository()
 			Expect(err).NotTo(HaveOccurred())
 		})
+
 		When("path credentials are provided just for root folder", func() {
 			It("fleet apply fails for sub folder", func() {
 				Eventually(func() string {
-					err := fleetApply([]string{cli.AssetsPath + "helm_path_credentials"}, apply.Options{
+					err := fleetApply("helm", []string{cli.AssetsPath + "helm_path_credentials"}, apply.Options{
 						AuthByPath: map[string]bundlereader.Auth{cli.AssetsPath + "helm_path_credentials": {Username: username, Password: password}},
 					})
 					Expect(err).To(HaveOccurred())
@@ -47,10 +50,11 @@ var _ = Describe("Fleet apply helm release", Serial, func() {
 				}).Should(ContainSubstring("401"))
 			})
 		})
+
 		When("path credentials are provided for both root and sub folder", func() {
 			It("fleet apply works fine", func() {
 				Eventually(func() error {
-					return fleetApply([]string{cli.AssetsPath + "helm_path_credentials"}, apply.Options{
+					return fleetApply("helm", []string{cli.AssetsPath + "helm_path_credentials"}, apply.Options{
 						AuthByPath: map[string]bundlereader.Auth{
 							cli.AssetsPath + "helm_path_credentials":           {Username: username, Password: password},
 							cli.AssetsPath + "helm_path_credentials/subfolder": {Username: username, Password: password},
@@ -86,7 +90,7 @@ func testHelmRepo(path, port string) {
 		})
 		It("fleet apply success", func() {
 			Eventually(func() error {
-				return fleetApply([]string{cli.AssetsPath + path}, apply.Options{})
+				return fleetApply("helm", []string{cli.AssetsPath + path}, apply.Options{})
 			}).Should(Not(HaveOccurred()))
 			By("verifying Bundle is created with all the resources inside of the helm release", func() {
 				Eventually(verifyResourcesArePresent).Should(BeTrue())
@@ -100,7 +104,7 @@ func testHelmRepo(path, port string) {
 		})
 		It("fleet apply fails when no auth provided", func() {
 			Eventually(func() string {
-				err := fleetApply([]string{cli.AssetsPath + path}, apply.Options{})
+				err := fleetApply("helm", []string{cli.AssetsPath + path}, apply.Options{})
 				Expect(err).To(HaveOccurred())
 				return err.Error()
 			}).Should(ContainSubstring("401"))
@@ -113,7 +117,7 @@ func testHelmRepo(path, port string) {
 		})
 		It("fleet apply success", func() {
 			Eventually(func() error {
-				return fleetApply([]string{cli.AssetsPath + path}, apply.Options{Auth: bundlereader.Auth{Username: username, Password: password}})
+				return fleetApply("helm", []string{cli.AssetsPath + path}, apply.Options{Auth: bundlereader.Auth{Username: username, Password: password}})
 			}).Should(Not(HaveOccurred()))
 			By("verifying Bundle is created with all the resources inside of the helm release", func() {
 				Eventually(verifyResourcesArePresent).Should(BeTrue())
@@ -127,7 +131,7 @@ func testHelmRepo(path, port string) {
 		})
 		It("fleet apply success", func() {
 			Eventually(func() error {
-				return fleetApply([]string{cli.AssetsPath + path}, apply.Options{
+				return fleetApply("helm", []string{cli.AssetsPath + path}, apply.Options{
 					Auth:             bundlereader.Auth{Username: username, Password: password},
 					HelmRepoURLRegex: "http://localhost/*",
 				})
@@ -144,7 +148,7 @@ func testHelmRepo(path, port string) {
 		})
 		It("fleet apply fails when --helm-repo-url-regex doesn't match the helm repo url", func() {
 			Eventually(func() string {
-				err := fleetApply([]string{cli.AssetsPath + path}, apply.Options{
+				err := fleetApply("helm", []string{cli.AssetsPath + path}, apply.Options{
 					Auth:             bundlereader.Auth{Username: username, Password: password},
 					HelmRepoURLRegex: "nomatch",
 				})
@@ -160,7 +164,7 @@ func testHelmRepo(path, port string) {
 		})
 		It("fleet apply fails when --helm-repo-url-regex is not valid", func() {
 			Eventually(func() string {
-				err := fleetApply([]string{cli.AssetsPath + path}, apply.Options{
+				err := fleetApply("helm", []string{cli.AssetsPath + path}, apply.Options{
 					Auth:             bundlereader.Auth{Username: username, Password: password},
 					HelmRepoURLRegex: "a(b",
 				})
@@ -176,7 +180,7 @@ func testHelmRepo(path, port string) {
 		})
 		It("fleet apply uses credentials from HelmSecretNameForPaths", func() {
 			Eventually(func() error {
-				return fleetApply([]string{cli.AssetsPath + path}, apply.Options{AuthByPath: map[string]bundlereader.Auth{cli.AssetsPath + path: {Username: username, Password: password}}})
+				return fleetApply("helm", []string{cli.AssetsPath + path}, apply.Options{AuthByPath: map[string]bundlereader.Auth{cli.AssetsPath + path: {Username: username, Password: password}}})
 			}).Should(Not(HaveOccurred()))
 			By("verify Bundle is created with all the resources inside of the helm release", func() {
 				Eventually(verifyResourcesArePresent).Should(BeTrue())
@@ -190,7 +194,7 @@ func testHelmRepo(path, port string) {
 		})
 		It("fleet apply uses credentials from HelmSecretNameForPaths", func() {
 			Eventually(func() error {
-				return fleetApply([]string{cli.AssetsPath + path}, apply.Options{Auth: bundlereader.Auth{Username: "wrong", Password: "wrong"}, AuthByPath: map[string]bundlereader.Auth{cli.AssetsPath + path: {Username: username, Password: password}}})
+				return fleetApply("helm", []string{cli.AssetsPath + path}, apply.Options{Auth: bundlereader.Auth{Username: "wrong", Password: "wrong"}, AuthByPath: map[string]bundlereader.Auth{cli.AssetsPath + path: {Username: username, Password: password}}})
 			}).Should(Not(HaveOccurred()))
 			By("verify Bundle is created with all the resources inside of the helm release", func() {
 				Eventually(verifyResourcesArePresent).Should(BeTrue())
diff --git a/integrationtests/cli/apply/suite_test.go b/integrationtests/cli/apply/suite_test.go
index 5c2d2c8d..ea3ee080 100644
--- a/integrationtests/cli/apply/suite_test.go
+++ b/integrationtests/cli/apply/suite_test.go
@@ -9,15 +9,19 @@ import (
 
 	. "github.com/onsi/ginkgo/v2"
 	. "github.com/onsi/gomega"
+	"github.com/onsi/gomega/gbytes"
 )
 
-func TestFleet(t *testing.T) {
+var buf *gbytes.Buffer
+
+func TestFleetApply(t *testing.T) {
 	RegisterFailHandler(Fail)
-	RunSpecs(t, "Fleet Suite")
+	RunSpecs(t, "Fleet CLI Apply Suite")
 }
 
 // simulates fleet cli execution
 func fleetApply(name string, dirs []string, options apply.Options) error {
-
-	return apply.Apply(context.Background(), client.NewGetter("", "", "fleet-local"), name, dirs, options)
+	buf = gbytes.NewBuffer()
+	options.Output = buf
+	return apply.CreateBundles(context.Background(), client.NewGetter("", "", "fleet-local"), name, dirs, options)
 }
diff --git a/integrationtests/cli/apply/target_test.go b/integrationtests/cli/apply/targetsfile_test.go
similarity index 87%
rename from integrationtests/cli/apply/target_test.go
rename to integrationtests/cli/apply/targetsfile_test.go
index 2d780e2b..5082ff80 100644
--- a/integrationtests/cli/apply/target_test.go
+++ b/integrationtests/cli/apply/targetsfile_test.go
@@ -10,7 +10,6 @@ import (
 
 	. "github.com/onsi/ginkgo/v2"
 	. "github.com/onsi/gomega"
-	"github.com/onsi/gomega/gbytes"
 )
 
 var _ = Describe("Fleet apply targets", func() {
@@ -26,12 +25,11 @@ var _ = Describe("Fleet apply targets", func() {
 
 	When("Targets file is empty, and overrideTargets is not provided", func() {
 		BeforeEach(func() {
-			options = apply.Options{Output: gbytes.NewBuffer()}
 			dirs = []string{cli.AssetsPath + "targets/simple"}
 		})
 
 		It("Bundle contains the default target", func() {
-			bundle, err := cli.GetBundleFromOutput(options.Output)
+			bundle, err := cli.GetBundleFromOutput(buf)
 			Expect(err).NotTo(HaveOccurred())
 			Expect(len(bundle.Spec.Targets)).To(Equal(1))
 			Expect(bundle.Spec.Targets[0].ClusterGroup).To(Equal("default"))
@@ -41,12 +39,11 @@ var _ = Describe("Fleet apply targets", func() {
 
 	When("Targets file is empty, and overrideTargets is provided", func() {
 		BeforeEach(func() {
-			options = apply.Options{Output: gbytes.NewBuffer()}
 			dirs = []string{cli.AssetsPath + "targets/override"}
 		})
 
 		It("Bundle contains targets and targetRestrictions from override", func() {
-			bundle, err := cli.GetBundleFromOutput(options.Output)
+			bundle, err := cli.GetBundleFromOutput(buf)
 			Expect(err).NotTo(HaveOccurred())
 			Expect(len(bundle.Spec.Targets)).To(Equal(1))
 			Expect(bundle.Spec.Targets[0].ClusterName).To(Equal("overridden"))
@@ -65,12 +62,12 @@ var _ = Describe("Fleet apply targets", func() {
 			targets = []fleet.BundleTarget{{Name: "target1", ClusterName: "test1"}}
 			targetRestrictions = []fleet.BundleTargetRestriction{{Name: "target1", ClusterName: "test1"}}
 			file := createTargetsFile(targets, targetRestrictions)
-			options = apply.Options{Output: gbytes.NewBuffer(), TargetsFile: file.Name()}
+			options = apply.Options{TargetsFile: file.Name()}
 			dirs = []string{cli.AssetsPath + "targets/simple"}
 		})
 
 		It("Bundle contains targets and targetRestrictions from the targets file", func() {
-			bundle, err := cli.GetBundleFromOutput(options.Output)
+			bundle, err := cli.GetBundleFromOutput(buf)
 			Expect(err).NotTo(HaveOccurred())
 			Expect(bundle.Spec.Targets).To(Equal(targets))
 			Expect(bundle.Spec.TargetRestrictions).To(Equal(targetRestrictions))
@@ -87,12 +84,12 @@ var _ = Describe("Fleet apply targets", func() {
 			targets = []fleet.BundleTarget{{Name: "target1", ClusterName: "test1"}}
 			targetRestrictions = []fleet.BundleTargetRestriction{{Name: "target1", ClusterName: "test1"}}
 			file := createTargetsFile(targets, targetRestrictions)
-			options = apply.Options{Output: gbytes.NewBuffer(), TargetsFile: file.Name()}
+			options = apply.Options{TargetsFile: file.Name()}
 			dirs = []string{cli.AssetsPath + "targets/override"}
 		})
 
 		It("Bundle contains targets and targetRestrictions from override", func() {
-			bundle, err := cli.GetBundleFromOutput(options.Output)
+			bundle, err := cli.GetBundleFromOutput(buf)
 			Expect(err).NotTo(HaveOccurred())
 			Expect(bundle.Spec.Targets).To(Not(Equal(targets)))
 			Expect(bundle.Spec.TargetRestrictions).To(Not(Equal(targetRestrictions)))
diff --git a/integrationtests/cli/helm/suite_test.go b/integrationtests/cli/helm/suite_test.go
deleted file mode 100644
index 785779ff..00000000
--- a/integrationtests/cli/helm/suite_test.go
+++ /dev/null
@@ -1,28 +0,0 @@
-package helm
-
-import (
-	"context"
-	"testing"
-
-	"github.com/rancher/fleet/internal/client"
-	"github.com/rancher/fleet/internal/cmd/cli/apply"
-
-	. "github.com/onsi/ginkgo/v2"
-	. "github.com/onsi/gomega"
-	"github.com/onsi/gomega/gbytes"
-)
-
-var buf *gbytes.Buffer
-
-func TestFleet(t *testing.T) {
-	RegisterFailHandler(Fail)
-	RunSpecs(t, "Fleet Suite")
-}
-
-// simulates fleet cli execution
-func fleetApply(dirs []string, options apply.Options) error {
-	buf = gbytes.NewBuffer()
-	options.Output = buf
-
-	return apply.Apply(context.Background(), client.NewGetter("", "", "fleet-local"), "helm", dirs, options)
-}
diff --git a/internal/cmd/agent/deployer/deployer_test.go b/internal/cmd/agent/deployer/deployer_test.go
index 8d5e1c66..4e82b9d2 100644
--- a/internal/cmd/agent/deployer/deployer_test.go
+++ b/internal/cmd/agent/deployer/deployer_test.go
@@ -33,13 +33,13 @@ func TestSetNamespaceLabelsAndAnnotations(t *testing.T) {
 			ns: corev1.Namespace{
 				ObjectMeta: metav1.ObjectMeta{
 					Name:   "namespace1234",
-					Labels: map[string]string{"name": "namespace"},
+					Labels: map[string]string{"kubernetes.io/metadata.name": "namespace"},
 				},
 			},
 			release: "namespace/foo/bar",
 			expectedNs: corev1.Namespace{
 				ObjectMeta: metav1.ObjectMeta{
-					Labels:      map[string]string{"name": "namespace", "optLabel1": "optValue1", "optLabel2": "optValue2"},
+					Labels:      map[string]string{"kubernetes.io/metadata.name": "namespace", "optLabel1": "optValue1", "optLabel2": "optValue2"},
 					Annotations: map[string]string{"optAnn1": "optValue1"},
 				},
 			},
@@ -55,14 +55,14 @@ func TestSetNamespaceLabelsAndAnnotations(t *testing.T) {
 			ns: corev1.Namespace{
 				ObjectMeta: metav1.ObjectMeta{
 					Name:        "namespace1234",
-					Labels:      map[string]string{"nsLabel": "nsValue", "name": "namespace"},
+					Labels:      map[string]string{"nsLabel": "nsValue", "kubernetes.io/metadata.name": "namespace"},
 					Annotations: map[string]string{"nsAnn": "nsValue"},
 				},
 			},
 			release: "namespace/foo/bar",
 			expectedNs: corev1.Namespace{
 				ObjectMeta: metav1.ObjectMeta{
-					Labels:      map[string]string{"optLabel": "optValue", "name": "namespace"},
+					Labels:      map[string]string{"optLabel": "optValue", "kubernetes.io/metadata.name": "namespace"},
 					Annotations: map[string]string{},
 				},
 			},
@@ -78,14 +78,14 @@ func TestSetNamespaceLabelsAndAnnotations(t *testing.T) {
 			ns: corev1.Namespace{
 				ObjectMeta: metav1.ObjectMeta{
 					Name:        "namespace1234",
-					Labels:      map[string]string{"bdLabel": "nsValue", "name": "namespace"},
+					Labels:      map[string]string{"bdLabel": "nsValue", "kubernetes.io/metadata.name": "namespace"},
 					Annotations: map[string]string{"bdAnn": "nsValue"},
 				},
 			},
 			release: "namespace/foo/bar",
 			expectedNs: corev1.Namespace{
 				ObjectMeta: metav1.ObjectMeta{
-					Labels:      map[string]string{"bdLabel": "labelUpdated", "name": "namespace"},
+					Labels:      map[string]string{"bdLabel": "labelUpdated", "kubernetes.io/metadata.name": "namespace"},
 					Annotations: map[string]string{"bdAnn": "annUpdated"},
 				},
 			},
diff --git a/internal/cmd/cli/test.go b/internal/cmd/cli/test.go
index 9eb20a66..f408816a 100644
--- a/internal/cmd/cli/test.go
+++ b/internal/cmd/cli/test.go
@@ -11,8 +11,9 @@ import (
 
 func NewTest() *cobra.Command {
 	return command.Command(&Test{}, cobra.Command{
-		Args:  cobra.MaximumNArgs(1),
-		Short: "Match a bundle to a target and render the output",
+		Args:       cobra.MaximumNArgs(1),
+		Deprecated: "use target and deploy sub-commands instead.",
+		Short:      "Match a bundle to a target and render the output (deprecated)",
 	})
 }