Repository: rancher/fleet
Pull request #3480 (2025-03-25 14:53:17)

Adds driven bundle scan
This adds a new feature to let the user define the bundles to be considered by Fleet.

It adds a new field `bundles` that is a list of the pair path and config file (the later being optional). The path is the base path for all the resources contained in the Bundle and the config file is optional and it's the Bundle's fleet.yaml file (that may have any custom name).

This is an example showing the new fields:
```yaml
kind: GitRepo
apiVersion: fleet.cattle.io/v1alpha1
metadata:
  name: driven
  namespace: fleet-local
spec:
  repo: https://github.com/0xavi0/fleet-test-data
  branch: driven-scan-example
  bundles:
  - path: driven/helm
  - path: driven/simple
  - path: driven/kustomize
    options: dev.yaml
  - path: driven/kustomize
    options: test.yaml
```

Given the root driven folder with the contents:
```
driven
     |___helm
     |      |__ fleet.yaml
     |
     |___simple
     |      |__ configmap.yaml
     |      |__ service.yaml
     |
     |___kustomize
            |__ base
            |      |__ kustomization.yaml
            |      |__ secret.yaml
            |
            |__ overlays
            |         |__ dev
            |         |     |__ kustomization.yaml
            |         |     |__ secret.yaml
            |         |__ prod
            |         |     |__ kustomization.yaml
            |         |     |__ secret.yaml
            |         |__ test
            |               |__ kustomization.yaml
            |               |__ secret.yaml
            |__ dev.yaml
            |__ prod.yaml
            |__ test.yaml
```

The GitRepo above would describe the following bundles:

### Helm based bundle (path: `driven/helm`, no config file)
 This one has a `fleet.yaml` file in the root folder and we're not specifying any config file. In this case Fleet will read the `fleet.yaml` file in the root folder and will load the helm chart resources.

### Raw yaml folder based bundle (path: `driven/simple`, no config file) 
In this case the folder contains 2 yamls and no `fleet.yaml` in its root. We are not specifying any config file, so the folder is considered as a bundle made of raw yaml files. It will contain:
- configmap.yaml
- service.yaml

### Kustomize bundle (path: `driven/kustomize`, config file: `dev.yaml`)
 In this case we are specifying a config file (with the format of a `fleet.yaml` file). Fleet will load all the resources found in the `drive/kustomize` folder and will load the config found in the `dev.yaml` file.
The `dev.yaml` has the following content:
```yaml
namespace: kustomize-dev
kustomize:
  dir: "overlays/dev"
```
Which means it points to the `overlays/dev` kustomization.

This Bundle will load all the resources, but will only apply the `overlays/dev` kustomization

### Kustomize bundle (path: `driven/kustomize`, config file: `test.yaml`)
This Bundle will be equivalent to the `dev` one described above, but pointint to the `overlays/test` kustomization.

Note that the `prod` kustomization is not used in this case, which shows that not all the options defined in the kustomization must be deployed.


## Location of the config file (fleet.yaml) inside the folder

Technically we can place the config files (`fleet.yaml` format) at any subfolder inside the root folder.
There is only 1 constrain: any path defined in the config file should be taking the root of the bundle as the initial path.

For example, in the above example we are placing the `dev.yaml`, `test.yalm` and `prod.yaml` at the root of the bundle folder. And we are referencing them as: `dev.yaml` or `prod.yaml`

The content of the `dev.yaml` file is:
```
namespace: kustomize-dev
kustomize:
  dir: "overlays/dev"
```

We can move that file to the `driven/kustomize/overlays/dev` folder, but the content must be the same. ie: the dir defined must still be referenced from the base folder of the bundle (`driven/kustomize`)
If we move the file to the new folder we would reference it like this:
```yaml
  - path: driven/kustomize
    options: overlays/dev/dev.yaml
```

## Possible improvements:
As we can be loading the same folder multiple times it would be better to just load the resources once and cache them for the next repeated folder.

<!-- Specify the issue ID that this pull request is solving -->
Refers to #210 
<!-- Make sure that the referenced issue provides steps to reproduce it -->




weyfonk: My initial doubt on the naming of this feature remains: why "driven"? :thinking: 
0xavi0: > My initial doubt on the naming of this feature remains: why "driven"? 🤔

Because "fleet apply" scans on its own according to the algorithm described in the documentation. 
In this case, the user "drives" how they want the Bundles to be generated. Fleet just takes the base directory and the options file and generates the Bundle as told.

I don’t know, if the name isn’t liked... I’ll gladly accept any suggestions. 


Patch:
diff --git a/charts/fleet-crd/templates/crds.yaml b/charts/fleet-crd/templates/crds.yaml
index 45708c7c..24721d4b 100644
--- a/charts/fleet-crd/templates/crds.yaml
+++ b/charts/fleet-crd/templates/crds.yaml
@@ -6342,6 +6342,23 @@ spec:
                   description: Branch The git branch to follow.
                   nullable: true
                   type: string
+                bundles:
+                  description: 'Bundles defines the paths of bundles to be read.
+
+                    This drives the fleet resource scanner that simply loads the specified
+                    folders'
+                  items:
+                    properties:
+                      base:
+                        description: Base is the base path for the bundle resources
+                        type: string
+                      options:
+                        description: Options is the path (relative to path above)
+                          that defines a fleet.yaml file to configure the bundle
+                        nullable: true
+                        type: string
+                    type: object
+                  type: array
                 caBundle:
                   description: CABundle is a PEM encoded CA bundle which will be used
                     to validate the repo's certificate.
diff --git a/internal/cmd/cli/apply.go b/internal/cmd/cli/apply.go
index 063c67cf..c84f9173 100644
--- a/internal/cmd/cli/apply.go
+++ b/internal/cmd/cli/apply.go
@@ -66,6 +66,8 @@ type Apply struct {
 	OCIPasswordFile             string            `usage:"Path of file containing basic auth password for OCI registry" name:"oci-password-file"`
 	OCIBasicHTTP                bool              `usage:"Use HTTP to access the OCI regustry" name:"oci-basic-http"`
 	OCIInsecure                 bool              `usage:"Allow connections to OCI registry without certs" name:"oci-insecure"`
+	DrivenScan                  bool              `usage:"Use driven scan. Bundles are defined by the user" name:"driven-scan"`
+	DrivenScanSeparator         string            `usage:"Separator to use for bundle folder and options file" name:"driven-scan-sep" default:":"`
 }
 
 func (r *Apply) PersistentPre(_ *cobra.Command, _ []string) error {
@@ -123,6 +125,8 @@ func (a *Apply) run(cmd *cobra.Command, args []string) error {
 		CorrectDrift:                a.CorrectDrift,
 		CorrectDriftForce:           a.CorrectDriftForce,
 		CorrectDriftKeepFailHistory: a.CorrectDriftKeepFailHistory,
+		DrivenScan:                  a.DrivenScan,
+		DrivenScanSeparator:         a.DrivenScanSeparator,
 	}
 
 	knownHostsPath, err := writeTmpKnownHosts()
@@ -170,6 +174,9 @@ func (a *Apply) run(cmd *cobra.Command, args []string) error {
 
 	defer restoreEnv() // nolint: errcheck // best-effort
 
+	if opts.DrivenScan {
+		return apply.CreateBundlesDriven(cmd.Context(), Client, name, args, opts)
+	}
 	return apply.CreateBundles(cmd.Context(), Client, name, args, opts)
 }
 
diff --git a/internal/cmd/cli/apply/apply.go b/internal/cmd/cli/apply/apply.go
index 17a13f1d..0542955c 100644
--- a/internal/cmd/cli/apply/apply.go
+++ b/internal/cmd/cli/apply/apply.go
@@ -70,6 +70,8 @@ type Options struct {
 	CorrectDriftForce           bool
 	CorrectDriftKeepFailHistory bool
 	OCIRegistry                 OCIRegistrySpec
+	DrivenScan                  bool
+	DrivenScanSeparator         string
 }
 
 func globDirs(baseDir string) (result []string, err error) {
@@ -151,6 +153,71 @@ func CreateBundles(ctx context.Context, client Getter, repoName string, baseDirs
 	return nil
 }
 
+// CreateBundlesDriven creates bundles from the given baseDirs. Those bundles' names will be prefixed with
+// repoName. Depending on opts.Output the bundles are created in the cluster or
+// printed to stdout, ...
+// CreateBundlesDriven does not scan the given dirs recursively, it simply considers each of them
+// to be the base path for a bundle.
+// The given baseDirs may describe a simple path or a path and a fleet file,
+// separated by a character set in opts.
+// If no fleet file is provided it tries to load a fleet.yaml in the root of the dir, or will consider
+// the directory as a raw content folder.
+func CreateBundlesDriven(ctx context.Context, client Getter, repoName string, baseDirs []string, opts Options) error {
+	if len(baseDirs) == 0 {
+		baseDirs = []string{"."}
+	}
+
+	foundBundle := false
+	gitRepoBundlesMap := make(map[string]bool)
+	for _, baseDir := range baseDirs {
+		opts := opts
+		// verify if it also defines a fleetFile
+		var err error
+		baseDir, opts.BundleFile, err = getPathAndFleetYaml(baseDir, opts.DrivenScanSeparator)
+		if err != nil {
+			return err
+		}
+		if auth, ok := opts.AuthByPath[baseDir]; ok {
+			opts.Auth = auth
+		}
+		if err := Dir(ctx, client, repoName, baseDir, &opts, gitRepoBundlesMap); err == ErrNoResources {
+			logrus.Warnf("%s: %v", baseDir, err)
+			return nil
+		} else if err != nil {
+			return err
+		}
+		foundBundle = true
+	}
+
+	if opts.Output == nil {
+		err := pruneBundlesNotFoundInRepo(client, repoName, gitRepoBundlesMap)
+		if err != nil {
+			return err
+		}
+	}
+
+	if !foundBundle {
+		return fmt.Errorf("no resource found at the following paths to deploy: %v", baseDirs)
+	}
+
+	return nil
+}
+
+// getPathAndFleetYaml returns the path and options file from a given path.
+// The path and options file should be separated by the given separator
+func getPathAndFleetYaml(path, separator string) (string, string, error) {
+	baseDirFleetFile := strings.Split(path, separator)
+	if len(baseDirFleetFile) == 2 {
+		return baseDirFleetFile[0], baseDirFleetFile[1], nil
+	}
+
+	if len(baseDirFleetFile) > 2 {
+		return "", "", fmt.Errorf("invalid bundle path: %q", path)
+	}
+
+	return path, "", nil
+}
+
 // pruneBundlesNotFoundInRepo lists all bundles for this gitrepo and prunes those not found in the repo
 func pruneBundlesNotFoundInRepo(client Getter, repoName string, gitRepoBundlesMap map[string]bool) error {
 	c, err := client.Get()
@@ -217,6 +284,9 @@ func Dir(ctx context.Context, client Getter, name, baseDir string, opts *Options
 	// The bundleID is a valid helm release name, it's used as a default if a release name is not specified in helm options.
 	// It's also used to create the bundle name.
 	bundleID := filepath.Join(name, baseDir)
+	if opts.BundleFile != "" {
+		bundleID = filepath.Join(bundleID, strings.TrimSuffix(opts.BundleFile, filepath.Ext(opts.BundleFile)))
+	}
 	bundleID = names.HelmReleaseName(bundleID)
 
 	bundle, scans, err := newBundle(ctx, bundleID, baseDir, opts)
diff --git a/internal/cmd/controller/gitops/reconciler/gitjob_controller.go b/internal/cmd/controller/gitops/reconciler/gitjob_controller.go
index 48ecacc7..12241e8d 100644
--- a/internal/cmd/controller/gitops/reconciler/gitjob_controller.go
+++ b/internal/cmd/controller/gitops/reconciler/gitjob_controller.go
@@ -73,6 +73,8 @@ const (
 	gitPollingCondition        = "GitPolling"
 	generationLabel            = "fleet.cattle.io/gitrepo-generation"
 	forceSyncGenerationLabel   = "fleet.cattle.io/force-sync-generation"
+
+	bundleOptionsSeparatorChars = ":,|?<>"
 )
 
 var zero = int32(0)
@@ -722,6 +724,27 @@ func (r *GitJobReconciler) newJobSpec(ctx context.Context, gitrepo *v1alpha1.Git
 		paths = []string{"."}
 	}
 
+	drivenScanSeparator := ""
+	if len(gitrepo.Spec.Bundles) > 0 {
+		paths = []string{}
+		// use driven scan instead
+		// We calculate a separator because we will continue using the
+		// same call format for "fleet apply."
+		// The bundle definitions + options file (fleet.yaml)
+		// will be passed at the end, in the same way we pass the bundle
+		// directories for the classic fleet scan, but since we need to
+		// pass 2 strings, we will separate them with
+		// the calculated separator.
+		drivenScanSeparator = getDrivenScanSeparator(*gitrepo)
+		for _, b := range gitrepo.Spec.Bundles {
+			path := b.Base
+			if b.Options != "" {
+				path = path + drivenScanSeparator + b.Options
+			}
+			paths = append(paths, path)
+		}
+	}
+
 	// compute configmap, needed because its name contains a hash
 	configMap, err := newTargetsConfigMap(gitrepo)
 	if err != nil {
@@ -819,7 +842,7 @@ func (r *GitJobReconciler) newJobSpec(ctx context.Context, gitrepo *v1alpha1.Git
 
 	saName := names.SafeConcatName("git", gitrepo.Name)
 	logger := log.FromContext(ctx)
-	args, envs := argsAndEnvs(gitrepo, logger, CACertsFilePathOverride, r.KnownHosts)
+	args, envs := argsAndEnvs(gitrepo, logger, CACertsFilePathOverride, r.KnownHosts, drivenScanSeparator)
 
 	return &batchv1.JobSpec{
 		BackoffLimit: &zero,
@@ -880,6 +903,7 @@ func argsAndEnvs(
 	logger logr.Logger,
 	CACertsPathOverride string,
 	knownHosts KnownHostsGetter,
+	drivenScanSeparator string,
 ) ([]string, []corev1.EnvVar) {
 	args := []string{
 		"fleet",
@@ -1019,6 +1043,12 @@ func argsAndEnvs(
 			args = append(args, "--oci-insecure")
 		}
 	}
+	if len(gitrepo.Spec.Bundles) > 0 {
+		args = append(args, "--driven-scan")
+		if drivenScanSeparator != "" {
+			args = append(args, "--driven-scan-sep", drivenScanSeparator)
+		}
+	}
 
 	return append(args, "--", gitrepo.Name), env
 }
@@ -1566,3 +1596,55 @@ func gitSSHCommandEnvVar(strictChecks bool) corev1.EnvVar {
 		Value: fmt.Sprintf("ssh -o stricthostkeychecking=%s", strictVal),
 	}
 }
+
+// getDrivenScanSeparator returns a separator that is valid for all the Bundle
+// definitions in the given GitRepo.
+// Since we cannot disregard the possibility that a user might have an
+// unavoidable need to use the character ":" (or another character typically not
+// used in directory or file paths), we need to find a separator that works for any
+// possible combination.
+// The function will first search for simple characters from those in
+// bundleOptionsSeparatorChars, and if none of them can be used, it will create
+// separators consisting of random strings with incremental length until it finds one
+// that meets our requirements.
+func getDrivenScanSeparator(gitrepo v1alpha1.GitRepo) string {
+	for _, sep := range bundleOptionsSeparatorChars {
+		if !separatorInBundleDefinitions(gitrepo, string(sep)) {
+			// We can safely use this separator
+			return string(sep)
+		}
+	}
+	// we need to create a string separator because all the bytes in bundleOptionsSeparatorChars are
+	// used in the bundle definitions paths.
+	// Get a random combination of increasing length until it is not found in the bundles definitions.
+	length := 2
+	sep := getRandomStringSeparator(length)
+	for separatorInBundleDefinitions(gitrepo, sep) {
+		length++
+		sep = getRandomStringSeparator(length)
+	}
+
+	return sep
+}
+
+func separatorInBundleDefinitions(gitrepo v1alpha1.GitRepo, sep string) bool {
+	for _, b := range gitrepo.Spec.Bundles {
+		if strings.Contains(b.Options, sep) {
+			return true
+		}
+
+		if strings.Contains(b.Base, sep) {
+			return true
+		}
+	}
+
+	return false
+}
+
+func getRandomStringSeparator(length int) string {
+	b := make([]byte, length)
+	for i := range b {
+		b[i] = bundleOptionsSeparatorChars[rand.IntN(len(bundleOptionsSeparatorChars))]
+	}
+	return string(b)
+}
diff --git a/pkg/apis/fleet.cattle.io/v1alpha1/gitrepo_types.go b/pkg/apis/fleet.cattle.io/v1alpha1/gitrepo_types.go
index c322ed83..41098a35 100644
--- a/pkg/apis/fleet.cattle.io/v1alpha1/gitrepo_types.go
+++ b/pkg/apis/fleet.cattle.io/v1alpha1/gitrepo_types.go
@@ -140,6 +140,18 @@ type GitRepoSpec struct {
 
 	// WebhookSecret contains the name of the secret to use for webhook parsing
 	WebhookSecret string `json:"webhookSecret,omitempty"`
+
+	// Bundles defines the paths of bundles to be read.
+	// This drives the fleet resource scanner that simply loads the specified folders
+	Bundles []BundlePath `json:"bundles,omitempty"`
+}
+
+type BundlePath struct {
+	// Base is the base path for the bundle resources
+	Base string `json:"base,omitempty"`
+	// Options is the path (relative to path above) that defines a fleet.yaml file to configure the bundle
+	// +nullable
+	Options string `json:"options,omitempty"`
 }
 
 // GitTarget is a cluster or cluster group to deploy to.
diff --git a/pkg/apis/fleet.cattle.io/v1alpha1/zz_generated.deepcopy.go b/pkg/apis/fleet.cattle.io/v1alpha1/zz_generated.deepcopy.go
index 6ebb58f4..92164e3e 100644
--- a/pkg/apis/fleet.cattle.io/v1alpha1/zz_generated.deepcopy.go
+++ b/pkg/apis/fleet.cattle.io/v1alpha1/zz_generated.deepcopy.go
@@ -440,6 +440,21 @@ func (in *BundleNamespaceMappingList) DeepCopyObject() runtime.Object {
 	return nil
 }
 
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *BundlePath) DeepCopyInto(out *BundlePath) {
+	*out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BundlePath.
+func (in *BundlePath) DeepCopy() *BundlePath {
+	if in == nil {
+		return nil
+	}
+	out := new(BundlePath)
+	in.DeepCopyInto(out)
+	return out
+}
+
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *BundleRef) DeepCopyInto(out *BundleRef) {
 	*out = *in
@@ -1525,6 +1540,11 @@ func (in *GitRepoSpec) DeepCopyInto(out *GitRepoSpec) {
 		*out = new(OCIRegistrySpec)
 		**out = **in
 	}
+	if in.Bundles != nil {
+		in, out := &in.Bundles, &out.Bundles
+		*out = make([]BundlePath, len(*in))
+		copy(*out, *in)
+	}
 }
 
 // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GitRepoSpec.


Test patch:
diff --git a/e2e/assets/single-cluster/driven.yaml b/e2e/assets/single-cluster/driven.yaml
new file mode 100644
index 00000000..7c41b3bc
--- /dev/null
+++ b/e2e/assets/single-cluster/driven.yaml
@@ -0,0 +1,14 @@
+kind: GitRepo
+apiVersion: fleet.cattle.io/v1alpha1
+metadata:
+  name: driven
+  namespace: fleet-local
+spec:
+  repo: https://github.com/rancher/fleet-test-data
+  bundles:
+  - base: driven/helm
+  - base: driven/simple
+  - base: driven/kustomize
+    options: dev.yaml
+  - base: driven/kustomize
+    options: test.yaml
diff --git a/e2e/single-cluster/single_cluster_test.go b/e2e/single-cluster/single_cluster_test.go
index f1d0d801..dfbcc530 100644
--- a/e2e/single-cluster/single_cluster_test.go
+++ b/e2e/single-cluster/single_cluster_test.go
@@ -2,6 +2,7 @@ package singlecluster_test
 
 import (
 	"strings"
+	"time"
 
 	"github.com/rancher/fleet/e2e/testenv"
 	"github.com/rancher/fleet/e2e/testenv/kubectl"
@@ -135,5 +136,135 @@ var _ = Describe("Single Cluster Deployments", func() {
 				}).Should(ContainSubstring("mp-app-service"))
 			})
 		})
+
+		Context("containing a gitrepo that defines bundles explicitly", func() {
+			BeforeEach(func() {
+				asset = "single-cluster/driven.yaml"
+			})
+
+			AfterEach(func() {
+				_, _ = k.Delete("ns", "fleet-helm-oci-example")
+				_, _ = k.Delete("ns", "fleet-kustomize-example")
+				_, _ = k.Delete("ns", "kustomize-dev")
+				_, _ = k.Delete("ns", "kustomize-test")
+			})
+
+			It("deploys 4 bundles as expected", func() {
+				Eventually(func() string {
+					out, _ := k.Namespace("fleet-local").Get("bundles")
+					return out
+				}).Should(SatisfyAll(
+					ContainSubstring("driven-driven-helm"),
+					ContainSubstring("driven-driven-simple"),
+					ContainSubstring("driven-driven-kustomize-dev"),
+					ContainSubstring("driven-driven-kustomize-test"),
+				))
+
+				Eventually(func() bool {
+					out, err := k.Get("gitrepo", "driven", "-n", "fleet-local", "-o", "jsonpath='{.status.summary}'")
+					Expect(err).ToNot(HaveOccurred(), out)
+					return strings.Contains(out, "\"ready\":4")
+				}).Should(BeTrue())
+
+				// check that all bundles are ready
+				bundle1Ready(k, "driven-driven-helm")
+				bundle1Ready(k, "driven-driven-simple")
+				bundle1Ready(k, "driven-driven-kustomize-dev")
+				bundle1Ready(k, "driven-driven-kustomize-test")
+
+				Eventually(func() string {
+					out, _ := k.Get("bundledeployments", "-A")
+					return out
+				}).Should(SatisfyAll(
+					ContainSubstring("driven-driven-helm"),
+					ContainSubstring("driven-driven-simple"),
+					ContainSubstring("driven-driven-kustomize-dev"),
+					ContainSubstring("driven-driven-kustomize-test"),
+				))
+
+				// check for the resources of the simple bundle
+				// it deploys a config map and a service
+				Eventually(func(g Gomega) {
+					out, err := k.Get(
+						"service",
+						"simple-service",
+						"-n",
+						"default",
+						"-o",
+						"jsonpath='{.spec.type}'",
+					)
+					g.Expect(err).ToNot(HaveOccurred(), out)
+					g.Expect(out).To(ContainSubstring("ClusterIP"))
+
+					out, err = k.Get(
+						"cm",
+						"simple-config",
+						"-n",
+						"default",
+						"-o",
+						"jsonpath='{.data.test}'",
+					)
+					g.Expect(err).ToNot(HaveOccurred(), out)
+					g.Expect(out).To(ContainSubstring("value"))
+				}).Should(Succeed())
+
+				// helm bundle deploys a config map
+				Eventually(func() string {
+					out, _ := k.Namespace("fleet-helm-oci-example").Get("configmaps")
+					return out
+				}).Should(ContainSubstring("fleet-test-configmap"))
+
+				// kustomize dev deploys a configmap, which should also have the -dev suffix in its name
+				Eventually(func(g Gomega) {
+					out, err := k.Get(
+						"configmap",
+						"superconfigmap-dev",
+						"-n",
+						"kustomize-dev",
+						"-o",
+						"jsonpath='{.data.test}'",
+					)
+					g.Expect(err).ToNot(HaveOccurred(), out)
+					g.Expect(out).To(ContainSubstring("value_dev"))
+				}).Should(Succeed())
+
+				// kustomize test deploys a configmap, which should also have the -test suffix in its name
+				Eventually(func(g Gomega) {
+					out, err := k.Get(
+						"configmap",
+						"superconfigmap-test",
+						"-n",
+						"kustomize-test",
+						"-o",
+						"jsonpath='{.data.test}'",
+					)
+					g.Expect(err).ToNot(HaveOccurred(), out)
+					g.Expect(out).To(ContainSubstring("value_test"))
+				}).Should(Succeed())
+
+				// finally, the kustomize-prod namespace should not be created, as the specific fleet yaml
+				// was not specified
+				Consistently(func(g Gomega) {
+					out, _ := k.Get("ns")
+					g.Expect(out).ToNot(ContainSubstring("kustomize-prod"))
+				}, 5*time.Second, 1*time.Second).Should(Succeed())
+			})
+		})
 	})
 })
+
+func bundle1Ready(k kubectl.Command, name string) {
+	Eventually(func(g Gomega) {
+		out, err := k.Get(
+			"bundle",
+			name,
+			"-n",
+			"fleet-local",
+			"-o",
+			"jsonpath='{.status.summary}'",
+		)
+		g.Expect(err).ToNot(HaveOccurred(), out)
+
+		g.Expect(out).To(ContainSubstring(`"ready":1`))
+	}).Should(Succeed())
+}
diff --git a/integrationtests/cli/apply/apply_test.go b/integrationtests/cli/apply/apply_test.go
index 680fe16b..66a0a86a 100644
--- a/integrationtests/cli/apply/apply_test.go
+++ b/integrationtests/cli/apply/apply_test.go
@@ -14,6 +14,7 @@ import (
 	cp "github.com/otiai10/copy"
 
 	"github.com/rancher/fleet/integrationtests/cli"
+	"github.com/rancher/fleet/internal/bundlereader"
 	"github.com/rancher/fleet/internal/cmd/cli/apply"
 	"github.com/rancher/fleet/pkg/apis/fleet.cattle.io/v1alpha1"
 )
@@ -194,6 +195,227 @@ var _ = Describe("Fleet apply", Ordered, func() {
 	})
 })
 
+var _ = Describe("Fleet apply driven", Ordered, func() {
+
+	var (
+		dirs    []string
+		name    string
+		options apply.Options
+		repo    = repository{
+			port: port,
+		}
+	)
+
+	JustBeforeEach(func() {
+		err := fleetApplyDriven(name, dirs, options)
+		Expect(err).NotTo(HaveOccurred())
+	})
+
+	When("using driven scan with multiple paths and options", func() {
+		BeforeEach(func() {
+			name = "multiple_paths"
+			dirs = []string{
+				cli.AssetsPath + "driven/helm",
+				cli.AssetsPath + "driven/simple",
+				cli.AssetsPath + "driven/kustomize" + ":dev.yaml",
+				cli.AssetsPath + "driven/kustomize" + ":prod.yaml",
+			}
+			// set credentials to download helm chart for mock repository
+			options.AuthByPath = map[string]bundlereader.Auth{
+				cli.AssetsPath + "driven/helm": {Username: username, Password: password},
+			}
+			repo.startRepository(true)
+		})
+
+		AfterEach(func() {
+			err := repo.stopRepository()
+			Expect(err).NotTo(HaveOccurred())
+		})
+
+		It("creates the expected 4 bundles of different types", func() {
+			bundles, err := cli.GetBundleListFromOutput(buf)
+			Expect(err).NotTo(HaveOccurred())
+			Expect(bundles).To(HaveLen(4))
+
+			// helm bundle
+			helmBundle := bundles[0]
+			Expect(helmBundle.Name).To(Equal("assets-driven-helm"))
+			Expect(helmBundle.Spec.Resources).To(HaveLen(3))
+			// as files were unpacked from the downloaded chart we can't just
+			// list the files in the original folder and compare.
+			// Files are only located in the bundle resources
+			Expect("Chart.yaml").To(bePresentOnlyInBundleResources(helmBundle.Spec.Resources))
+			Expect("values.yaml").To(bePresentOnlyInBundleResources(helmBundle.Spec.Resources))
+			Expect("templates/configmap.yaml").To(bePresentOnlyInBundleResources(helmBundle.Spec.Resources))
+			// check for helm options defined in the fleet.yaml file
+			Expect(helmBundle.Spec.Helm).ToNot(BeNil())
+			Expect(helmBundle.Spec.Helm.ReleaseName).To(Equal("config-chart"))
+			Expect(helmBundle.Spec.Helm.Chart).To(Equal("http://localhost:3000/config-chart-0.1.0.tgz"))
+
+			// simple bundle
+			simpleBundle := bundles[1]
+			Expect(simpleBundle.Name).To(Equal("assets-driven-simple"))
+			Expect(simpleBundle.Spec.Resources).To(HaveLen(2))
+			expectedResources := []string{
+				cli.AssetsPath + "driven/simple/deployment.yaml",
+				cli.AssetsPath + "driven/simple/svc.yaml",
+			}
+			for _, r := range expectedResources {
+				Expect(r).To(bePresentInBundleResources(simpleBundle.Spec.Resources))
+			}
+
+			// kustomize dev bundle
+			kDevBundle := bundles[2]
+			Expect(kDevBundle.Name).To(Equal("assets-driven-kustomize-dev"))
+
+			// kustomize prod bundle
+			kProdBundle := bundles[3]
+			Expect(kProdBundle.Name).To(Equal("assets-driven-kustomize-prod"))
+
+			// both kustomize bundles have the same resources, but different config fleet.yaml
+			kResources := []string{
+				cli.AssetsPath + "driven/kustomize/base/kustomization.yaml",
+				cli.AssetsPath + "driven/kustomize/base/secret.yaml",
+				cli.AssetsPath + "driven/kustomize/overlays/dev/kustomization.yaml",
+				cli.AssetsPath + "driven/kustomize/overlays/dev/secret.yaml",
+				cli.AssetsPath + "driven/kustomize/overlays/prod/kustomization.yaml",
+				cli.AssetsPath + "driven/kustomize/overlays/prod/secret.yaml",
+				cli.AssetsPath + "driven/kustomize/dev.yaml",
+				cli.AssetsPath + "driven/kustomize/prod.yaml",
+			}
+			Expect(kDevBundle.Spec.Resources).To(HaveLen(8))
+			Expect(kProdBundle.Spec.Resources).To(HaveLen(8))
+			for _, r := range kResources {
+				Expect(r).To(bePresentInBundleResources(kDevBundle.Spec.Resources))
+				Expect(r).To(bePresentInBundleResources(kProdBundle.Spec.Resources))
+			}
+
+			// they should have different Kustomize options
+			Expect(kDevBundle.Spec.Kustomize).ToNot(BeNil())
+			Expect(kProdBundle.Spec.Kustomize).ToNot(BeNil())
+			Expect(kDevBundle.Spec.Kustomize.Dir).To(Equal("overlays/dev"))
+			Expect(kProdBundle.Spec.Kustomize.Dir).To(Equal("overlays/prod"))
+		})
+	})
+
+	When("deploying folder with fleet files in camelCase", func() {
+		BeforeEach(func() {
+			name = "camelCase_config_filenames"
+			dirs = []string{
+				cli.AssetsPath + "driven2/kustomize" + ":fleetDev.yaml",
+				cli.AssetsPath + "driven2/kustomize" + ":fleetProd.yaml",
+			}
+		})
+
+		It("creates the bundles with names in lower case and having a suffix", func() {
+			bundles, err := cli.GetBundleListFromOutput(buf)
+			Expect(err).NotTo(HaveOccurred())
+			Expect(bundles).To(HaveLen(2))
+
+			// kustomize dev bundle
+			kDevBundle := bundles[0]
+			Expect(kDevBundle.Name).To(Equal("assets-driven2-kustomize-fleetdev-2946f474"))
+
+			// kustomize prod bundle
+			kProdBundle := bundles[1]
+			Expect(kProdBundle.Name).To(Equal("assets-driven2-kustomize-fleetprod-99f597b0"))
+
+			// both kustomize bundles have the same resources, but different config fleet.yaml
+			kResources := []string{
+				cli.AssetsPath + "driven2/kustomize/base/kustomization.yaml",
+				cli.AssetsPath + "driven2/kustomize/base/secret.yaml",
+				cli.AssetsPath + "driven2/kustomize/overlays/dev/kustomization.yaml",
+				cli.AssetsPath + "driven2/kustomize/overlays/dev/secret.yaml",
+				cli.AssetsPath + "driven2/kustomize/overlays/prod/kustomization.yaml",
+				cli.AssetsPath + "driven2/kustomize/overlays/prod/secret.yaml",
+				cli.AssetsPath + "driven2/kustomize/fleetDev.yaml",
+				cli.AssetsPath + "driven2/kustomize/fleetProd.yaml",
+			}
+			Expect(kDevBundle.Spec.Resources).To(HaveLen(8))
+			Expect(kProdBundle.Spec.Resources).To(HaveLen(8))
+			for _, r := range kResources {
+				Expect(r).To(bePresentInBundleResources(kDevBundle.Spec.Resources))
+				Expect(r).To(bePresentInBundleResources(kProdBundle.Spec.Resources))
+			}
+
+			// they should have different Kustomize options
+			Expect(kDevBundle.Spec.Kustomize).ToNot(BeNil())
+			Expect(kProdBundle.Spec.Kustomize).ToNot(BeNil())
+			Expect(kDevBundle.Spec.Kustomize.Dir).To(Equal("overlays/dev"))
+			Expect(kProdBundle.Spec.Kustomize.Dir).To(Equal("overlays/prod"))
+		})
+	})
+
+	When("deploying folder with fleet file in a subfolder and no specified config path", func() {
+		BeforeEach(func() {
+			name = "single_path_no_config_specified"
+			dirs = []string{
+				cli.AssetsPath + "driven_fleet_yaml_subfolder/helm",
+			}
+		})
+
+		It("creates 1 raw bundle, that has the fleet.yaml only and no helm options", func() {
+			bundles, err := cli.GetBundleListFromOutput(buf)
+			Expect(err).NotTo(HaveOccurred())
+			Expect(bundles).To(HaveLen(1))
+
+			// kustomize dev bundle
+			b := bundles[0]
+
+			// name will be truncated
+			Expect(b.Name).To(Equal("assets-driven-fleet-yaml-subfolder-helm-f49dc35c"))
+			Expect(b.Spec.Resources).To(HaveLen(1))
+			resPath := cli.AssetsPath + "driven_fleet_yaml_subfolder/helm/test/fleet.yaml"
+			Expect(resPath).To(bePresentInBundleResources(b.Spec.Resources))
+
+			// as the fleet.yaml was not parsed as a fleet.yaml the helm options are not set
+			Expect(b.Spec.Helm).To(BeNil())
+		})
+	})
+
+	When("deploying folder with fleet file in a subfolder and specifying the path", func() {
+		BeforeEach(func() {
+			name = "single_path_config_specified"
+			dirs = []string{
+				cli.AssetsPath + "driven_fleet_yaml_subfolder/helm:test/fleet.yaml",
+			}
+			// set credentials to download helm chart for mock repository
+			options.AuthByPath = map[string]bundlereader.Auth{
+				cli.AssetsPath + "driven_fleet_yaml_subfolder/helm": {Username: username, Password: password},
+			}
+			repo.startRepository(true)
+		})
+
+		AfterEach(func() {
+			err := repo.stopRepository()
+			Expect(err).NotTo(HaveOccurred())
+		})
+
+		It("creates 1 raw bundle, that has chart resources and has helm options", func() {
+			bundles, err := cli.GetBundleListFromOutput(buf)
+			Expect(err).NotTo(HaveOccurred())
+			Expect(bundles).To(HaveLen(1))
+
+			// helm bundle
+			helmBundle := bundles[0]
+			Expect(helmBundle.Name).To(Equal("assets-driven-fleet-yaml-subfolder-helm-test-fl-b676f"))
+			Expect(helmBundle.Spec.Resources).To(HaveLen(4))
+			// as files were unpacked from the downloaded chart we can't just
+			// list the files in the original folder and compare.
+			// Files are only located in the bundle resources
+			Expect("Chart.yaml").To(bePresentOnlyInBundleResources(helmBundle.Spec.Resources))
+			Expect("values.yaml").To(bePresentOnlyInBundleResources(helmBundle.Spec.Resources))
+			Expect("templates/configmap.yaml").To(bePresentOnlyInBundleResources(helmBundle.Spec.Resources))
+			resPath := cli.AssetsPath + "driven_fleet_yaml_subfolder/helm/test/fleet.yaml"
+			Expect(resPath).To(bePresentInBundleResources(helmBundle.Spec.Resources))
+			// check for helm options defined in the fleet.yaml file
+			Expect(helmBundle.Spec.Helm).ToNot(BeNil())
+			Expect(helmBundle.Spec.Helm.ReleaseName).To(Equal("config-chart"))
+			Expect(helmBundle.Spec.Helm.Chart).To(Equal("http://localhost:3000/config-chart-0.1.0.tgz"))
+		})
+	})
+})
+
 var _ = Describe("Fleet apply with helm charts with dependencies", Ordered, func() {
 
 	var (
diff --git a/integrationtests/cli/apply/suite_test.go b/integrationtests/cli/apply/suite_test.go
index f15e8d21..55656a80 100644
--- a/integrationtests/cli/apply/suite_test.go
+++ b/integrationtests/cli/apply/suite_test.go
@@ -26,6 +26,15 @@ func fleetApply(name string, dirs []string, options apply.Options) error {
 	return apply.CreateBundles(context.Background(), client.NewGetter("", "", "fleet-local"), name, dirs, options)
 }
 
+// simulates fleet cli execution in driven mode
+func fleetApplyDriven(name string, dirs []string, options apply.Options) error {
+	buf = gbytes.NewBuffer()
+	options.DrivenScan = true
+	options.Output = buf
+	options.DrivenScanSeparator = ":"
+	return apply.CreateBundlesDriven(context.Background(), client.NewGetter("", "", "fleet-local"), name, dirs, options)
+}
+
 // simulates fleet cli online execution, with mocked client
 func fleetApplyOnline(getter apply.Getter, name string, dirs []string, options apply.Options) error {
 	return apply.CreateBundles(context.Background(), getter, name, dirs, options)
diff --git a/integrationtests/cli/assets/driven/helm/fleet.yaml b/integrationtests/cli/assets/driven/helm/fleet.yaml
new file mode 100644
index 00000000..1bd51c88
--- /dev/null
+++ b/integrationtests/cli/assets/driven/helm/fleet.yaml
@@ -0,0 +1,3 @@
+helm:
+  releaseName: config-chart
+  chart: http://localhost:3000/config-chart-0.1.0.tgz
diff --git a/integrationtests/cli/assets/driven/kustomize/base/kustomization.yaml b/integrationtests/cli/assets/driven/kustomize/base/kustomization.yaml
new file mode 100644
index 00000000..2be662f7
--- /dev/null
+++ b/integrationtests/cli/assets/driven/kustomize/base/kustomization.yaml
@@ -0,0 +1,2 @@
+resources:
+- secret.yaml
\ No newline at end of file
diff --git a/integrationtests/cli/assets/driven/kustomize/base/secret.yaml b/integrationtests/cli/assets/driven/kustomize/base/secret.yaml
new file mode 100644
index 00000000..4d436079
--- /dev/null
+++ b/integrationtests/cli/assets/driven/kustomize/base/secret.yaml
@@ -0,0 +1,8 @@
+apiVersion: v1
+kind: Secret
+metadata:
+  name: supersecret
+type: Opaque
+data:
+  username: YmxhaA==
+  password: YmxhaA==
\ No newline at end of file
diff --git a/integrationtests/cli/assets/driven/kustomize/dev.yaml b/integrationtests/cli/assets/driven/kustomize/dev.yaml
new file mode 100644
index 00000000..5a9bb324
--- /dev/null
+++ b/integrationtests/cli/assets/driven/kustomize/dev.yaml
@@ -0,0 +1,3 @@
+namespace: fleet-kustomize-example
+kustomize:
+  dir: "overlays/dev"
\ No newline at end of file
diff --git a/integrationtests/cli/assets/driven/kustomize/overlays/dev/kustomization.yaml b/integrationtests/cli/assets/driven/kustomize/overlays/dev/kustomization.yaml
new file mode 100644
index 00000000..aa2b0002
--- /dev/null
+++ b/integrationtests/cli/assets/driven/kustomize/overlays/dev/kustomization.yaml
@@ -0,0 +1,5 @@
+nameSuffix: "-dev"
+resources:
+  - ../../base
+patchesStrategicMerge:
+  - secret.yaml
\ No newline at end of file
diff --git a/integrationtests/cli/assets/driven/kustomize/overlays/dev/secret.yaml b/integrationtests/cli/assets/driven/kustomize/overlays/dev/secret.yaml
new file mode 100644
index 00000000..e870eb9a
--- /dev/null
+++ b/integrationtests/cli/assets/driven/kustomize/overlays/dev/secret.yaml
@@ -0,0 +1,7 @@
+apiVersion: v1
+kind: Secret
+metadata:
+  name: supersecret
+data:
+  username: YmxhaDE=
+  password: YmxhaDE=
\ No newline at end of file
diff --git a/integrationtests/cli/assets/driven/kustomize/overlays/prod/kustomization.yaml b/integrationtests/cli/assets/driven/kustomize/overlays/prod/kustomization.yaml
new file mode 100644
index 00000000..ab2fda41
--- /dev/null
+++ b/integrationtests/cli/assets/driven/kustomize/overlays/prod/kustomization.yaml
@@ -0,0 +1,5 @@
+nameSuffix: "-prod"
+resources:
+  - ../../base
+patchesStrategicMerge:
+  - secret.yaml
\ No newline at end of file
diff --git a/integrationtests/cli/assets/driven/kustomize/overlays/prod/secret.yaml b/integrationtests/cli/assets/driven/kustomize/overlays/prod/secret.yaml
new file mode 100644
index 00000000..cd3fd323
--- /dev/null
+++ b/integrationtests/cli/assets/driven/kustomize/overlays/prod/secret.yaml
@@ -0,0 +1,7 @@
+apiVersion: v1
+kind: Secret
+metadata:
+  name: supersecret
+data:
+  username: YmxhaDI=
+  password: YmxhaDI=
\ No newline at end of file
diff --git a/integrationtests/cli/assets/driven/kustomize/prod.yaml b/integrationtests/cli/assets/driven/kustomize/prod.yaml
new file mode 100644
index 00000000..bfdb65cc
--- /dev/null
+++ b/integrationtests/cli/assets/driven/kustomize/prod.yaml
@@ -0,0 +1,3 @@
+namespace: fleet-kustomize-example
+kustomize:
+  dir: "overlays/prod"
\ No newline at end of file
diff --git a/integrationtests/cli/assets/driven/simple/deployment.yaml b/integrationtests/cli/assets/driven/simple/deployment.yaml
new file mode 100644
index 00000000..c295e5f9
--- /dev/null
+++ b/integrationtests/cli/assets/driven/simple/deployment.yaml
@@ -0,0 +1,21 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: nginx-deployment
+  labels:
+    app: nginx
+spec:
+  replicas: 1
+  selector:
+    matchLabels:
+      app: nginx
+  template:
+    metadata:
+      labels:
+        app: nginx
+    spec:
+      containers:
+      - name: nginx
+        image: nginx:1.14.2
+        ports:
+        - containerPort: 80
diff --git a/integrationtests/cli/assets/driven/simple/svc.yaml b/integrationtests/cli/assets/driven/simple/svc.yaml
new file mode 100644
index 00000000..bffb2fe4
--- /dev/null
+++ b/integrationtests/cli/assets/driven/simple/svc.yaml
@@ -0,0 +1,12 @@
+apiVersion: v1
+kind: Service
+metadata:
+  name: nginx-service
+spec:
+  selector:
+    app: nginx
+  ports:
+  - name: name-of-service-port
+    protocol: TCP
+    port: 80
+    targetPort: http-web-svc
diff --git a/integrationtests/cli/assets/driven2/kustomize/base/kustomization.yaml b/integrationtests/cli/assets/driven2/kustomize/base/kustomization.yaml
new file mode 100644
index 00000000..2be662f7
--- /dev/null
+++ b/integrationtests/cli/assets/driven2/kustomize/base/kustomization.yaml
@@ -0,0 +1,2 @@
+resources:
+- secret.yaml
\ No newline at end of file
diff --git a/integrationtests/cli/assets/driven2/kustomize/base/secret.yaml b/integrationtests/cli/assets/driven2/kustomize/base/secret.yaml
new file mode 100644
index 00000000..4d436079
--- /dev/null
+++ b/integrationtests/cli/assets/driven2/kustomize/base/secret.yaml
@@ -0,0 +1,8 @@
+apiVersion: v1
+kind: Secret
+metadata:
+  name: supersecret
+type: Opaque
+data:
+  username: YmxhaA==
+  password: YmxhaA==
\ No newline at end of file
diff --git a/integrationtests/cli/assets/driven2/kustomize/fleetDev.yaml b/integrationtests/cli/assets/driven2/kustomize/fleetDev.yaml
new file mode 100644
index 00000000..5a9bb324
--- /dev/null
+++ b/integrationtests/cli/assets/driven2/kustomize/fleetDev.yaml
@@ -0,0 +1,3 @@
+namespace: fleet-kustomize-example
+kustomize:
+  dir: "overlays/dev"
\ No newline at end of file
diff --git a/integrationtests/cli/assets/driven2/kustomize/fleetProd.yaml b/integrationtests/cli/assets/driven2/kustomize/fleetProd.yaml
new file mode 100644
index 00000000..bfdb65cc
--- /dev/null
+++ b/integrationtests/cli/assets/driven2/kustomize/fleetProd.yaml
@@ -0,0 +1,3 @@
+namespace: fleet-kustomize-example
+kustomize:
+  dir: "overlays/prod"
\ No newline at end of file
diff --git a/integrationtests/cli/assets/driven2/kustomize/overlays/dev/kustomization.yaml b/integrationtests/cli/assets/driven2/kustomize/overlays/dev/kustomization.yaml
new file mode 100644
index 00000000..aa2b0002
--- /dev/null
+++ b/integrationtests/cli/assets/driven2/kustomize/overlays/dev/kustomization.yaml
@@ -0,0 +1,5 @@
+nameSuffix: "-dev"
+resources:
+  - ../../base
+patchesStrategicMerge:
+  - secret.yaml
\ No newline at end of file
diff --git a/integrationtests/cli/assets/driven2/kustomize/overlays/dev/secret.yaml b/integrationtests/cli/assets/driven2/kustomize/overlays/dev/secret.yaml
new file mode 100644
index 00000000..e870eb9a
--- /dev/null
+++ b/integrationtests/cli/assets/driven2/kustomize/overlays/dev/secret.yaml
@@ -0,0 +1,7 @@
+apiVersion: v1
+kind: Secret
+metadata:
+  name: supersecret
+data:
+  username: YmxhaDE=
+  password: YmxhaDE=
\ No newline at end of file
diff --git a/integrationtests/cli/assets/driven2/kustomize/overlays/prod/kustomization.yaml b/integrationtests/cli/assets/driven2/kustomize/overlays/prod/kustomization.yaml
new file mode 100644
index 00000000..ab2fda41
--- /dev/null
+++ b/integrationtests/cli/assets/driven2/kustomize/overlays/prod/kustomization.yaml
@@ -0,0 +1,5 @@
+nameSuffix: "-prod"
+resources:
+  - ../../base
+patchesStrategicMerge:
+  - secret.yaml
\ No newline at end of file
diff --git a/integrationtests/cli/assets/driven2/kustomize/overlays/prod/secret.yaml b/integrationtests/cli/assets/driven2/kustomize/overlays/prod/secret.yaml
new file mode 100644
index 00000000..cd3fd323
--- /dev/null
+++ b/integrationtests/cli/assets/driven2/kustomize/overlays/prod/secret.yaml
@@ -0,0 +1,7 @@
+apiVersion: v1
+kind: Secret
+metadata:
+  name: supersecret
+data:
+  username: YmxhaDI=
+  password: YmxhaDI=
\ No newline at end of file
diff --git a/integrationtests/cli/assets/driven_fleet_yaml_subfolder/helm/test/fleet.yaml b/integrationtests/cli/assets/driven_fleet_yaml_subfolder/helm/test/fleet.yaml
new file mode 100644
index 00000000..1bd51c88
--- /dev/null
+++ b/integrationtests/cli/assets/driven_fleet_yaml_subfolder/helm/test/fleet.yaml
@@ -0,0 +1,3 @@
+helm:
+  releaseName: config-chart
+  chart: http://localhost:3000/config-chart-0.1.0.tgz
diff --git a/internal/cmd/controller/gitops/reconciler/gitjob_test.go b/internal/cmd/controller/gitops/reconciler/gitjob_test.go
index aa1465f1..1253933c 100644
--- a/internal/cmd/controller/gitops/reconciler/gitjob_test.go
+++ b/internal/cmd/controller/gitops/reconciler/gitjob_test.go
@@ -8,6 +8,7 @@ import (
 	"fmt"
 	"os"
 	"slices"
+	"strings"
 	"testing"
 	"time"
 
@@ -2307,7 +2308,181 @@ func TestGitClonerSSH(t *testing.T) {
 
 				}
 			}
+		})
+	}
+}
 
+func TestDrivenScanSeparator(t *testing.T) {
+	tests := map[string]struct {
+		bundles                    []fleetv1.BundlePath
+		expectedRandomResult       bool
+		expectedResult             string
+		expectedResultRandomLength int
+	}{
+		"Bundle definitions have no separator character": {
+			bundles: []fleetv1.BundlePath{
+				{
+					Base:    "test/one/two",
+					Options: "options.yaml",
+				},
+				{
+					Base:    "test/",
+					Options: "options2.yaml",
+				},
+			},
+			expectedRandomResult: false,
+			expectedResult:       ":",
+		},
+		"Bundle definitions have : separator character": {
+			bundles: []fleetv1.BundlePath{
+				{
+					Base:    "test/one:two",
+					Options: "options.yaml",
+				},
+				{
+					Base:    "test/",
+					Options: "options2.yaml",
+				},
+			},
+			expectedRandomResult: false,
+			expectedResult:       ",",
+		},
+		"Bundle definitions have : and , separator characters": {
+			bundles: []fleetv1.BundlePath{
+				{
+					Base:    "test/one:two",
+					Options: "options.yaml",
+				},
+				{
+					Base:    "test,one",
+					Options: "options2.yaml",
+				},
+			},
+			expectedRandomResult: false,
+			expectedResult:       "|",
+		},
+		"Bundle definitions have : , and | separator characters": {
+			bundles: []fleetv1.BundlePath{
+				{
+					Base:    "test/one:two",
+					Options: "options.yaml",
+				},
+				{
+					Base:    "test,one",
+					Options: "options2|.yaml",
+				},
+			},
+			expectedRandomResult: false,
+			expectedResult:       "?",
+		},
+		"Bundle definitions have : ,  | and ? separator characters": {
+			bundles: []fleetv1.BundlePath{
+				{
+					Base:    "test?one:two",
+					Options: "options.yaml",
+				},
+				{
+					Base:    "test,one",
+					Options: "options2|.yaml",
+				},
+			},
+			expectedRandomResult: false,
+			expectedResult:       "<",
+		},
+		"Bundle definitions have : ,  |  ? and < separator characters": {
+			bundles: []fleetv1.BundlePath{
+				{
+					Base:    "test?one:two",
+					Options: "options.yaml",
+				},
+				{
+					Base:    "test,one<",
+					Options: "options2|.yaml",
+				},
+			},
+			expectedRandomResult: false,
+			expectedResult:       ">",
+		},
+		"Bundle definitions all separator characters": {
+			bundles: []fleetv1.BundlePath{
+				{
+					Base:    "test?one:two",
+					Options: "options.yaml",
+				},
+				{
+					Base:    "test,one<>",
+					Options: "options2|.yaml",
+				},
+			},
+			expectedRandomResult:       true,
+			expectedResult:             "",
+			expectedResultRandomLength: 2,
+		},
+	}
+	for name, test := range tests {
+		t.Run(name, func(t *testing.T) {
+			gitrepo := &fleetv1.GitRepo{
+				Spec: fleetv1.GitRepoSpec{
+					Bundles: test.bundles,
+				},
+			}
+			separator := getDrivenScanSeparator(*gitrepo)
+			if !test.expectedRandomResult {
+				if test.expectedResult != separator {
+					t.Errorf("expecting separator to be: %q, got: %q", test.expectedResult, separator)
+				}
+			} else {
+				// Verify that the string has the desired length and
+				// is composed only of the specified characters.
+				if len(separator) != test.expectedResultRandomLength {
+					t.Errorf("expecting separator length to be: %d, got: %d", test.expectedResultRandomLength, len(separator))
+				}
+				for _, char := range separator {
+					if !strings.ContainsRune(bundleOptionsSeparatorChars, char) {
+						t.Errorf("expecting separator to only contain characters from the set: %q, got: %q", bundleOptionsSeparatorChars, separator)
+					}
+				}
+				for _, b := range gitrepo.Spec.Bundles {
+					// use the separator and verify we get the expected items
+					concatStr := b.Base + separator + b.Options
+					items := strings.Split(concatStr, separator)
+					if len(items) != 2 {
+						t.Errorf("expecting to have exactly 2 items, got: %d", len(items))
+					}
+					if items[0] != b.Base {
+						t.Errorf("expecting first item to be: %q, got: %q", b.Base, items[0])
+					}
+					if items[1] != b.Options {
+						t.Errorf("expecting second item to be: %q, got: %q", b.Options, items[1])
+					}
+				}
+				// Take it a step further and use the random separator in the bundle definitions.
+				// This will verify that the function returns a longer separator.
+				gitrepo = &fleetv1.GitRepo{
+					Spec: fleetv1.GitRepoSpec{
+						Bundles: []fleetv1.BundlePath{
+							{
+								Base:    fmt.Sprintf("test?one:%stwo", separator),
+								Options: "options.yaml",
+							},
+							{
+								Base:    "test,one<>",
+								Options: "options2|.yaml",
+							},
+						},
+					},
+				}
+				// Finally, verify that the new separator is also valid.
+				newSeparator := getDrivenScanSeparator(*gitrepo)
+				if len(newSeparator) < len(separator) {
+					t.Errorf("expecting separator length to be greater or equal to %d, got: %d", len(separator), len(newSeparator))
+				}
+				for _, char := range newSeparator {
+					if !strings.ContainsRune(bundleOptionsSeparatorChars, char) {
+						t.Errorf("expecting separator to only contain characters from the set: %q, got: %q", bundleOptionsSeparatorChars, separator)
+					}
+				}
+			}
 		})
 	}
 }