/* Copyright The Helm Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package values import ( "bytes" "encoding/json" "fmt" "io" "io/fs" "net/url" "os" "path/filepath" "strings" "helm.sh/helm/v4/pkg/chart/v2/loader" "helm.sh/helm/v4/pkg/getter" "helm.sh/helm/v4/pkg/strvals" ) // Options captures the different ways to specify values type Options struct { ValuesDirectories []string // -d / --values-directory ValueFiles []string // -f / --values StringValues []string // --set-string Values []string // --set FileValues []string // --set-file JSONValues []string // --set-json LiteralValues []string // --set-literal } // MergeValues merges values from multiple sources according to Helm's precedence rules. // // The following list is ordered from lowest to highest precedence; items lower in the list override those above. // i.e., values from sources later in the list take precedence over earlier ones: // // 1. -d / --values-directory : Values files from one or more directories. // 2. -f / --values : Values files or URLs. // 3. --set-json : Values provided as raw JSON. // 4. --set : Inline key=value pairs. // 5. --set-string : Inline key=value pairs (values always treated as strings). // 6. --set-file : Values read from file contents. // 7. --set-literal : Values provided as raw string literals. // // For example, if `captain=Luffy` is set via --values-directory (1) and `captain=Usopp` is set via --values (2), the // final merged value for `captain` will be "Usopp". // // --- // // In case any supported flag is specified multiple times, the latter occurrence has higher precedence, i.e. overrides // the former. // // For example, for `--set captain=Luffy --set captain=Usopp`, the final merged value for `captain` will be "Usopp". // // This applies to all values flags (-d/--values-directory, -f/--values, --set-json, --set, --set-string, --set-file, // and --set-literal). // // --- // // Additional context: The precedence of default values. // // - By default, Helm reads values from the chart’s values.yaml file (if present). // - These default values have the lowest precedence (level 0), meaning any values specified via the above-mentioned // flags will override them. // - If the same values.yaml file is explicitly provided using -f/--values, its values can override those loaded via // -d/--values-directory. However, in that case, they are no longer considered default values. // // Note: Default values are not handled by this function, but understanding their precedence is important for the // overall behavior. func (opts *Options) MergeValues(p getter.Providers) (map[string]any, error) { base := map[string]any{} var valuesFiles []string // 1. User specified directory(s) via -d/--values-directory. for _, dir := range opts.ValuesDirectories { // Recursively find all .yaml files in the directory. files, err := listYAMLFilesRecursively(dir) if err != nil { // Error is already wrapped. return nil, err } valuesFiles = append(valuesFiles, files...) } // 2. User specified values files via -f/--values. valuesFiles = append(valuesFiles, opts.ValueFiles...) for _, filePath := range valuesFiles { raw, err := readFile(filePath, p) if err != nil { return nil, err } currentMap, err := loader.LoadValues(bytes.NewReader(raw)) if err != nil { return nil, fmt.Errorf("failed to parse %s: %w", filePath, err) } // Merge with the previous map base = loader.MergeMaps(base, currentMap) } // 3. User specified a value via --set-json. for _, value := range opts.JSONValues { trimmedValue := strings.TrimSpace(value) if len(trimmedValue) > 0 && trimmedValue[0] == '{' { // If value is JSON object format, parse it as map var jsonMap map[string]any if err := json.Unmarshal([]byte(trimmedValue), &jsonMap); err != nil { return nil, fmt.Errorf("failed parsing --set-json data JSON: %s", value) } base = loader.MergeMaps(base, jsonMap) } else { // Otherwise, parse it as key=value format if err := strvals.ParseJSON(value, base); err != nil { return nil, fmt.Errorf("failed parsing --set-json data %s", value) } } } // 4. User specified a value via --set. for _, value := range opts.Values { if err := strvals.ParseInto(value, base); err != nil { return nil, fmt.Errorf("failed parsing --set data: %w", err) } } // 5. User specified a value via --set-string. for _, value := range opts.StringValues { if err := strvals.ParseIntoString(value, base); err != nil { return nil, fmt.Errorf("failed parsing --set-string data: %w", err) } } // 6. User specified a value via --set-file. for _, value := range opts.FileValues { reader := func(rs []rune) (any, error) { bytes, err := readFile(string(rs), p) if err != nil { return nil, err } return string(bytes), err } if err := strvals.ParseIntoFile(value, base, reader); err != nil { return nil, fmt.Errorf("failed parsing --set-file data: %w", err) } } // 7. User specified a value via --set-literal. for _, value := range opts.LiteralValues { if err := strvals.ParseLiteralInto(value, base); err != nil { return nil, fmt.Errorf("failed parsing --set-literal data: %w", err) } } return base, nil } // readFile load a file from stdin, the local directory, or a remote file with a url. func readFile(filePath string, p getter.Providers) ([]byte, error) { if strings.TrimSpace(filePath) == "-" { return io.ReadAll(os.Stdin) } u, err := url.Parse(filePath) if err != nil { return nil, err } // FIXME: maybe someone handle other protocols like ftp. g, err := p.ByScheme(u.Scheme) if err != nil { return os.ReadFile(filePath) } data, err := g.Get(filePath, getter.WithURL(filePath)) if err != nil { return nil, err } return data.Bytes(), nil } // listYAMLFilesRecursively walks a directory tree and returns a lexicographically sorted list of all YAML files. // // Example: (dir="foo") // // foo/ // ├── bar/ // │ └── bar.yaml // ├── baz/ // │ ├── baz.yaml // │ └── qux.yaml // ├── baz.txt // └── foo.yaml // // Result: ["foo/bar/bar.yaml", "foo/baz/baz.yaml", "foo/baz/qux.yaml", "foo/foo.yaml"] func listYAMLFilesRecursively(dir string) ([]string, error) { var files []string // Check if the directory exists and is a directory. info, err := os.Stat(dir) if err != nil { return nil, fmt.Errorf("failed to access values directory %q: %w", dir, err) } if !info.IsDir() { return nil, fmt.Errorf("path %q is not a directory", dir) } // Walk the directory tree in lexical order. For the above example, this will visit: // 1. foo/bar // 2. foo/bar/bar.yaml // 3. foo/baz // 4. foo/baz/baz.yaml // 5. foo/baz/qux.yaml // 6. foo/baz.txt // 7. foo/foo.yaml // // The inner function filters the “.yaml” files as follows: // 1. foo/bar/bar.yaml // 2. foo/baz/baz.yaml // 3. foo/baz/qux.yaml // 4. foo/foo.yaml // // Note: Since filepath.WalkDir walks in lexical order, the returned list of files is also sorted lexicographically. err = filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { if err != nil { return fmt.Errorf("failed to walk directory %q: %w", path, err) } // Collect YAML files (.yaml or .yml, case-insensitive), skipping directories. if !d.IsDir() && isYamlFileExtension(d.Name()) { files = append(files, path) } return nil }) if err != nil { return nil, fmt.Errorf("failed to list files in directory %q: %w", dir, err) } return files, nil } // isYamlFileExtension checks if the given file name has a YAML file extension. It returns true for files ending with // .yaml or .yml (case-insensitive). func isYamlFileExtension(fileName string) bool { // Extract file extension and convert to lower case for case-insensitive comparison. ext := strings.ToLower(filepath.Ext(fileName)) // Check for .yaml or .yml extensions. return ext == ".yaml" || ext == ".yml" }