diff --git a/cmd/helm/flags.go b/cmd/helm/flags.go
index 3d159babd..c5f45dd3d 100644
--- a/cmd/helm/flags.go
+++ b/cmd/helm/flags.go
@@ -44,6 +44,7 @@ const (
func addValueOptionsFlags(f *pflag.FlagSet, v *values.Options) {
f.StringSliceVarP(&v.ValueFiles, "values", "f", []string{}, "specify values in a YAML file or a URL (can specify multiple)")
+ f.StringSliceVarP(&v.ValuesDirectories, "values-directory", "d", []string{}, "specify values directory to recursively read for value's YAML files. Note: The YAML files in the directory are read in the lexical order. (can specify multiple)")
f.StringArrayVar(&v.Values, "set", []string{}, "set values on the command line (can specify multiple or separate values with commas: key1=val1,key2=val2)")
f.StringArrayVar(&v.StringValues, "set-string", []string{}, "set STRING values on the command line (can specify multiple or separate values with commas: key1=val1,key2=val2)")
f.StringArrayVar(&v.FileValues, "set-file", []string{}, "set values from respective files specified via the command line (can specify multiple or separate values with commas: key1=path1,key2=path2)")
diff --git a/pkg/cli/values/options.go b/pkg/cli/values/options.go
index 24c47ecba..ea8e36603 100644
--- a/pkg/cli/values/options.go
+++ b/pkg/cli/values/options.go
@@ -18,8 +18,10 @@ package values
import (
"io"
+ "io/fs"
"net/url"
"os"
+ "path/filepath"
"strings"
"github.com/pkg/errors"
@@ -31,21 +33,48 @@ import (
// Options captures the different ways to specify values
type Options struct {
- ValueFiles []string // -f/--values
- StringValues []string // --set-string
- Values []string // --set
- FileValues []string // --set-file
- JSONValues []string // --set-json
- LiteralValues []string // --set-literal
+ ValueFiles []string // -f/--values
+ ValuesDirectories []string // -d/--values-directory
+ StringValues []string // --set-string
+ Values []string // --set
+ FileValues []string // --set-file
+ JSONValues []string // --set-json
+ LiteralValues []string // --set-literal
}
-// MergeValues merges values from files specified via -f/--values and directly
-// via --set-json, --set, --set-string, or --set-file, marshaling them to YAML
+// MergeValues merges values specified via any of the following flags, and marshals them to YAML:
+// 1. -d/--values-directory - from values file(s) in the directory(s)
+// 2. -f/--values - from values file(s) or URL
+// 3. --set-json - from input JSON
+// 4. --set - from input key-value pairs
+// 5. --set-string - from input key-value pairs, with string values, always
+// 6. --set-file - from files
+// 7. --set-literal - from input string literal
+//
+// The precedence order of inputs are 1 to 7, where 1 gets evaluated first and 7 last. i.e., If key1="val1" in inputs
+// from --values-directory, and key1="val2" in --values, the second overwrites the first and the final value of key1
+// is "val2". Similarly values from --set-json are replaced from that of --values, and so on.
func (opts *Options) MergeValues(p getter.Providers) (map[string]interface{}, error) {
base := map[string]interface{}{}
- // User specified a values files via -f/--values
- for _, filePath := range opts.ValueFiles {
+ var valuesFiles []string
+
+ // User specified directory(s) via -d/--values-directory
+ for _, dir := range opts.ValuesDirectories {
+ // Recursive list of YAML files in input values directory
+ files, err := recursiveListOfFilesInDir(dir, `.yaml`)
+ if err != nil {
+ // Error already wrapped
+ return nil, err
+ }
+
+ valuesFiles = append(valuesFiles, files...)
+ }
+
+ // User specified values files via -f/--values
+ valuesFiles = append(valuesFiles, opts.ValueFiles...)
+
+ for _, filePath := range valuesFiles {
currentMap := map[string]interface{}{}
bytes, err := readFile(filePath, p)
@@ -145,3 +174,31 @@ func readFile(filePath string, p getter.Providers) ([]byte, error) {
}
return data.Bytes(), err
}
+
+// recursiveListOfFilesInDir lists the directory recursively, i.e., files in all nested directories.
+// The list can be filtered by file extension. If no extension is specified, it returns all files.
+//
+// Result format: [
/, ..., // ...]
+func recursiveListOfFilesInDir(directory, extension string) ([]string, error) {
+ var files []string
+
+ // Traverse through the directory, recursively
+ err := filepath.WalkDir(directory, func(path string, file fs.DirEntry, err error) error {
+ // Check if accessing the file failed
+ if err != nil {
+ return errors.Wrapf(err, "failed to read info of file %q", path)
+ }
+
+ // When the file has the required extension, or when extension is not specified
+ if !file.IsDir() && (extension == "" || filepath.Ext(path) == extension) {
+ files = append(files, path)
+ }
+
+ return nil
+ })
+ if err != nil {
+ return nil, errors.Wrapf(err, "failed to recursively list files in directory %q", directory)
+ }
+
+ return files, nil
+}
diff --git a/pkg/cli/values/options_test.go b/pkg/cli/values/options_test.go
index 9182e3cc8..f6ce67d31 100644
--- a/pkg/cli/values/options_test.go
+++ b/pkg/cli/values/options_test.go
@@ -23,7 +23,7 @@ import (
"helm.sh/helm/v4/pkg/getter"
)
-func TestMergeValues(t *testing.T) {
+func Test_mergeMaps(t *testing.T) {
nestedMap := map[string]interface{}{
"foo": "bar",
"baz": map[string]string{
@@ -86,3 +86,147 @@ func TestReadFile(t *testing.T) {
t.Errorf("Expected error when has special strings")
}
}
+
+func TestOptions_MergeValues(t *testing.T) {
+ const (
+ crewNameKey = `crew`
+ shipNameKey = `ship`
+ powerUserskey = `power-users`
+ nonPowerUsersKey = `non-power-users`
+ strawHatsCrew = `Straw Hat Pirates`
+ strawHatsShip1 = `Going Merry`
+ strawHatsShip2 = `Thousand Sunny`
+ )
+
+ var (
+ powerUsersVal = []interface{}{
+ "Luffy",
+ "Chopper",
+ "Robin",
+ "Brook",
+ }
+ nonPowerUsersVal = []interface{}{
+ "Zoro",
+ "Nami",
+ "Ussop",
+ "Sanji",
+ "Franky",
+ "Jinbei",
+ }
+ )
+
+ type args struct {
+ p getter.Providers
+ }
+ tests := []struct {
+ name string
+ opts Options
+ args args
+ want map[string]interface{}
+ wantErr bool
+ }{
+ {
+ name: "--values-directory with single level",
+ opts: Options{
+ ValueFiles: []string{},
+ ValuesDirectories: []string{
+ "testdata/chart-with-values-dir/values.d",
+ },
+ StringValues: []string{},
+ Values: []string{},
+ FileValues: []string{},
+ JSONValues: []string{},
+ },
+ args: args{
+ p: []getter.Provider{},
+ },
+ want: map[string]interface{}{
+ powerUserskey: powerUsersVal,
+ nonPowerUsersKey: nonPowerUsersVal,
+ },
+ wantErr: false,
+ },
+ {
+ name: "--values-directory with nested directories",
+ opts: Options{
+ ValueFiles: []string{},
+ ValuesDirectories: []string{
+ "testdata/multi-level-values-dir/values.d",
+ },
+ StringValues: []string{},
+ Values: []string{},
+ FileValues: []string{},
+ JSONValues: []string{},
+ },
+ args: args{
+ p: []getter.Provider{},
+ },
+ want: map[string]interface{}{
+ crewNameKey: strawHatsCrew,
+ shipNameKey: strawHatsShip1,
+ powerUserskey: powerUsersVal,
+ nonPowerUsersKey: nonPowerUsersVal,
+ },
+ wantErr: false,
+ },
+ {
+ name: "--values-directory value overwritten by --values",
+ opts: Options{
+ ValueFiles: []string{
+ "testdata/multi-level-values-dir/ship.yaml",
+ },
+ ValuesDirectories: []string{
+ "testdata/multi-level-values-dir/values.d",
+ },
+ StringValues: []string{},
+ Values: []string{},
+ FileValues: []string{},
+ JSONValues: []string{},
+ },
+ args: args{
+ p: []getter.Provider{},
+ },
+ want: map[string]interface{}{
+ crewNameKey: strawHatsCrew,
+ shipNameKey: strawHatsShip2, // This is the value overwritten by values file "ship.yaml"
+ powerUserskey: powerUsersVal,
+ nonPowerUsersKey: nonPowerUsersVal,
+ },
+ wantErr: false,
+ },
+ {
+ name: "--values-directory with missing directory",
+ opts: Options{
+ ValueFiles: []string{},
+ ValuesDirectories: []string{
+ "testdata/chart-with-values-dir/non-existing/",
+ },
+ StringValues: []string{},
+ Values: []string{},
+ FileValues: []string{},
+ JSONValues: []string{},
+ },
+ args: args{
+ p: []getter.Provider{},
+ },
+ want: map[string]interface{}(nil),
+ wantErr: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := tt.opts.MergeValues(tt.args.p)
+
+ if (err != nil) != tt.wantErr {
+ t.Errorf("Options.MergeValues() error = %v, wantErr %v", err, tt.wantErr)
+
+ return
+ }
+
+ if !reflect.DeepEqual(got, tt.want) {
+ t.Errorf("Expected result from MergeValues() = %v, got %v", tt.want, got)
+ }
+ })
+ }
+}
diff --git a/pkg/cli/values/testdata/chart-with-values-dir/values.d/non-power-users.yaml b/pkg/cli/values/testdata/chart-with-values-dir/values.d/non-power-users.yaml
new file mode 100644
index 000000000..b1be381b5
--- /dev/null
+++ b/pkg/cli/values/testdata/chart-with-values-dir/values.d/non-power-users.yaml
@@ -0,0 +1,7 @@
+non-power-users:
+ - Zoro
+ - Nami
+ - Ussop
+ - Sanji
+ - Franky
+ - Jinbei
diff --git a/pkg/cli/values/testdata/chart-with-values-dir/values.d/power-users.yaml b/pkg/cli/values/testdata/chart-with-values-dir/values.d/power-users.yaml
new file mode 100644
index 000000000..05d9cda91
--- /dev/null
+++ b/pkg/cli/values/testdata/chart-with-values-dir/values.d/power-users.yaml
@@ -0,0 +1,5 @@
+power-users:
+ - Luffy
+ - Chopper
+ - Robin
+ - Brook
diff --git a/pkg/cli/values/testdata/multi-level-values-dir/ship.yaml b/pkg/cli/values/testdata/multi-level-values-dir/ship.yaml
new file mode 100644
index 000000000..41603bb1a
--- /dev/null
+++ b/pkg/cli/values/testdata/multi-level-values-dir/ship.yaml
@@ -0,0 +1 @@
+ship: Thousand Sunny
diff --git a/pkg/cli/values/testdata/multi-level-values-dir/values.d/extras/crew.yaml b/pkg/cli/values/testdata/multi-level-values-dir/values.d/extras/crew.yaml
new file mode 100644
index 000000000..8266463b1
--- /dev/null
+++ b/pkg/cli/values/testdata/multi-level-values-dir/values.d/extras/crew.yaml
@@ -0,0 +1 @@
+crew: Straw Hat Pirates
diff --git a/pkg/cli/values/testdata/multi-level-values-dir/values.d/extras/ship.yaml b/pkg/cli/values/testdata/multi-level-values-dir/values.d/extras/ship.yaml
new file mode 100644
index 000000000..659919c7d
--- /dev/null
+++ b/pkg/cli/values/testdata/multi-level-values-dir/values.d/extras/ship.yaml
@@ -0,0 +1 @@
+ship: Going Merry
diff --git a/pkg/cli/values/testdata/multi-level-values-dir/values.d/non-power-users.yaml b/pkg/cli/values/testdata/multi-level-values-dir/values.d/non-power-users.yaml
new file mode 100644
index 000000000..b1be381b5
--- /dev/null
+++ b/pkg/cli/values/testdata/multi-level-values-dir/values.d/non-power-users.yaml
@@ -0,0 +1,7 @@
+non-power-users:
+ - Zoro
+ - Nami
+ - Ussop
+ - Sanji
+ - Franky
+ - Jinbei
diff --git a/pkg/cli/values/testdata/multi-level-values-dir/values.d/power-users.yaml b/pkg/cli/values/testdata/multi-level-values-dir/values.d/power-users.yaml
new file mode 100644
index 000000000..05d9cda91
--- /dev/null
+++ b/pkg/cli/values/testdata/multi-level-values-dir/values.d/power-users.yaml
@@ -0,0 +1,5 @@
+power-users:
+ - Luffy
+ - Chopper
+ - Robin
+ - Brook