From 17c1d0390eb063ca85f6a53c49e23136fe128180 Mon Sep 17 00:00:00 2001 From: Ruslan Shaydullin Date: Thu, 2 Jul 2026 16:03:41 +0500 Subject: [PATCH 1/2] feat(env): add --output json|yaml to 'helm env' 'helm env' only emits KEY="VALUE" lines, which is inconvenient for tooling that needs the helm paths on the current system (#11909). Add an --output/-o flag accepting env (the default, unchanged), json, and yaml. Single-variable mode keeps printing the bare value by default and emits a one-entry object for the structured formats. Closes #11909 Signed-off-by: Ruslan Shaydullin --- pkg/cmd/env.go | 113 +++++++++++++-- pkg/cmd/env_test.go | 133 ++++++++++++++++++ pkg/cmd/testdata/output/env-output-comp.txt | 5 + .../testdata/output/env-output-invalid.txt | 1 + 4 files changed, 241 insertions(+), 11 deletions(-) create mode 100644 pkg/cmd/testdata/output/env-output-comp.txt create mode 100644 pkg/cmd/testdata/output/env-output-invalid.txt diff --git a/pkg/cmd/env.go b/pkg/cmd/env.go index 8da201031..ea38c00d0 100644 --- a/pkg/cmd/env.go +++ b/pkg/cmd/env.go @@ -19,10 +19,15 @@ package cmd import ( "fmt" "io" + "log" + "maps" + "slices" "sort" + "strings" "github.com/spf13/cobra" + "helm.sh/helm/v4/pkg/cli/output" "helm.sh/helm/v4/pkg/cmd/require" ) @@ -31,6 +36,7 @@ Env prints out all the environment information in use by Helm. ` func newEnvCmd(out io.Writer) *cobra.Command { + outfmt := envFormatKeyValue cmd := &cobra.Command{ Use: "env", Short: "helm client environment information", @@ -44,22 +50,18 @@ func newEnvCmd(out io.Writer) *cobra.Command { return noMoreArgsComp() }, - Run: func(_ *cobra.Command, args []string) { + RunE: func(_ *cobra.Command, args []string) error { envVars := settings.EnvVars() - if len(args) == 0 { - // Sort the variables by alphabetical order. - // This allows for a constant output across calls to 'helm env'. - keys := getSortedEnvVarKeys() - - for _, k := range keys { - fmt.Fprintf(out, "%s=\"%s\"\n", k, envVars[k]) - } - } else { - fmt.Fprintf(out, "%s\n", envVars[args[0]]) + if len(args) == 1 { + return outfmt.writeSingle(out, args[0], envVars[args[0]]) } + return outfmt.write(out, envVars) }, } + + bindEnvOutputFlag(cmd, &outfmt) + return cmd } @@ -74,3 +76,92 @@ func getSortedEnvVarKeys() []string { return keys } + +// envFormat is the set of output formats supported by 'helm env'. The +// KEY="VALUE" shell format predates the structured formats and stays the +// default; it is not part of output.Format because no other command emits it. +type envFormat string + +const ( + envFormatKeyValue envFormat = "env" + envFormatJSON envFormat = "json" + envFormatYAML envFormat = "yaml" +) + +func envFormats() []string { + return []string{string(envFormatKeyValue), string(envFormatJSON), string(envFormatYAML)} +} + +func envFormatsWithDesc() map[string]string { + return map[string]string{ + string(envFormatKeyValue): `Output result in KEY="VALUE" format`, + string(envFormatJSON): "Output result in JSON format", + string(envFormatYAML): "Output result in YAML format", + } +} + +// String, Set and Type make envFormat usable as a pflag.Value. +func (o *envFormat) String() string { return string(*o) } + +func (o *envFormat) Type() string { return "format" } + +func (o *envFormat) Set(s string) error { + if !slices.Contains(envFormats(), s) { + return output.ErrInvalidFormatType + } + *o = envFormat(s) + return nil +} + +// write prints every variable in the given format. +func (o envFormat) write(out io.Writer, envVars map[string]string) error { + switch o { + case envFormatKeyValue: + // Sort the variables by alphabetical order. + // This allows for a constant output across calls to 'helm env'. + for _, k := range slices.Sorted(maps.Keys(envVars)) { + fmt.Fprintf(out, "%s=\"%s\"\n", k, envVars[k]) + } + return nil + case envFormatJSON: + return output.EncodeJSON(out, envVars) + case envFormatYAML: + return output.EncodeYAML(out, envVars) + } + return output.ErrInvalidFormatType +} + +// writeSingle prints one variable. The key=value format keeps the historic +// behavior of printing the bare value only. +func (o envFormat) writeSingle(out io.Writer, key, value string) error { + switch o { + case envFormatKeyValue: + fmt.Fprintf(out, "%s\n", value) + return nil + case envFormatJSON: + return output.EncodeJSON(out, map[string]string{key: value}) + case envFormatYAML: + return output.EncodeYAML(out, map[string]string{key: value}) + } + return output.ErrInvalidFormatType +} + +func bindEnvOutputFlag(cmd *cobra.Command, varRef *envFormat) { + cmd.Flags().VarP(varRef, outputFlag, "o", + "prints the output in the specified format. Allowed values: "+strings.Join(envFormats(), ", ")) + + err := cmd.RegisterFlagCompletionFunc(outputFlag, func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { + var formatNames []string + for format, desc := range envFormatsWithDesc() { + formatNames = append(formatNames, fmt.Sprintf("%s\t%s", format, desc)) + } + + // Sort the results to get a deterministic order for the tests + sort.Strings(formatNames) + return formatNames, cobra.ShellCompDirectiveNoFileComp + }) + + if err != nil { + log.Fatal(err) + } +} diff --git a/pkg/cmd/env_test.go b/pkg/cmd/env_test.go index c5d7af1b7..572583d09 100644 --- a/pkg/cmd/env_test.go +++ b/pkg/cmd/env_test.go @@ -17,6 +17,10 @@ limitations under the License. package cmd import ( + "bytes" + "encoding/json" + "regexp" + "strings" "testing" ) @@ -25,10 +29,139 @@ func TestEnv(t *testing.T) { name: "completion for env", cmd: "__complete env ''", golden: "output/env-comp.txt", + }, { + name: "completion for env output flag", + cmd: "__complete env --output ''", + golden: "output/env-output-comp.txt", + }, { + name: "env with invalid output format", + cmd: "env --output table", + golden: "output/env-output-invalid.txt", + wantError: true, }} runTestCmd(t, tests) } +func TestEnvOutputDefault(t *testing.T) { + // Pin the default format: without --output, every line must keep the + // historic KEY="VALUE" shape. The values are machine-specific, so + // assert the shape rather than compare against a golden file. + _, out, err := executeActionCommand("env") + if err != nil { + t.Fatal(err) + } + + if !strings.Contains(out, `HELM_BIN="`) { + t.Errorf("expected HELM_BIN in default output, got %q", out) + } + lineRe := regexp.MustCompile(`^[A-Z0-9_]+=".*"$`) + for line := range strings.SplitSeq(strings.TrimSuffix(out, "\n"), "\n") { + if !lineRe.MatchString(line) { + t.Errorf("line %q does not match KEY=\"VALUE\" format", line) + } + } + + // Single-variable mode without --output keeps printing the bare value. + _, out, err = executeActionCommand("env HELM_BURST_LIMIT") + if err != nil { + t.Fatal(err) + } + if out != "100\n" { + t.Errorf("expected %q, got %q", "100\n", out) + } +} + +func TestEnvOutputJSON(t *testing.T) { + // The full listing contains machine-specific paths, so validate the + // shape instead of comparing against a golden file. + _, out, err := executeActionCommand("env --output json") + if err != nil { + t.Fatal(err) + } + + var envVars map[string]string + if err := json.Unmarshal([]byte(out), &envVars); err != nil { + t.Fatalf("expected valid JSON output, got %q: %s", out, err) + } + if _, ok := envVars["HELM_BIN"]; !ok { + t.Errorf("expected HELM_BIN in JSON output, got %v", envVars) + } +} + +func TestEnvFormatWrite(t *testing.T) { + envVars := map[string]string{ + "HELM_BIN": "/usr/local/bin/helm", + "HELM_DEBUG": "false", + "HELM_NAMESPACE": "default", + } + + tests := []struct { + name string + format envFormat + want string + }{{ + name: "key=value format", + format: envFormatKeyValue, + want: `HELM_BIN="/usr/local/bin/helm" +HELM_DEBUG="false" +HELM_NAMESPACE="default" +`, + }, { + name: "json format", + format: envFormatJSON, + want: `{"HELM_BIN":"/usr/local/bin/helm","HELM_DEBUG":"false","HELM_NAMESPACE":"default"}` + "\n", + }, { + name: "yaml format", + format: envFormatYAML, + want: `HELM_BIN: /usr/local/bin/helm +HELM_DEBUG: "false" +HELM_NAMESPACE: default +`, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var buf bytes.Buffer + if err := tt.format.write(&buf, envVars); err != nil { + t.Fatal(err) + } + if got := buf.String(); got != tt.want { + t.Errorf("expected %q, got %q", tt.want, got) + } + }) + } +} + +func TestEnvFormatWriteSingle(t *testing.T) { + tests := []struct { + name string + format envFormat + want string + }{{ + name: "key=value format prints the bare value", + format: envFormatKeyValue, + want: "default\n", + }, { + name: "json format", + format: envFormatJSON, + want: `{"HELM_NAMESPACE":"default"}` + "\n", + }, { + name: "yaml format", + format: envFormatYAML, + want: "HELM_NAMESPACE: default\n", + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var buf bytes.Buffer + if err := tt.format.writeSingle(&buf, "HELM_NAMESPACE", "default"); err != nil { + t.Fatal(err) + } + if got := buf.String(); got != tt.want { + t.Errorf("expected %q, got %q", tt.want, got) + } + }) + } +} + func TestEnvFileCompletion(t *testing.T) { checkFileCompletion(t, "env", false) checkFileCompletion(t, "env HELM_BIN", false) diff --git a/pkg/cmd/testdata/output/env-output-comp.txt b/pkg/cmd/testdata/output/env-output-comp.txt new file mode 100644 index 000000000..cd054b1e1 --- /dev/null +++ b/pkg/cmd/testdata/output/env-output-comp.txt @@ -0,0 +1,5 @@ +env Output result in KEY="VALUE" format +json Output result in JSON format +yaml Output result in YAML format +:4 +Completion ended with directive: ShellCompDirectiveNoFileComp diff --git a/pkg/cmd/testdata/output/env-output-invalid.txt b/pkg/cmd/testdata/output/env-output-invalid.txt new file mode 100644 index 000000000..e57d55e82 --- /dev/null +++ b/pkg/cmd/testdata/output/env-output-invalid.txt @@ -0,0 +1 @@ +Error: invalid argument "table" for "-o, --output" flag: invalid format type From b39ce53e7a1deb0a8f35155ec460fc415e26c249 Mon Sep 17 00:00:00 2001 From: Ruslan Shaydullin Date: Tue, 7 Jul 2026 10:31:38 +0500 Subject: [PATCH 2/2] test(env): pin BurstLimit so the bare-value assertion is hermetic TestEnvOutputDefault asserted the literal default '100', so an ambient HELM_BURST_LIMIT in the test process environment would fail the run and the assertion was coupled to the current default value. Pin settings.BurstLimit for the single-variable check instead. Suggested by the Copilot review on #32282. Signed-off-by: Ruslan Shaydullin --- pkg/cmd/env_test.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/env_test.go b/pkg/cmd/env_test.go index 572583d09..02e024c99 100644 --- a/pkg/cmd/env_test.go +++ b/pkg/cmd/env_test.go @@ -62,12 +62,18 @@ func TestEnvOutputDefault(t *testing.T) { } // Single-variable mode without --output keeps printing the bare value. + // Pin BurstLimit so the expected value cannot drift with the ambient + // HELM_BURST_LIMIT of the test process. + oldBurstLimit := settings.BurstLimit + settings.BurstLimit = 150 + t.Cleanup(func() { settings.BurstLimit = oldBurstLimit }) + _, out, err = executeActionCommand("env HELM_BURST_LIMIT") if err != nil { t.Fatal(err) } - if out != "100\n" { - t.Errorf("expected %q, got %q", "100\n", out) + if out != "150\n" { + t.Errorf("expected %q, got %q", "150\n", out) } }