From d48e8bdc9bce86aea69f65303a0bfe5462dcb885 Mon Sep 17 00:00:00 2001 From: DanielHabenicht Date: Fri, 31 Jul 2026 16:29:33 +0200 Subject: [PATCH 1/2] Support helmfile template functions Helmfile value files use template functions beyond Helm/Sprig (env, requiredEnv, exec, readFile, fetchSecretValue, ...), which were reported as "function ... not defined" by validateTemplateSyntax. Add helmfileFuncMap() registering stubs for those functions on top of helmFuncMap(), and a helmfile validation mode enabled automatically for .gotmpl files or explicitly via --helmfile (needed for stdin). Coverage is a real template fixture (templates_test/) formatting a .gotmpl values file that uses env/requiredEnv; the stdin path selects helmfile mode from the input extension, exercising validation too. Fixes #16. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 22 ++++++++++- format.go | 39 +++++++++++++++---- main.go | 32 +++++++++------ templates_test.go | 5 ++- templates_test/helmfile_values.yaml | 3 ++ .../templates/helmfile_values.yaml.gotmpl | 7 ++++ .../helmfile_values.yaml.gotmpl | 7 ++++ 7 files changed, 93 insertions(+), 22 deletions(-) create mode 100644 templates_test/helmfile_values.yaml create mode 100644 templates_test/templates/helmfile_values.yaml.gotmpl create mode 100644 templates_test/templates_expected/helmfile_values.yaml.gotmpl diff --git a/README.md b/README.md index 86a8bc3..40aefe9 100644 --- a/README.md +++ b/README.md @@ -225,6 +225,26 @@ docker run --rm -v "$PWD:/work" ghcr.io/digitalstudium/helmfmt:latest \ --files templates/deployment.yaml templates/service.yaml ``` +### Helmfile templates + +[Helmfile](https://helmfile.readthedocs.io/) value files use extra template +functions on top of Helm/Sprig (`env`, `requiredEnv`, `exec`, `readFile`, +`fetchSecretValue`, ...). By default `helmfmt` validates against Helm's function +set, so these would be reported as `function "env" not defined`. + +Helmfile mode accepts those functions. It is enabled automatically for files +with a `.gotmpl` extension (e.g. `helmfile.yaml.gotmpl`, +`values/demo.yaml.gotmpl`), and can be forced with `--helmfile` (required for +stdin, which has no filename): + +```bash +# Auto-detected via the .gotmpl extension +helmfmt --check --files helmfile.yaml.gotmpl values/demo.yaml.gotmpl +# Force helmfile mode for any file or for stdin +helmfmt --helmfile --files values/demo.yaml +cat values/demo.yaml | helmfmt --helmfile --check +``` + --- ## Configuration @@ -239,7 +259,7 @@ docker run --rm -v "$PWD:/work" ghcr.io/digitalstudium/helmfmt:latest \ ```json { "indent_size": 2, - "extensions": [".yaml", ".yml", ".tpl"], + "extensions": [".yaml", ".yml", ".tpl", ".gotmpl"], "rules": { "indent": { "tpl": { diff --git a/format.go b/format.go index 9382a34..f2fd09f 100644 --- a/format.go +++ b/format.go @@ -67,15 +67,40 @@ func helmFuncMap() template.FuncMap { return f } -// validateTemplateSyntax validates the given template source string using -// Helm function set. Returns an error if the template has invalid syntax. -func validateTemplateSyntax(src string) error { +// helmfileFuncMap extends helmFuncMap with the template functions helmfile +// registers on top of Helm/Sprig (env, requiredEnv, exec, readFile, ...). As +// with helmFuncMap, only the presence of each name matters for parse-time +// validation; the stubs are never executed. Names already provided by +// Helm/sprig (get, tpl, required, ...) keep their existing registration. +func helmfileFuncMap() template.FuncMap { + f := helmFuncMap() + // Helmfile-specific functions not in Helm/sprig + helmfileExtras := []string{ + "env", "requiredEnv", "exec", "envExec", + "readFile", "readDir", "readDirEntries", + "getOrNil", "setValueAtPath", + "fetchSecretValue", "expandSecretRefs", "kustomizeBuild", + } + for _, name := range helmfileExtras { + f[name] = stub + } + return f +} + +// validateTemplateSyntax validates the given template source string using the +// Helm function set. When helmfile is true, helmfile's additional template +// functions are also accepted. Returns an error if the template has invalid +// syntax. +func validateTemplateSyntax(src string, helmfile bool) error { - // Get Helm's built-in function map - helmFuncMap := helmFuncMap() + // Get the applicable function map + funcMap := helmFuncMap() + if helmfile { + funcMap = helmfileFuncMap() + } - // Create and parse template with helm function map - _, err := template.New("validation").Funcs(helmFuncMap).Parse(src) + // Create and parse template with the function map + _, err := template.New("validation").Funcs(funcMap).Parse(src) if err != nil { return fmt.Errorf("invalid template syntax: %w", err) } diff --git a/main.go b/main.go index c4b8d02..5b2245a 100644 --- a/main.go +++ b/main.go @@ -37,7 +37,7 @@ func loadConfig() *Config { // Default config config := &Config{ IndentSize: 2, - Extensions: []string{".yaml", ".yml", ".tpl"}, + Extensions: []string{".yaml", ".yml", ".tpl", ".gotmpl"}, Rules: RulesConfig{ Indent: map[string]RuleConfig{ "tpl": {Disabled: true, Exclude: []string{}}, @@ -79,7 +79,7 @@ func main() { func run() int { config := loadConfig() - var stdout, files, check bool + var stdout, files, check, helmfile bool var disableRules, enableRules []string var rootCmd = &cobra.Command{ @@ -121,12 +121,12 @@ func run() int { if len(args) == 0 { // --files with no args means read filenames from stdin (pre-commit style) if stdinPiped { - return processFilesFromStdin(config, stdout, check) + return processFilesFromStdin(config, stdout, check, helmfile) } return fmt.Errorf("--files requires at least one file argument") } // --files with args means process those files - exitCode := process(args, stdout, check, config) + exitCode := process(args, stdout, check, helmfile, config) if exitCode != 0 { os.Exit(exitCode) } @@ -135,7 +135,7 @@ func run() int { // If stdin is piped and no --files flag, process stdin as content if stdinPiped && len(args) == 0 { - return processStdin(config, check) + return processStdin(config, check, helmfile) } // Chart mode @@ -152,7 +152,7 @@ func run() int { if err != nil { return err } - exitCode := process(chartFiles, false, check, config) + exitCode := process(chartFiles, false, check, helmfile, config) if exitCode != 0 { os.Exit(exitCode) } @@ -163,6 +163,7 @@ func run() int { rootCmd.Flags().BoolVar(&files, "files", false, "Process specific files") rootCmd.Flags().BoolVar(&stdout, "stdout", false, "Output to stdout") rootCmd.Flags().BoolVar(&check, "check", false, "Check formatting without modifying files (exit 1 if unformatted)") + rootCmd.Flags().BoolVar(&helmfile, "helmfile", false, "Accept helmfile template functions (env, requiredEnv, exec, ...); auto-enabled for .gotmpl files") rootCmd.Flags().StringSliceVar(&disableRules, "disable-indent", []string{}, "Disable specific indent rules (e.g., --disable-indent=printf,include)") rootCmd.Flags().StringSliceVar(&enableRules, "enable-indent", []string{}, "Enable specific indent rules (e.g., --enable-indent=printf,include)") @@ -173,7 +174,7 @@ func run() int { return 0 } -func processFilesFromStdin(config *Config, stdout bool, check bool) error { +func processFilesFromStdin(config *Config, stdout bool, check bool, helmfile bool) error { // Read filenames from stdin (one per line) input, err := io.ReadAll(os.Stdin) if err != nil { @@ -193,14 +194,14 @@ func processFilesFromStdin(config *Config, stdout bool, check bool) error { return fmt.Errorf("no files provided via stdin") } - exitCode := process(filenames, stdout, check, config) + exitCode := process(filenames, stdout, check, helmfile, config) if exitCode != 0 { os.Exit(exitCode) } return nil } -func processStdin(config *Config, check bool) error { +func processStdin(config *Config, check bool, helmfile bool) error { // Read all input from stdin input, err := io.ReadAll(os.Stdin) if err != nil { @@ -210,7 +211,7 @@ func processStdin(config *Config, check bool) error { orig := string(input) // Validate syntax - if err := validateTemplateSyntax(orig); err != nil { + if err := validateTemplateSyntax(orig, helmfile); err != nil { return fmt.Errorf("invalid syntax: %w", err) } @@ -246,7 +247,7 @@ func collectFiles(root string, config *Config) ([]string, error) { return out, err } -func process(files []string, stdout bool, check bool, config *Config) int { +func process(files []string, stdout bool, check bool, helmfile bool, config *Config) int { var total, updated, failed, unformatted int for _, file := range files { @@ -260,7 +261,7 @@ func process(files []string, stdout bool, check bool, config *Config) int { } orig := string(b) - if err := validateTemplateSyntax(orig); err != nil { + if err := validateTemplateSyntax(orig, helmfile || isHelmfile(file)); err != nil { fmt.Fprintf(os.Stderr, "[ERROR] Invalid syntax %s: %v\n", file, err) failed++ continue @@ -320,6 +321,13 @@ func process(files []string, stdout bool, check bool, config *Config) int { return 0 } +// isHelmfile reports whether a path should be validated with helmfile's +// template functions, based on the .gotmpl extension (covers values.yaml.gotmpl, +// helmfile.yaml.gotmpl, etc.). +func isHelmfile(path string) bool { + return strings.EqualFold(filepath.Ext(path), ".gotmpl") +} + func wanted(path string, config *Config) bool { ext := strings.ToLower(filepath.Ext(path)) for _, validExt := range config.Extensions { diff --git a/templates_test.go b/templates_test.go index aae6a56..6e21e8b 100644 --- a/templates_test.go +++ b/templates_test.go @@ -113,8 +113,9 @@ func TestFormatIndentationFromTemplates(t *testing.T) { w.Close() }() - // pass check=false and assert no error - if err := processStdin(config, false); err != nil { + // pass check=false and assert no error; helmfile mode is + // selected the same way the CLI does, from the input extension + if err := processStdin(config, false, isHelmfile(testCase.InputFile)); err != nil { t.Fatalf("processStdin failed: %v", err) } diff --git a/templates_test/helmfile_values.yaml b/templates_test/helmfile_values.yaml new file mode 100644 index 0000000..29a3969 --- /dev/null +++ b/templates_test/helmfile_values.yaml @@ -0,0 +1,3 @@ +name: "Helmfile .gotmpl values with env/requiredEnv" +input_file: "templates/helmfile_values.yaml.gotmpl" +expected_file: "templates_expected/helmfile_values.yaml.gotmpl" diff --git a/templates_test/templates/helmfile_values.yaml.gotmpl b/templates_test/templates/helmfile_values.yaml.gotmpl new file mode 100644 index 0000000..5beb220 --- /dev/null +++ b/templates_test/templates/helmfile_values.yaml.gotmpl @@ -0,0 +1,7 @@ +{{- if env "FEATURE_ENABLED" }} +{{ range $name := .Values.releases }} +{{- $token := requiredEnv "GITHUB_TOKEN" }} +name: {{ $name }} +registry: {{ env "REGISTRY" | default "private.azurecr.io" }} +{{- end }} +{{- end }} diff --git a/templates_test/templates_expected/helmfile_values.yaml.gotmpl b/templates_test/templates_expected/helmfile_values.yaml.gotmpl new file mode 100644 index 0000000..ec3c400 --- /dev/null +++ b/templates_test/templates_expected/helmfile_values.yaml.gotmpl @@ -0,0 +1,7 @@ +{{- if env "FEATURE_ENABLED" }} + {{ range $name := .Values.releases }} + {{- $token := requiredEnv "GITHUB_TOKEN" }} +name: {{ $name }} +registry: {{ env "REGISTRY" | default "private.azurecr.io" }} + {{- end }} +{{- end }} From 8fe71972b559127e96b03a1a4263cc6f86fbd72e Mon Sep 17 00:00:00 2001 From: DanielHabenicht Date: Mon, 3 Aug 2026 09:31:07 +0200 Subject: [PATCH 2/2] Always accept helmfile template functions Drop the --helmfile flag and .gotmpl auto-detection; merge helmfile's extra template functions (env, requiredEnv, exec, readFile, ...) into helmFuncMap so helmfmt validates and formats helmfile files by default. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 15 ++++----------- format.go | 35 ++++++++++------------------------- main.go | 30 +++++++++++------------------- templates_test.go | 5 ++--- 4 files changed, 27 insertions(+), 58 deletions(-) diff --git a/README.md b/README.md index 40aefe9..f0e9a39 100644 --- a/README.md +++ b/README.md @@ -229,20 +229,13 @@ docker run --rm -v "$PWD:/work" ghcr.io/digitalstudium/helmfmt:latest \ [Helmfile](https://helmfile.readthedocs.io/) value files use extra template functions on top of Helm/Sprig (`env`, `requiredEnv`, `exec`, `readFile`, -`fetchSecretValue`, ...). By default `helmfmt` validates against Helm's function -set, so these would be reported as `function "env" not defined`. - -Helmfile mode accepts those functions. It is enabled automatically for files -with a `.gotmpl` extension (e.g. `helmfile.yaml.gotmpl`, -`values/demo.yaml.gotmpl`), and can be forced with `--helmfile` (required for -stdin, which has no filename): +`fetchSecretValue`, ...). `helmfmt` accepts these functions as well, so it also +formats helmfile files. The `.gotmpl` extension (e.g. `helmfile.yaml.gotmpl`, +`values/demo.yaml.gotmpl`) is included by default: ```bash -# Auto-detected via the .gotmpl extension helmfmt --check --files helmfile.yaml.gotmpl values/demo.yaml.gotmpl -# Force helmfile mode for any file or for stdin -helmfmt --helmfile --files values/demo.yaml -cat values/demo.yaml | helmfmt --helmfile --check +cat values/demo.yaml | helmfmt --check ``` --- diff --git a/format.go b/format.go index f2fd09f..79801a7 100644 --- a/format.go +++ b/format.go @@ -44,14 +44,16 @@ var ( func stub(args ...interface{}) interface{} { return nil } // helmFuncMap returns a template.FuncMap containing stub registrations for -// every sprig function (minus env/expandenv which Helm drops) plus Helm's own -// additions. Only the presence of each name matters for parse-time syntax -// validation; the stubs are never executed. +// every sprig function (minus env/expandenv which Helm drops), Helm's own +// additions, and the template functions helmfile registers on top of +// Helm/Sprig (env, requiredEnv, exec, readFile, ...). Only the presence of +// each name matters for parse-time syntax validation; the stubs are never +// executed. // // The sprigStubNames slice is generated by `go generate` from the actual // sprig.TxtFuncMap(), so it stays in sync when sprig adds new functions. func helmFuncMap() template.FuncMap { - f := make(template.FuncMap, len(sprigStubNames)+10) + f := make(template.FuncMap, len(sprigStubNames)+25) for _, name := range sprigStubNames { f[name] = stub } @@ -64,16 +66,6 @@ func helmFuncMap() template.FuncMap { for _, name := range helmExtras { f[name] = stub } - return f -} - -// helmfileFuncMap extends helmFuncMap with the template functions helmfile -// registers on top of Helm/Sprig (env, requiredEnv, exec, readFile, ...). As -// with helmFuncMap, only the presence of each name matters for parse-time -// validation; the stubs are never executed. Names already provided by -// Helm/sprig (get, tpl, required, ...) keep their existing registration. -func helmfileFuncMap() template.FuncMap { - f := helmFuncMap() // Helmfile-specific functions not in Helm/sprig helmfileExtras := []string{ "env", "requiredEnv", "exec", "envExec", @@ -88,19 +80,12 @@ func helmfileFuncMap() template.FuncMap { } // validateTemplateSyntax validates the given template source string using the -// Helm function set. When helmfile is true, helmfile's additional template -// functions are also accepted. Returns an error if the template has invalid -// syntax. -func validateTemplateSyntax(src string, helmfile bool) error { - - // Get the applicable function map - funcMap := helmFuncMap() - if helmfile { - funcMap = helmfileFuncMap() - } +// combined Helm/helmfile function set. Returns an error if the template has +// invalid syntax. +func validateTemplateSyntax(src string) error { // Create and parse template with the function map - _, err := template.New("validation").Funcs(funcMap).Parse(src) + _, err := template.New("validation").Funcs(helmFuncMap()).Parse(src) if err != nil { return fmt.Errorf("invalid template syntax: %w", err) } diff --git a/main.go b/main.go index 5b2245a..a97e94d 100644 --- a/main.go +++ b/main.go @@ -79,7 +79,7 @@ func main() { func run() int { config := loadConfig() - var stdout, files, check, helmfile bool + var stdout, files, check bool var disableRules, enableRules []string var rootCmd = &cobra.Command{ @@ -121,12 +121,12 @@ func run() int { if len(args) == 0 { // --files with no args means read filenames from stdin (pre-commit style) if stdinPiped { - return processFilesFromStdin(config, stdout, check, helmfile) + return processFilesFromStdin(config, stdout, check) } return fmt.Errorf("--files requires at least one file argument") } // --files with args means process those files - exitCode := process(args, stdout, check, helmfile, config) + exitCode := process(args, stdout, check, config) if exitCode != 0 { os.Exit(exitCode) } @@ -135,7 +135,7 @@ func run() int { // If stdin is piped and no --files flag, process stdin as content if stdinPiped && len(args) == 0 { - return processStdin(config, check, helmfile) + return processStdin(config, check) } // Chart mode @@ -152,7 +152,7 @@ func run() int { if err != nil { return err } - exitCode := process(chartFiles, false, check, helmfile, config) + exitCode := process(chartFiles, false, check, config) if exitCode != 0 { os.Exit(exitCode) } @@ -163,7 +163,6 @@ func run() int { rootCmd.Flags().BoolVar(&files, "files", false, "Process specific files") rootCmd.Flags().BoolVar(&stdout, "stdout", false, "Output to stdout") rootCmd.Flags().BoolVar(&check, "check", false, "Check formatting without modifying files (exit 1 if unformatted)") - rootCmd.Flags().BoolVar(&helmfile, "helmfile", false, "Accept helmfile template functions (env, requiredEnv, exec, ...); auto-enabled for .gotmpl files") rootCmd.Flags().StringSliceVar(&disableRules, "disable-indent", []string{}, "Disable specific indent rules (e.g., --disable-indent=printf,include)") rootCmd.Flags().StringSliceVar(&enableRules, "enable-indent", []string{}, "Enable specific indent rules (e.g., --enable-indent=printf,include)") @@ -174,7 +173,7 @@ func run() int { return 0 } -func processFilesFromStdin(config *Config, stdout bool, check bool, helmfile bool) error { +func processFilesFromStdin(config *Config, stdout bool, check bool) error { // Read filenames from stdin (one per line) input, err := io.ReadAll(os.Stdin) if err != nil { @@ -194,14 +193,14 @@ func processFilesFromStdin(config *Config, stdout bool, check bool, helmfile boo return fmt.Errorf("no files provided via stdin") } - exitCode := process(filenames, stdout, check, helmfile, config) + exitCode := process(filenames, stdout, check, config) if exitCode != 0 { os.Exit(exitCode) } return nil } -func processStdin(config *Config, check bool, helmfile bool) error { +func processStdin(config *Config, check bool) error { // Read all input from stdin input, err := io.ReadAll(os.Stdin) if err != nil { @@ -211,7 +210,7 @@ func processStdin(config *Config, check bool, helmfile bool) error { orig := string(input) // Validate syntax - if err := validateTemplateSyntax(orig, helmfile); err != nil { + if err := validateTemplateSyntax(orig); err != nil { return fmt.Errorf("invalid syntax: %w", err) } @@ -247,7 +246,7 @@ func collectFiles(root string, config *Config) ([]string, error) { return out, err } -func process(files []string, stdout bool, check bool, helmfile bool, config *Config) int { +func process(files []string, stdout bool, check bool, config *Config) int { var total, updated, failed, unformatted int for _, file := range files { @@ -261,7 +260,7 @@ func process(files []string, stdout bool, check bool, helmfile bool, config *Con } orig := string(b) - if err := validateTemplateSyntax(orig, helmfile || isHelmfile(file)); err != nil { + if err := validateTemplateSyntax(orig); err != nil { fmt.Fprintf(os.Stderr, "[ERROR] Invalid syntax %s: %v\n", file, err) failed++ continue @@ -321,13 +320,6 @@ func process(files []string, stdout bool, check bool, helmfile bool, config *Con return 0 } -// isHelmfile reports whether a path should be validated with helmfile's -// template functions, based on the .gotmpl extension (covers values.yaml.gotmpl, -// helmfile.yaml.gotmpl, etc.). -func isHelmfile(path string) bool { - return strings.EqualFold(filepath.Ext(path), ".gotmpl") -} - func wanted(path string, config *Config) bool { ext := strings.ToLower(filepath.Ext(path)) for _, validExt := range config.Extensions { diff --git a/templates_test.go b/templates_test.go index 6e21e8b..aae6a56 100644 --- a/templates_test.go +++ b/templates_test.go @@ -113,9 +113,8 @@ func TestFormatIndentationFromTemplates(t *testing.T) { w.Close() }() - // pass check=false and assert no error; helmfile mode is - // selected the same way the CLI does, from the input extension - if err := processStdin(config, false, isHelmfile(testCase.InputFile)); err != nil { + // pass check=false and assert no error + if err := processStdin(config, false); err != nil { t.Fatalf("processStdin failed: %v", err) }