Repository: rancher/fleet
Pull request #1557 (2023-05-26 07:41:52)

Allow helm credentials to be defined for each path
This PR adds a new field `HelmSecretNameForPaths` to the `GitRepo`. Fleet will look for a secret that contains credentials for helm repositories for specific paths. These credentials will be used when fetching from the helm repositories in the relevant paths. `HelmSecretName` will be ignored if `HelmSecretNameForPaths` is provided.

Example of a file used to create the `HelmSecretNameForPaths` secret:
```yaml
helm-test: # path helm-test must exist in the repository
  username: user
  password: pass
helm-test-two:  # path helm-test-two must exist in the repository
  username: user2
  password: pass2
  caBundle: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCiAgICBNSUlEblRDQ0FvV2dBd0lCQWdJVUNwMHB2SVJTb2c0eHJKN2Q1SUI2ME1ka0k1WXdEUVlKS29aSWh2Y05BUUVMCiAgICBCUUF3WGpFTE1Ba0dBMVVFQmhNQ1FWVXhFekFSQmdOVkJBZ01DbE52YldVdFUzUmhkR1V4SVRBZkJnTlZCQW9NCiAgICBHRWx1ZEdWeWJtVjBJRmRwWkdkcGRITWdVSFI1SUV4MFpERVhNQlVHQTFVRUF3d09jbUZ1WTJobGNpNXRlUzV2CiAgICBjbWN3SGhjTk1qTXdOREkzTVRVd056VXpXaGNOTWpnd05ESTFNVFV3TnpVeldqQmVNUXN3Q1FZRFZRUUdFd0pCCiAgICBWVEVUTUJFR0ExVUVDQXdLVTI5dFpTMVRkR0YwWlRFaE1COEdBMVVFQ2d3WVNXNTBaWEp1WlhRZ1YybGtaMmwwCiAgICBjeUJRZEhrZ1RIUmtNUmN3RlFZRFZRUUREQTV5WVc1amFHVnlMbTE1TG05eVp6Q0NBU0l3RFFZSktvWklodmNOCiAgICBBUUVCQlFBRGdnRVBBRENDQVFvQ2dnRUJBTXBvZE5TMDB6NDc1dnVSc2ZZcTFRYTFHQVl3QU92anV4MERKTHY5CiAgICBrZFhwT091dGdjMU8yWUdqNUlCVGQzVmpISmFJYUg3SDR2Rm84RlBaMG9zcU9YaFg3eUM4STdBS3ZhOEE5VmVmCiAgICBJVXp6Vlo1cCs1elNxRjdtZTlOaUNiL0pVSkZLT0ZsTkF4cjZCcXhoMEIyN1VZTlpjaUIvL1V0L0I2eHJuVE55CiAgICBoRzJiNzk4bjg4bFZqY3EzbEE0djFyM3VzWGYxVG5aS2t2UEN4ZnFHYk5OdTlpTjdFZnZHOWoyekdHcWJvcDRYCiAgICBXY3VSa3N3QkgxZlRNS0ZrbGcrR1VsZkZPMGFzL3phalVOdmdweTlpdVBMZUtqZTVWcDBiMlBLd09qUENpV2d4CiAgICBabDJlVDlNRnJjV0F3NTg3emE5NDBlT1Era2pkdmVvUE5sU2k3eVJMMW96YlRka0NBd0VBQWFOVE1GRXdIUVlECiAgICBWUjBPQkJZRUZEQkNkYjE4M1hsU0tWYzBxNmJSTCt0dVNTV3lNQjhHQTFVZEl3UVlNQmFBRkRCQ2RiMTgzWGxTCiAgICBLVmMwcTZiUkwrdHVTU1d5TUE4R0ExVWRFd0VCL3dRRk1BTUJBZjh3RFFZSktvWklodmNOQVFFTEJRQURnZ0VCCiAgICBBQ1BCVERkZ0dCVDVDRVoxd1pnQmhKdm9GZTk2MUJqVCtMU2RxSlpsSmNRZnlnS0hyNks5ZmZaY1ZlWlBoMVU0CiAgICB3czBuWGNOZiszZGJlTjl4dVBiY0VqUWlQaFJCcnRzalE1T1JiVHdYWEdBdzlYbDZYTkl6YjN4ZDF6RWFzQXZPCiAgICBJMjM2ZHZXQ1A0dWoycWZqR0FkQjJnaXU2b2xHK01CWHlneUZKMElzRENraldLZysyWEdmU3lyci9KZU1vZlFBCiAgICB1VU9wcFVGdERYd0lrUW1VTGNVVUxWcTdtUVNQb0lzVkNNM2hKNVQzczdUSWtHUDZVcGVSSjgzdU9LbURYMkRHCiAgICBwVWVQVHBuVWVLOVMzUEVKTi9XcmJSSVd3WU1OR29qdDRKWitaK1N6VE1aVkh0SlBzaGpjL1hYOWZNU1ZXQmlzCiAgICBQRW5MU256MDQ4OGFUQm5SUFlnVXFsdz0KICAgIC0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0=
  sshPrivateKey: ICAgIC0tLS0tQkVHSU4gQ0VSVElGSUNBVEUtLS0tLQogICAgTUlJRFF6Q0NBaXNDRkgxTm5YUWI5SlV6anNBR3FSc3RCYncwRlFpak1BMEdDU3FHU0liM0RRRUJDd1VBTUY0eAogICAgQ3pBSkJnTlZCQVlUQWtGVk1STXdFUVlEVlFRSURBcFRiMjFsTFZOMFlYUmxNU0V3SHdZRFZRUUtEQmhKYm5SbAogICAgY201bGRDQlhhV1JuYVhSeklGQjBlU0JNZEdReEZ6QVZCZ05WQkFNTURuSmhibU5vWlhJdWJYa3ViM0puTUI0WAogICAgRFRJek1EUXlOekUxTVRBMU5Gb1hEVEkwTURReU5qRTFNVEExTkZvd1hqRUxNQWtHQTFVRUJoTUNRVlV4RXpBUgogICAgQmdOVkJBZ01DbE52YldVdFUzUmhkR1V4SVRBZkJnTlZCQW9NR0VsdWRHVnlibVYwSUZkcFpHZHBkSE1nVUhSNQogICAgSUV4MFpERVhNQlVHQTFVRUF3d09jbUZ1WTJobGNpNXRlUzV2Y21jd2dnRWlNQTBHQ1NxR1NJYjNEUUVCQVFVQQogICAgQTRJQkR3QXdnZ0VLQW9JQkFRRGd6UUJJTW8xQVFHNnFtYmozbFlYUTFnZjhYcURTbjdyM2lGcVZZZldDVWZOSwogICAgaGZwampTRGpOMmRWWEV2UXA3R0t3akFHUElFbXR5RmxyUW5rUGtnTGFSaU9jSDdNN0p2c3ZIa0Ewd0g0dzJ2QgogICAgUEp6aVlINWh2MUE2WS9NcFM5bVkvQUVxVm80TUJkdnNZQzc3MFpCbzVBMitIUEtMd1YzMVZyYlhhTytWeUJtNAogICAgSmJhZHlNUk40N3BKRWdPMjJaYVRXL3Y3S1dKdjNydGJTMlZVSkNlU0piWlpsN09ocHhLRTVocStmK0RWaU1mcQogICAgTWx4ODNEV2pVSlVkV3lqVUZYVlk0bEdVaUtrRWVtSlVuSlVyY1ErOXE1SzVaWmhyRjhoRXhKRjhiZTZjemVzeAogICAga1VWN3dKb1RjWkd2bUhYSk1FNmtrQXh4Mmh3bU8wSFcyQWdDdTJZekFnTUJBQUV3RFFZSktvWklodmNOQVFFTAogICAgQlFBRGdnRUJBS1BpTWdXc1dCTnJvRkY2aWpYL2xMM3FxaWc4TjlkR1VPWDIyRVJDU1RTekNONjM0ZTFkZUhsdQogICAgbTc5OU11Q3hvWSsyZWluNlV1cFMvTEV6cnpvU2dDVWllQzQrT3ZralF5eGJpTFR6bW1OWEFnd09TM3RvTHRGWAogICAgbytmWWpSMU9xcHVPS29kMkhiYjliczRWcXdaNHEvMlVKbXE2Q01pYjZKZUE2VFJvK2Rkc0pUM2dDOFhWL1Z1MAogICAgNnkwdjJxdTM0bm1MYjFxOHFTS1RwZXYyQmwzQUJGY3NyS0JvNHFieUM2bnBTbnpZenNYcS90SlFLclplNE4vMgogICAgUXIzd1dxQ0pDVWUrMWVsT3A2b0JVcXNWSnc3aHk3YzRLc1Fna09ERDJkc2NuNEF1NGJhWlY2QmpySm1USVY0aQogICAgeXJ1dk9oZ2lINklGUVdDWmVQM2s0MU5obWRzRTNHQT0KICAgIC0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K
```

Credentials for each path can be passed to the fleet apply with the new argument `--helm-credentials-by-path-file`
Fleet controller will fetch the secret referenced by `HelmSecretNameForPaths`, and it will pass these credentials to the apply command in the fleet cli.

refers to https://github.com/rancher/fleet/issues/617


Patch:
diff --git a/charts/fleet-crd/templates/crds.yaml b/charts/fleet-crd/templates/crds.yaml
index a669da88..1d3e8159 100644
--- a/charts/fleet-crd/templates/crds.yaml
+++ b/charts/fleet-crd/templates/crds.yaml
@@ -2693,6 +2693,9 @@ spec:
               helmSecretName:
                 nullable: true
                 type: string
+              helmSecretNameForPaths:
+                nullable: true
+                type: string
               imageScanCommit:
                 properties:
                   authorEmail:
diff --git a/modules/cli/apply/apply.go b/modules/cli/apply/apply.go
index 7ddc4c43..74b90b1a 100644
--- a/modules/cli/apply/apply.go
+++ b/modules/cli/apply/apply.go
@@ -45,6 +45,7 @@ type Options struct {
 	Auth             bundlereader.Auth
 	HelmRepoURLRegex string
 	KeepResources    bool
+	AuthByPath       map[string]bundlereader.Auth
 }
 
 func globDirs(baseDir string) (result []string, err error) {
@@ -66,11 +67,7 @@ func globDirs(baseDir string) (result []string, err error) {
 // Apply 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 *client.Getter, repoName string, baseDirs []string, opts *Options) error {
-	if opts == nil {
-		opts = &Options{}
-	}
-
+func Apply(ctx context.Context, client *client.Getter, repoName string, baseDirs []string, opts Options) error {
 	if len(baseDirs) == 0 {
 		baseDirs = []string{"."}
 	}
@@ -89,6 +86,7 @@ func Apply(ctx context.Context, client *client.Getter, repoName string, baseDirs
 				}
 			}
 			err := filepath.Walk(baseDir, func(path string, info os.FileInfo, err error) error {
+				opts := opts
 				createBundle, e := shouldCreateBundleForThisPath(baseDir, path, info)
 				if e != nil {
 					return e
@@ -96,7 +94,10 @@ func Apply(ctx context.Context, client *client.Getter, repoName string, baseDirs
 				if !createBundle {
 					return nil
 				}
-				if err := Dir(ctx, client, repoName, path, opts, gitRepoBundlesMap); err == ErrNoResources {
+				if auth, ok := opts.AuthByPath[path]; ok {
+					opts.Auth = auth
+				}
+				if err := Dir(ctx, client, repoName, path, &opts, gitRepoBundlesMap); err == ErrNoResources {
 					logrus.Warnf("%s: %v", path, err)
 					return nil
 				} else if err != nil {
diff --git a/modules/cli/cmds/apply.go b/modules/cli/cmds/apply.go
index 2a85e57e..3c8e6670 100644
--- a/modules/cli/cmds/apply.go
+++ b/modules/cli/cmds/apply.go
@@ -11,9 +11,13 @@ import (
 
 	"github.com/rancher/fleet/modules/cli/apply"
 	"github.com/rancher/fleet/modules/cli/pkg/writer"
+	"github.com/rancher/fleet/pkg/bundlereader"
 	command "github.com/rancher/wrangler-cli"
+	"github.com/rancher/wrangler/pkg/yaml"
 )
 
+type readFile func(name string) ([]byte, error)
+
 func NewApply() *cobra.Command {
 	cmd := command.Command(&Apply{}, cobra.Command{
 		Use:   "apply [flags] BUNDLE_NAME PATH...",
@@ -26,20 +30,21 @@ func NewApply() *cobra.Command {
 type Apply struct {
 	BundleInputArgs
 	OutputArgsNoDefault
-	Label             map[string]string `usage:"Labels to apply to created bundles" short:"l"`
-	TargetsFile       string            `usage:"Addition source of targets and restrictions to be append"`
-	Compress          bool              `usage:"Force all resources to be compress" short:"c"`
-	ServiceAccount    string            `usage:"Service account to assign to bundle created" short:"a"`
-	SyncGeneration    int               `usage:"Generation number used to force sync the deployment"`
-	TargetNamespace   string            `usage:"Ensure this bundle goes to this target namespace"`
-	Paused            bool              `usage:"Create bundles in a paused state"`
-	Commit            string            `usage:"Commit to assign to the bundle" env:"COMMIT"`
-	Username          string            `usage:"Basic auth username for helm repo" env:"HELM_USERNAME"`
-	PasswordFile      string            `usage:"Path of file containing basic auth password for helm repo"`
-	CACertsFile       string            `usage:"Path of custom cacerts for helm repo" name:"cacerts-file"`
-	SSHPrivateKeyFile string            `usage:"Path of ssh-private-key for helm repo" name:"ssh-privatekey-file"`
-	HelmRepoURLRegex  string            `usage:"Helm credentials will be used if the helm repo matches this regex. Credentials will always be used if this is empty or not provided" name:"helm-repo-url-regex"`
-	KeepResources     bool              `usage:"Keep resources created after the GitRepo or Bundle is deleted" name:"keep-resources"`
+	Label                     map[string]string `usage:"Labels to apply to created bundles" short:"l"`
+	TargetsFile               string            `usage:"Addition source of targets and restrictions to be append"`
+	Compress                  bool              `usage:"Force all resources to be compress" short:"c"`
+	ServiceAccount            string            `usage:"Service account to assign to bundle created" short:"a"`
+	SyncGeneration            int               `usage:"Generation number used to force sync the deployment"`
+	TargetNamespace           string            `usage:"Ensure this bundle goes to this target namespace"`
+	Paused                    bool              `usage:"Create bundles in a paused state"`
+	Commit                    string            `usage:"Commit to assign to the bundle" env:"COMMIT"`
+	Username                  string            `usage:"Basic auth username for helm repo" env:"HELM_USERNAME"`
+	PasswordFile              string            `usage:"Path of file containing basic auth password for helm repo"`
+	CACertsFile               string            `usage:"Path of custom cacerts for helm repo" name:"cacerts-file"`
+	SSHPrivateKeyFile         string            `usage:"Path of ssh-private-key for helm repo" name:"ssh-privatekey-file"`
+	HelmRepoURLRegex          string            `usage:"Helm credentials will be used if the helm repo matches this regex. Credentials will always be used if this is empty or not provided" name:"helm-repo-url-regex"`
+	KeepResources             bool              `usage:"Keep resources created after the GitRepo or Bundle is deleted" name:"keep-resources"`
+	HelmCredentialsByPathFile string            `usage:"Path of file containing helm credentials for paths" name:"helm-credentials-by-path-file"`
 }
 
 func (a *Apply) Run(cmd *cobra.Command, args []string) error {
@@ -55,7 +60,7 @@ func (a *Apply) Run(cmd *cobra.Command, args []string) error {
 	}
 
 	name := ""
-	opts := &apply.Options{
+	opts := apply.Options{
 		BundleFile:       a.BundleFile,
 		Output:           writer.NewDefaultNone(a.Output),
 		Compress:         a.Compress,
@@ -68,31 +73,10 @@ func (a *Apply) Run(cmd *cobra.Command, args []string) error {
 		HelmRepoURLRegex: a.HelmRepoURLRegex,
 		KeepResources:    a.KeepResources,
 	}
-
-	if a.Username != "" && a.PasswordFile != "" {
-		password, err := os.ReadFile(a.PasswordFile)
-		if err != nil && !os.IsNotExist(err) {
-			return err
-		}
-
-		opts.Auth.Username = a.Username
-		opts.Auth.Password = string(password)
+	err := a.addAuthToOpts(&opts, os.ReadFile)
+	if err != nil {
+		return err
 	}
-	if a.CACertsFile != "" {
-		cabundle, err := os.ReadFile(a.CACertsFile)
-		if err != nil && !os.IsNotExist(err) {
-			return err
-		}
-		opts.Auth.CABundle = cabundle
-	}
-	if a.SSHPrivateKeyFile != "" {
-		privateKey, err := os.ReadFile(a.SSHPrivateKeyFile)
-		if err != nil && !os.IsNotExist(err) {
-			return err
-		}
-		opts.Auth.SSHPrivateKey = privateKey
-	}
-
 	if a.File == "-" {
 		opts.BundleReader = os.Stdin
 		if len(args) != 1 {
@@ -120,6 +104,52 @@ func (a *Apply) Run(cmd *cobra.Command, args []string) error {
 	return apply.Apply(cmd.Context(), Client, name, args, opts)
 }
 
+// addAuthToOpts adds auth if provided as arguments. It will look first for HelmCredentialsByPathFile. If HelmCredentialsByPathFile
+// is not provided it means that the same helm secret should be used for all helm repositories, then it will look for
+// Username, PasswordFile, CACertsFile and SSHPrivateKeyFile
+func (a *Apply) addAuthToOpts(opts *apply.Options, readFile readFile) error {
+	if a.HelmCredentialsByPathFile != "" {
+		file, err := readFile(a.HelmCredentialsByPathFile)
+		if err != nil && !os.IsNotExist(err) {
+			return err
+		}
+		var authByPath map[string]bundlereader.Auth
+		err = yaml.Unmarshal(file, &authByPath)
+		if err != nil {
+			return err
+		}
+		opts.AuthByPath = authByPath
+
+		return nil
+	}
+
+	if a.Username != "" && a.PasswordFile != "" {
+		password, err := readFile(a.PasswordFile)
+		if err != nil && !os.IsNotExist(err) {
+			return err
+		}
+
+		opts.Auth.Username = a.Username
+		opts.Auth.Password = string(password)
+	}
+	if a.CACertsFile != "" {
+		cabundle, err := readFile(a.CACertsFile)
+		if err != nil && !os.IsNotExist(err) {
+			return err
+		}
+		opts.Auth.CABundle = cabundle
+	}
+	if a.SSHPrivateKeyFile != "" {
+		privateKey, err := readFile(a.SSHPrivateKeyFile)
+		if err != nil && !os.IsNotExist(err) {
+			return err
+		}
+		opts.Auth.SSHPrivateKey = privateKey
+	}
+
+	return nil
+}
+
 func currentCommit() string {
 	cmd := exec.Command("git", "rev-parse", "HEAD")
 	buf := &bytes.Buffer{}
diff --git a/pkg/apis/fleet.cattle.io/v1alpha1/git.go b/pkg/apis/fleet.cattle.io/v1alpha1/git.go
index 1b4b959b..0d4b4641 100644
--- a/pkg/apis/fleet.cattle.io/v1alpha1/git.go
+++ b/pkg/apis/fleet.cattle.io/v1alpha1/git.go
@@ -43,6 +43,9 @@ type GitRepoSpec struct {
 	// HelmSecretName contains the auth secret for private helm repository
 	HelmSecretName string `json:"helmSecretName,omitempty"`
 
+	// HelmSecretNameForPaths contains the auth secret for private helm repository for each path
+	HelmSecretNameForPaths string `json:"helmSecretNameForPaths,omitempty"`
+
 	// HelmRepoURLRegex Helm credentials will be used if the helm repo matches this regex
 	// Credentials will always be used if this is empty or not provided
 	HelmRepoURLRegex string `json:"helmRepoURLRegex,omitempty"`
diff --git a/pkg/bundlereader/resources.go b/pkg/bundlereader/resources.go
index 7f23727c..f50c0878 100644
--- a/pkg/bundlereader/resources.go
+++ b/pkg/bundlereader/resources.go
@@ -23,10 +23,10 @@ import (
 var hasOCIURL = regexp.MustCompile(`^oci:\/\/`)
 
 type Auth struct {
-	Username      string
-	Password      string
-	CABundle      []byte
-	SSHPrivateKey []byte
+	Username      string `json:"username,omitempty"`
+	Password      string `json:"password,omitempty"`
+	CABundle      []byte `json:"caBundle,omitempty"`
+	SSHPrivateKey []byte `json:"sshPrivateKey,omitempty"`
 }
 
 // readResources reads and downloads all resources from the bundle
diff --git a/pkg/controllers/git/git.go b/pkg/controllers/git/git.go
index f3224dcd..156665ff 100644
--- a/pkg/controllers/git/git.go
+++ b/pkg/controllers/git/git.go
@@ -320,7 +320,11 @@ func (h *handler) OnChange(gitrepo *fleet.GitRepo, status fleet.GitRepoStatus) (
 		return nil, status, nil
 	}
 
-	if gitrepo.Spec.HelmSecretName != "" {
+	if gitrepo.Spec.HelmSecretNameForPaths != "" {
+		if _, err := h.secrets.Get(gitrepo.Namespace, gitrepo.Spec.HelmSecretNameForPaths); err != nil {
+			return nil, status, fmt.Errorf("failed to look up HelmSecretNameForPaths, error: %v", err)
+		}
+	} else if gitrepo.Spec.HelmSecretName != "" {
 		if _, err := h.secrets.Get(gitrepo.Namespace, gitrepo.Spec.HelmSecretName); err != nil {
 			return nil, status, fmt.Errorf("failed to look up helmSecretName, error: %v", err)
 		}
@@ -611,7 +615,20 @@ func volumes(gitrepo *fleet.GitRepo, configMap *corev1.ConfigMap) ([]corev1.Volu
 		},
 	}
 
-	if gitrepo.Spec.HelmSecretName != "" {
+	if gitrepo.Spec.HelmSecretNameForPaths != "" {
+		volumes = append(volumes, corev1.Volume{
+			Name: "helm-secret-by-path",
+			VolumeSource: corev1.VolumeSource{
+				Secret: &corev1.SecretVolumeSource{
+					SecretName: gitrepo.Spec.HelmSecretNameForPaths,
+				},
+			},
+		})
+		volumeMounts = append(volumeMounts, corev1.VolumeMount{
+			Name:      "helm-secret-by-path",
+			MountPath: "/etc/fleet/helm",
+		})
+	} else if gitrepo.Spec.HelmSecretName != "" {
 		volumes = append(volumes, corev1.Volume{
 			Name: "helm-secret",
 			VolumeSource: corev1.VolumeSource{
@@ -657,7 +674,21 @@ func argsAndEnvs(gitrepo *fleet.GitRepo) ([]string, []corev1.EnvVar) {
 	}
 
 	var env []corev1.EnvVar
-	if gitrepo.Spec.HelmSecretName != "" {
+	if gitrepo.Spec.HelmSecretNameForPaths != "" {
+		helmArgs := []string{
+			"--helm-credentials-by-path-file",
+			"/etc/fleet/helm/secrets-path.yaml",
+		}
+
+		args = append(args, helmArgs...)
+		env = append(env,
+			// for ssh go-getter, make sure we always accept new host key
+			corev1.EnvVar{
+				Name:  "GIT_SSH_COMMAND",
+				Value: "ssh -o stricthostkeychecking=accept-new",
+			},
+		)
+	} else if gitrepo.Spec.HelmSecretName != "" {
 		helmArgs := []string{
 			"--password-file",
 			"/etc/fleet/helm/password",


Test patch:
diff --git a/integrationtests/cli/apply/apply_test.go b/integrationtests/cli/apply/apply_test.go
index f1d27edb..e5b2f10d 100644
--- a/integrationtests/cli/apply/apply_test.go
+++ b/integrationtests/cli/apply/apply_test.go
@@ -10,7 +10,7 @@ import (
 var _ = Describe("Fleet apply", Ordered, func() {
 	When("simple resources", func() {
 		It("fleet apply is called", func() {
-			err := fleetApply("simple", []string{cli.AssetsPath + "simple"}, &apply.Options{})
+			err := fleetApply("simple", []string{cli.AssetsPath + "simple"}, apply.Options{})
 			Expect(err).NotTo(HaveOccurred())
 		})
 
@@ -30,7 +30,7 @@ var _ = Describe("Fleet apply", Ordered, func() {
 	})
 	When("simple resources in a nested folder", func() {
 		It("fleet apply is called", func() {
-			err := fleetApply("nested_simple", []string{cli.AssetsPath + "nested_simple"}, &apply.Options{})
+			err := fleetApply("nested_simple", []string{cli.AssetsPath + "nested_simple"}, apply.Options{})
 			Expect(err).NotTo(HaveOccurred())
 		})
 
@@ -52,7 +52,7 @@ var _ = Describe("Fleet apply", Ordered, func() {
 	})
 	When("simple resources in a nested folder with two levels", func() {
 		It("fleet apply is called", func() {
-			err := fleetApply("nested_two_levels", []string{cli.AssetsPath + "nested_two_levels"}, &apply.Options{})
+			err := fleetApply("nested_two_levels", []string{cli.AssetsPath + "nested_two_levels"}, apply.Options{})
 			Expect(err).NotTo(HaveOccurred())
 		})
 
@@ -72,7 +72,7 @@ var _ = Describe("Fleet apply", Ordered, func() {
 	})
 	When("multiple fleet.yaml in a nested folder", func() {
 		It("fleet apply is called", func() {
-			err := fleetApply("nested_multiple", []string{cli.AssetsPath + "nested_multiple"}, &apply.Options{})
+			err := fleetApply("nested_multiple", []string{cli.AssetsPath + "nested_multiple"}, apply.Options{})
 			Expect(err).NotTo(HaveOccurred())
 		})
 
@@ -108,7 +108,7 @@ var _ = Describe("Fleet apply", Ordered, func() {
 	})
 	When("multiple fleet.yaml mixed with simple resources in a nested folder", func() {
 		It("fleet apply is called", func() {
-			err := fleetApply("nested_mixed_two_levels", []string{cli.AssetsPath + "nested_mixed_two_levels"}, &apply.Options{})
+			err := fleetApply("nested_mixed_two_levels", []string{cli.AssetsPath + "nested_mixed_two_levels"}, apply.Options{})
 			Expect(err).NotTo(HaveOccurred())
 		})
 
@@ -148,7 +148,7 @@ var _ = Describe("Fleet apply", Ordered, func() {
 	})
 	When("containing keepResources in the fleet.yaml", func() {
 		It("fleet apply is called", func() {
-			err := fleetApply("keepResources", []string{cli.AssetsPath + "keep_resources"}, &apply.Options{})
+			err := fleetApply("keepResources", []string{cli.AssetsPath + "keep_resources"}, apply.Options{})
 			Expect(err).NotTo(HaveOccurred())
 		})
 
diff --git a/integrationtests/cli/apply/suite_test.go b/integrationtests/cli/apply/suite_test.go
index 6bfa4078..f75ad36e 100644
--- a/integrationtests/cli/apply/suite_test.go
+++ b/integrationtests/cli/apply/suite_test.go
@@ -20,7 +20,7 @@ func TestFleet(t *testing.T) {
 }
 
 // simulates fleet cli execution
-func fleetApply(name string, dirs []string, options *apply.Options) error {
+func fleetApply(name string, dirs []string, options apply.Options) error {
 	buf = gbytes.NewBuffer()
 	options.Output = buf
 
diff --git a/integrationtests/cli/assets/helm_chart_url/fleet.yaml b/integrationtests/cli/assets/helm_chart_url/fleet.yaml
index d8a31e70..1bd51c88 100644
--- a/integrationtests/cli/assets/helm_chart_url/fleet.yaml
+++ b/integrationtests/cli/assets/helm_chart_url/fleet.yaml
@@ -1,3 +1,3 @@
 helm:
   releaseName: config-chart
-  chart: http://localhost:3001/config-chart-0.1.0.tgz
+  chart: http://localhost:3000/config-chart-0.1.0.tgz
diff --git a/integrationtests/cli/assets/helm_path_credentials/fleet.yaml b/integrationtests/cli/assets/helm_path_credentials/fleet.yaml
new file mode 100644
index 00000000..e850905c
--- /dev/null
+++ b/integrationtests/cli/assets/helm_path_credentials/fleet.yaml
@@ -0,0 +1,4 @@
+helm:
+  releaseName: config-chart
+  chart: config-chart
+  repo: http://localhost:3000
diff --git a/integrationtests/cli/assets/helm_path_credentials/subfolder/fleet.yaml b/integrationtests/cli/assets/helm_path_credentials/subfolder/fleet.yaml
new file mode 100644
index 00000000..e850905c
--- /dev/null
+++ b/integrationtests/cli/assets/helm_path_credentials/subfolder/fleet.yaml
@@ -0,0 +1,4 @@
+helm:
+  releaseName: config-chart
+  chart: config-chart
+  repo: http://localhost:3000
diff --git a/integrationtests/cli/helm/helm_test.go b/integrationtests/cli/helm/helm_test.go
index da9a1538..36d09c73 100644
--- a/integrationtests/cli/helm/helm_test.go
+++ b/integrationtests/cli/helm/helm_test.go
@@ -4,25 +4,64 @@ import (
 	"io/fs"
 	"path/filepath"
 
+	. "github.com/onsi/ginkgo/v2"
+	. "github.com/onsi/gomega"
 	"github.com/rancher/fleet/integrationtests/cli"
 	"github.com/rancher/fleet/modules/cli/apply"
 	"github.com/rancher/fleet/pkg/bundlereader"
-
-	. "github.com/onsi/ginkgo/v2"
-	. "github.com/onsi/gomega"
 )
 
 const (
 	numberOfFilesInHelmConfigChart = 3
+	port                           = ":3000"
 )
 
-var _ = Describe("Fleet apply helm release", func() {
-	When("applying a folder with fleet.yaml that contains a helm release in the repo field", Serial, func() {
-		testHelmRepo("helm_repo_url", ":3000")
+var _ = Describe("Fleet apply helm release", Serial, func() {
+	When("applying a folder with fleet.yaml that contains a helm release in the repo field", func() {
+		testHelmRepo("helm_repo_url", port)
 	})
 
-	When("applying a folder with fleet.yaml that contains a helm release in the chart field", Serial, func() {
-		testHelmRepo("helm_chart_url", ":3001")
+	When("applying a folder with fleet.yaml that contains a helm release in the chart field", func() {
+		testHelmRepo("helm_chart_url", port)
+	})
+
+	When("applying a folder with fleet.yaml that contains a sub folder with another fleet.yaml", 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{
+						AuthByPath: map[string]bundlereader.Auth{cli.AssetsPath + "helm_path_credentials": {Username: username, Password: password}},
+					})
+					Expect(err).To(HaveOccurred())
+					return err.Error()
+				}).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{
+						AuthByPath: map[string]bundlereader.Auth{
+							cli.AssetsPath + "helm_path_credentials":           {Username: username, Password: password},
+							cli.AssetsPath + "helm_path_credentials/subfolder": {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())
+				})
+			})
+		})
 	})
 })
 
@@ -47,7 +86,7 @@ func testHelmRepo(path, port string) {
 		})
 		It("fleet apply success", func() {
 			Eventually(func() error {
-				return fleetApply([]string{cli.AssetsPath + path}, &apply.Options{})
+				return fleetApply([]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())
@@ -61,7 +100,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([]string{cli.AssetsPath + path}, apply.Options{})
 				Expect(err).To(HaveOccurred())
 				return err.Error()
 			}).Should(ContainSubstring("401"))
@@ -74,7 +113,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([]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())
@@ -88,7 +127,7 @@ func testHelmRepo(path, port string) {
 		})
 		It("fleet apply success", func() {
 			Eventually(func() error {
-				return fleetApply([]string{cli.AssetsPath + path}, &apply.Options{
+				return fleetApply([]string{cli.AssetsPath + path}, apply.Options{
 					Auth:             bundlereader.Auth{Username: username, Password: password},
 					HelmRepoURLRegex: "http://localhost/*",
 				})
@@ -105,7 +144,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([]string{cli.AssetsPath + path}, apply.Options{
 					Auth:             bundlereader.Auth{Username: username, Password: password},
 					HelmRepoURLRegex: "nomatch",
 				})
@@ -121,7 +160,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([]string{cli.AssetsPath + path}, apply.Options{
 					Auth:             bundlereader.Auth{Username: username, Password: password},
 					HelmRepoURLRegex: "a(b",
 				})
@@ -130,6 +169,34 @@ func testHelmRepo(path, port string) {
 			}).Should(Equal("error parsing regexp: missing closing ): `a(b`"))
 		})
 	})
+
+	When("Auth is required, and it is provided in HelmSecretNameForPaths", func() {
+		BeforeEach(func() {
+			authEnabled = true
+		})
+		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}}})
+			}).Should(Not(HaveOccurred()))
+			By("verify Bundle is created with all the resources inside of the helm release", func() {
+				Eventually(verifyResourcesArePresent).Should(BeTrue())
+			})
+		})
+	})
+
+	When("Auth is required, and it provided in both HelmSecretNameForPaths and HelmSecret", func() {
+		BeforeEach(func() {
+			authEnabled = true
+		})
+		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}}})
+			}).Should(Not(HaveOccurred()))
+			By("verify Bundle is created with all the resources inside of the helm release", func() {
+				Eventually(verifyResourcesArePresent).Should(BeTrue())
+			})
+		})
+	})
 }
 
 func verifyResourcesArePresent() bool {
diff --git a/integrationtests/cli/helm/repository.go b/integrationtests/cli/helm/repository.go
index 98dbd7fa..756f70d7 100644
--- a/integrationtests/cli/helm/repository.go
+++ b/integrationtests/cli/helm/repository.go
@@ -25,9 +25,24 @@ func (r *repository) startRepository(authEnabled bool) {
 	r.server = &http.Server{Addr: r.port, ReadHeaderTimeout: 1 * time.Second}
 	r.server.Handler = getHandler(authEnabled)
 	go func() {
-		err := r.server.ListenAndServe()
-		if err != nil && err != http.ErrServerClosed {
-			panic("error creating helm repository: " + err.Error())
+		const maxAttempts = 10
+		currentAttempt := 0
+		for {
+			err := r.server.ListenAndServe()
+			// It is possible that the previous repository is still closing as it takes a few extra milliseconds to fully close
+			// retry after 100 milliseconds if "address already in use" error is returned
+			if err != nil && strings.Contains(err.Error(), "address already in use") {
+				if currentAttempt == maxAttempts {
+					panic("Max number of attempts reached: error creating helm repository: " + err.Error())
+				}
+				currentAttempt++
+				time.Sleep(100 * time.Millisecond)
+				continue
+			}
+			if err != nil && err != http.ErrServerClosed {
+				panic("error creating helm repository: " + err.Error())
+			}
+			break
 		}
 	}()
 }
diff --git a/integrationtests/cli/helm/suite_test.go b/integrationtests/cli/helm/suite_test.go
index b0a9cf68..0a5f8854 100644
--- a/integrationtests/cli/helm/suite_test.go
+++ b/integrationtests/cli/helm/suite_test.go
@@ -20,7 +20,7 @@ func TestFleet(t *testing.T) {
 }
 
 // simulates fleet cli execution
-func fleetApply(dirs []string, options *apply.Options) error {
+func fleetApply(dirs []string, options apply.Options) error {
 	buf = gbytes.NewBuffer()
 	options.Output = buf
 
diff --git a/modules/cli/cmds/apply_test.go b/modules/cli/cmds/apply_test.go
new file mode 100644
index 00000000..739cd0ac
--- /dev/null
+++ b/modules/cli/cmds/apply_test.go
@@ -0,0 +1,98 @@
+package cmds
+
+import (
+	"errors"
+	"testing"
+
+	"github.com/google/go-cmp/cmp"
+	"sigs.k8s.io/yaml"
+
+	"github.com/rancher/fleet/modules/cli/apply"
+	"github.com/rancher/fleet/pkg/bundlereader"
+)
+
+const (
+	username = "user"
+
+	password_file    = "password_file"
+	password_content = "pass"
+
+	caCerts_file    = "caCerts_file"
+	caCerts_content = "caCerts"
+
+	sshPrivateKey_file    = "sshPrivateKey_file"
+	sshPrivateKey_content = "sshPrivateKey"
+
+	helmSecretsNameByPath_file = "helmSecretsNameByPath_file"
+)
+
+var helmSecretsNameByPath_content = map[string]bundlereader.Auth{"path": {Username: username, Password: password_content}}
+
+func TestAddAuthToOpts(t *testing.T) {
+	tests := map[string]struct {
+		name         string
+		apply        Apply
+		expectedOpts *apply.Options
+		expectedErr  error
+	}{
+		"Auth is empty if no arguments are provided": {
+			apply:        Apply{},
+			expectedOpts: &apply.Options{},
+			expectedErr:  nil,
+		},
+		"Auth contains values from username, password, caCerts and sshPrivatey when helmSecretsNameByPath not provided": {
+			apply:        Apply{PasswordFile: password_file, Username: username, CACertsFile: caCerts_file, SSHPrivateKeyFile: sshPrivateKey_file},
+			expectedOpts: &apply.Options{Auth: bundlereader.Auth{Username: username, Password: password_content, CABundle: []byte(caCerts_content), SSHPrivateKey: []byte(sshPrivateKey_content)}},
+			expectedErr:  nil,
+		},
+		"AuthByPath contains values from HelmCredentialsByPathFile if provided": {
+			apply:        Apply{HelmCredentialsByPathFile: helmSecretsNameByPath_file},
+			expectedOpts: &apply.Options{AuthByPath: helmSecretsNameByPath_content},
+			expectedErr:  nil,
+		},
+		"HelmCredentialsByPathFile has priority over username and password for a generic helm secret if both are provided": {
+			apply:        Apply{HelmCredentialsByPathFile: helmSecretsNameByPath_file, PasswordFile: password_file, Username: username, CACertsFile: caCerts_file, SSHPrivateKeyFile: sshPrivateKey_file},
+			expectedOpts: &apply.Options{AuthByPath: helmSecretsNameByPath_content},
+			expectedErr:  nil,
+		},
+		"Error if file doesn't exist": {
+			apply:        Apply{HelmCredentialsByPathFile: "notfound"},
+			expectedOpts: &apply.Options{},
+			expectedErr:  errorNotFound,
+		},
+	}
+
+	for name, test := range tests {
+		t.Run(name, func(t *testing.T) {
+			opts := &apply.Options{}
+			err := test.apply.addAuthToOpts(opts, mockReadFile)
+			if !cmp.Equal(opts, test.expectedOpts) {
+				t.Errorf("opts don't match: expected %v, got %v", test.expectedOpts, opts)
+			}
+			if err != test.expectedErr {
+				t.Errorf("errors don't match: expected %v, got %v", test.expectedErr, err)
+			}
+		})
+	}
+}
+
+var errorNotFound = errors.New("not found")
+
+func mockReadFile(name string) ([]byte, error) {
+	switch name {
+	case helmSecretsNameByPath_file:
+		b, err := yaml.Marshal(helmSecretsNameByPath_content)
+		if err != nil {
+			return nil, err
+		}
+		return b, nil
+	case password_file:
+		return []byte(password_content), nil
+	case caCerts_file:
+		return []byte(caCerts_content), nil
+	case sshPrivateKey_file:
+		return []byte(sshPrivateKey_content), nil
+	}
+
+	return nil, errorNotFound
+}