From 586408691ec1e3fec27e1f20e4576a57721a2cc2 Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Thu, 20 Nov 2025 12:23:37 +0100 Subject: [PATCH] feat: add print-env command Signed-off-by: Dominik Schmidt --- cmd/print_env.go | 37 ++++ cmd/root.go | 1 + pkg/app/config.go | 4 + pkg/app/print_env.go | 85 ++++++++ pkg/app/print_env_test.go | 440 ++++++++++++++++++++++++++++++++++++++ pkg/config/print_env.go | 33 +++ 6 files changed, 600 insertions(+) create mode 100644 cmd/print_env.go create mode 100644 pkg/app/print_env.go create mode 100644 pkg/app/print_env_test.go create mode 100644 pkg/config/print_env.go diff --git a/cmd/print_env.go b/cmd/print_env.go new file mode 100644 index 00000000..8d44309c --- /dev/null +++ b/cmd/print_env.go @@ -0,0 +1,37 @@ +package cmd + +import ( + "github.com/spf13/cobra" + + "github.com/helmfile/helmfile/pkg/app" + "github.com/helmfile/helmfile/pkg/config" +) + +// NewPrintEnvCmd returns print-env subcmd +func NewPrintEnvCmd(globalCfg *config.GlobalImpl) *cobra.Command { + printEnvOptions := config.NewPrintEnvOptions() + + cmd := &cobra.Command{ + Use: "print-env", + Short: "Print parsed environment configuration including values and secrets", + RunE: func(cmd *cobra.Command, args []string) error { + printEnvImpl := config.NewPrintEnvImpl(globalCfg, printEnvOptions) + err := config.NewCLIConfigImpl(printEnvImpl.GlobalImpl) + if err != nil { + return err + } + + if err := printEnvImpl.ValidateConfig(); err != nil { + return err + } + + a := app.New(printEnvImpl) + return toCLIError(printEnvImpl.GlobalImpl, a.PrintEnv(printEnvImpl)) + }, + } + + f := cmd.Flags() + f.StringVar(&printEnvOptions.Output, "output", "yaml", "output format: yaml or json") + + return cmd +} diff --git a/cmd/root.go b/cmd/root.go index b2abbdd1..eedf844d 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -104,6 +104,7 @@ func NewRootCmd(globalConfig *config.GlobalOptions) (*cobra.Command, error) { NewDiffCmd(globalImpl), NewStatusCmd(globalImpl), NewShowDAGCmd(globalImpl), + NewPrintEnvCmd(globalImpl), extension.NewVersionCobraCmd( versionOpts..., ), diff --git a/pkg/app/config.go b/pkg/app/config.go index 1762bb4d..f8a070c1 100644 --- a/pkg/app/config.go +++ b/pkg/app/config.go @@ -294,6 +294,10 @@ type InitConfigProvider interface { Force() bool } +type PrintEnvConfigProvider interface { + Output() string +} + // reset/reuse values helm cli flags handling for apply/sync/diff type valuesControlMode interface { ReuseValues() bool diff --git a/pkg/app/print_env.go b/pkg/app/print_env.go new file mode 100644 index 00000000..138c1300 --- /dev/null +++ b/pkg/app/print_env.go @@ -0,0 +1,85 @@ +package app + +import ( + "encoding/json" + "fmt" + + "github.com/helmfile/helmfile/pkg/yaml" +) + +// PrintEnv prints the parsed environment configuration +func (a *App) PrintEnv(c PrintEnvConfigProvider) error { + docCount := 0 + + err := a.ForEachState(func(run *Run) (_ bool, errs []error) { + st := run.state + + // Get merged values (includes secrets if present) + values, err := st.Env.GetMergedValues() + if err != nil { + return false, []error{fmt.Errorf("failed to get merged values: %w", err)} + } + + // Get full absolute path to identify which helmfile this environment comes from + filePath := st.FilePath + if fullPath, err := st.FullFilePath(); err != nil { + a.Logger.Warnf("failed to get full file path for %s: %v", st.FilePath, err) + } else { + filePath = fullPath + } + + // Prepare output structure - include file path to identify source + output := map[string]any{ + "filePath": filePath, + "name": st.Env.Name, + "kubeContext": st.Env.KubeContext, + "values": values, + } + + // Marshal based on output format + var outputBytes []byte + switch c.Output() { + case "json": + // For JSON, print array of documents + if docCount > 0 { + fmt.Println(",") + } else { + fmt.Println("[") + } + outputBytes, err = json.MarshalIndent(output, " ", " ") + if err != nil { + return false, []error{fmt.Errorf("failed to marshal to JSON: %w", err)} + } + fmt.Print(" ") + fmt.Print(string(outputBytes)) + case "yaml", "": + // For YAML, use multi-document format with --- separator + if docCount > 0 { + fmt.Println("---") + } + outputBytes, err = yaml.Marshal(output) + if err != nil { + return false, []error{fmt.Errorf("failed to marshal to YAML: %w", err)} + } + fmt.Print(string(outputBytes)) + default: + return false, []error{fmt.Errorf("unsupported output format: %s (supported: yaml, json)", c.Output())} + } + + docCount++ + return false, nil + }, false) + + // Close JSON array + if c.Output() == "json" && docCount > 0 { + fmt.Println() + fmt.Println("]") + } + + // Suppress "no releases found" error - print-env doesn't need releases + if _, ok := err.(*NoMatchingHelmfileError); ok { + return nil + } + + return err +} diff --git a/pkg/app/print_env_test.go b/pkg/app/print_env_test.go new file mode 100644 index 00000000..1e531506 --- /dev/null +++ b/pkg/app/print_env_test.go @@ -0,0 +1,440 @@ +package app + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "github.com/helmfile/vals" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" + + ffs "github.com/helmfile/helmfile/pkg/filesystem" + "github.com/helmfile/helmfile/pkg/helmexec" + "github.com/helmfile/helmfile/pkg/testhelper" + "github.com/helmfile/helmfile/pkg/testutil" +) + +func TestPrintEnv_SingleHelmfile_YAML(t *testing.T) { + files := map[string]string{ + "/path/to/helmfile.yaml": ` +environments: + production: + kubeContext: prod-cluster + values: + - region: us-east-1 + environment: production + debug: false +--- +releases: [] +`, + } + + app := createTestApp(t, files, "production") + cfg := configImpl{output: "yaml"} + + out, err := testutil.CaptureStdout(func() { + err := app.PrintEnv(cfg) + assert.NoError(t, err) + }) + require.NoError(t, err) + + // Parse YAML output + var result map[string]any + err = yaml.Unmarshal([]byte(out), &result) + require.NoError(t, err) + + // Verify structure + assert.Equal(t, "production", result["name"]) + assert.Equal(t, "prod-cluster", result["kubeContext"]) + assert.Contains(t, result["filePath"], "helmfile.yaml") + + // Verify values + values, ok := result["values"].(map[string]any) + require.True(t, ok, "values should be a map") + assert.Equal(t, "us-east-1", values["region"]) + assert.Equal(t, "production", values["environment"]) + assert.Equal(t, false, values["debug"]) +} + +func TestPrintEnv_SingleHelmfile_JSON(t *testing.T) { + files := map[string]string{ + "/path/to/helmfile.yaml": ` +environments: + staging: + kubeContext: staging-cluster + values: + - database: + host: db.staging.local + port: 5432 +--- +releases: [] +`, + } + + app := createTestApp(t, files, "staging") + cfg := configImpl{output: "json"} + + out, err := testutil.CaptureStdout(func() { + err := app.PrintEnv(cfg) + assert.NoError(t, err) + }) + require.NoError(t, err) + + // Parse JSON output (should be an array) + var results []map[string]any + err = json.Unmarshal([]byte(out), &results) + require.NoError(t, err) + require.Len(t, results, 1, "should have one environment") + + result := results[0] + assert.Equal(t, "staging", result["name"]) + assert.Equal(t, "staging-cluster", result["kubeContext"]) + assert.Contains(t, result["filePath"], "helmfile.yaml") + + // Verify nested values + values, ok := result["values"].(map[string]any) + require.True(t, ok) + database, ok := values["database"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "db.staging.local", database["host"]) + assert.Equal(t, float64(5432), database["port"]) // JSON numbers are float64 +} + +func TestPrintEnv_MultipleHelmfiles_YAML(t *testing.T) { + files := map[string]string{ + "/path/to/helmfile.yaml": ` +environments: + dev: + kubeContext: main-context + values: + - source: main + sharedValue: from-main +--- +helmfiles: + - path: sub/helmfile.yaml +releases: [] +`, + "/path/to/sub/helmfile.yaml": ` +environments: + dev: + kubeContext: sub-context + values: + - source: sub + subValue: from-sub +--- +releases: [] +`, + } + + app := createTestApp(t, files, "dev") + cfg := configImpl{output: "yaml"} + + out, err := testutil.CaptureStdout(func() { + err := app.PrintEnv(cfg) + assert.NoError(t, err) + }) + require.NoError(t, err) + + // Split by --- to get individual documents + docs := strings.Split(out, "---\n") + // Filter out empty documents + var nonEmptyDocs []string + for _, doc := range docs { + trimmed := strings.TrimSpace(doc) + if trimmed != "" { + nonEmptyDocs = append(nonEmptyDocs, trimmed) + } + } + + assert.GreaterOrEqual(t, len(nonEmptyDocs), 2, "should have at least 2 environment documents") + + // Verify each document is valid YAML + for i, doc := range nonEmptyDocs { + var result map[string]any + err := yaml.Unmarshal([]byte(doc), &result) + require.NoError(t, err, "document %d should be valid YAML", i) + assert.Equal(t, "dev", result["name"], "document %d should have correct name", i) + assert.Contains(t, result, "kubeContext", "document %d should have kubeContext", i) + assert.Contains(t, result, "values", "document %d should have values", i) + assert.Contains(t, result, "filePath", "document %d should have filePath", i) + } +} + +func TestPrintEnv_MultipleHelmfiles_JSON(t *testing.T) { + files := map[string]string{ + "/path/to/helmfile.yaml": ` +environments: + test: + kubeContext: main-kube + values: + - mainKey: mainValue +--- +helmfiles: + - path: child1/helmfile.yaml + - path: child2/helmfile.yaml +releases: [] +`, + "/path/to/child1/helmfile.yaml": ` +environments: + test: + kubeContext: child1-kube + values: + - child1Key: child1Value +--- +releases: [] +`, + "/path/to/child2/helmfile.yaml": ` +environments: + test: + kubeContext: child2-kube + values: + - child2Key: child2Value +--- +releases: [] +`, + } + + app := createTestApp(t, files, "test") + cfg := configImpl{output: "json"} + + out, err := testutil.CaptureStdout(func() { + err := app.PrintEnv(cfg) + assert.NoError(t, err) + }) + require.NoError(t, err) + + // Parse JSON array + var results []map[string]any + err = json.Unmarshal([]byte(out), &results) + require.NoError(t, err) + assert.GreaterOrEqual(t, len(results), 3, "should have at least 3 environments") + + // Verify all have correct structure + for i, result := range results { + assert.Equal(t, "test", result["name"], "result %d should have name 'test'", i) + assert.Contains(t, result, "kubeContext", "result %d should have kubeContext", i) + assert.Contains(t, result, "values", "result %d should have values", i) + assert.Contains(t, result, "filePath", "result %d should have filePath", i) + } +} + +func TestPrintEnv_WithDefaults(t *testing.T) { + files := map[string]string{ + "/path/to/helmfile.yaml": ` +environments: + default: + values: + - color: blue + prod: + values: + - color: red + size: large +--- +releases: [] +`, + } + + app := createTestApp(t, files, "prod") + cfg := configImpl{output: "json"} + + out, err := testutil.CaptureStdout(func() { + err := app.PrintEnv(cfg) + assert.NoError(t, err) + }) + require.NoError(t, err) + + var results []map[string]any + err = json.Unmarshal([]byte(out), &results) + require.NoError(t, err) + require.Len(t, results, 1) + + values, ok := results[0]["values"].(map[string]any) + require.True(t, ok) + + // Should have values from prod environment + assert.Equal(t, "red", values["color"]) + assert.Equal(t, "large", values["size"]) +} + +func TestPrintEnv_InvalidOutputFormat(t *testing.T) { + files := map[string]string{ + "/path/to/helmfile.yaml": ` +environments: + dev: + values: + - test: value +--- +releases: [] +`, + } + + app := createTestApp(t, files, "dev") + cfg := configImpl{output: "xml"} // Invalid format + + _, err := testutil.CaptureStdout(func() { + err := app.PrintEnv(cfg) + assert.Error(t, err) + assert.Contains(t, err.Error(), "unsupported output format") + }) + require.NoError(t, err) +} + +func TestPrintEnv_EmptyValues(t *testing.T) { + files := map[string]string{ + "/path/to/helmfile.yaml": ` +environments: + minimal: + kubeContext: minimal-cluster +--- +releases: [] +`, + } + + app := createTestApp(t, files, "minimal") + cfg := configImpl{output: "json"} + + out, err := testutil.CaptureStdout(func() { + err := app.PrintEnv(cfg) + assert.NoError(t, err) + }) + require.NoError(t, err) + + var results []map[string]any + err = json.Unmarshal([]byte(out), &results) + require.NoError(t, err) + require.Len(t, results, 1) + + result := results[0] + assert.Equal(t, "minimal", result["name"]) + assert.Equal(t, "minimal-cluster", result["kubeContext"]) + + // Values should exist but be empty + values, ok := result["values"].(map[string]any) + require.True(t, ok) + assert.Empty(t, values) +} + +func TestPrintEnv_NoKubeContext(t *testing.T) { + files := map[string]string{ + "/path/to/helmfile.yaml": ` +environments: + local: + values: + - app: myapp +--- +releases: [] +`, + } + + app := createTestApp(t, files, "local") + cfg := configImpl{output: "yaml"} + + out, err := testutil.CaptureStdout(func() { + err := app.PrintEnv(cfg) + assert.NoError(t, err) + }) + require.NoError(t, err) + + var result map[string]any + err = yaml.Unmarshal([]byte(out), &result) + require.NoError(t, err) + + assert.Equal(t, "local", result["name"]) + // kubeContext should be present but empty + kubeContext, exists := result["kubeContext"] + assert.True(t, exists) + assert.Equal(t, "", kubeContext) +} + +func TestPrintEnv_DefaultOutput(t *testing.T) { + // When output is empty string, should default to YAML + files := map[string]string{ + "/path/to/helmfile.yaml": ` +environments: + dev: + values: + - key: value +--- +releases: [] +`, + } + + app := createTestApp(t, files, "dev") + cfg := configImpl{output: ""} // Empty output should default to yaml + + out, err := testutil.CaptureStdout(func() { + err := app.PrintEnv(cfg) + assert.NoError(t, err) + }) + require.NoError(t, err) + + // Should be valid YAML + var result map[string]any + err = yaml.Unmarshal([]byte(out), &result) + require.NoError(t, err, "empty output format should default to YAML") +} + +func TestPrintEnv_UndefinedEnvironment(t *testing.T) { + // Test behavior with undefined environment + files := map[string]string{ + "/path/to/helmfile.yaml": ` +environments: + production: + values: + - env: prod +--- +releases: [] +`, + } + + app := createTestApp(t, files, "staging") // staging is not defined + cfg := configImpl{output: "json"} + + out, err := testutil.CaptureStdout(func() { + err := app.PrintEnv(cfg) + // The behavior depends on helmfile's environment handling + // It may succeed with empty values or fail + if err != nil { + assert.Contains(t, err.Error(), "environment") + } + }) + require.NoError(t, err) + + // If no error, output should be valid JSON (potentially empty array) + if out != "" { + var results []map[string]any + err = json.Unmarshal([]byte(out), &results) + // Should either be valid JSON or empty + if err != nil { + assert.Equal(t, "", out, "if not valid JSON, output should be empty") + } + } +} + +// Helper function to create test app with common setup +func createTestApp(t *testing.T, files map[string]string, environment string) *App { + t.Helper() + + valsRuntime, err := vals.New(vals.Options{CacheSize: 32}) + require.NoError(t, err) + + var buffer bytes.Buffer + syncWriter := testhelper.NewSyncWriter(&buffer) + logger := helmexec.NewLogger(syncWriter, "warn") + + app := appWithFs(&App{ + OverrideHelmBinary: DefaultHelmBinary, + fs: ffs.DefaultFileSystem(), + OverrideKubeContext: "default", + Env: environment, + Logger: logger, + valsRuntime: valsRuntime, + }, files) + + expectNoCallsToHelm(app) + + return app +} diff --git a/pkg/config/print_env.go b/pkg/config/print_env.go new file mode 100644 index 00000000..eef9333b --- /dev/null +++ b/pkg/config/print_env.go @@ -0,0 +1,33 @@ +package config + +// PrintEnvOptions is the options for the print-env command +type PrintEnvOptions struct { + // Output is the output format (yaml or json) + Output string +} + +// NewPrintEnvOptions creates a new PrintEnvOptions +func NewPrintEnvOptions() *PrintEnvOptions { + return &PrintEnvOptions{ + Output: "yaml", // default to yaml + } +} + +// PrintEnvImpl is impl for PrintEnvOptions +type PrintEnvImpl struct { + *GlobalImpl + *PrintEnvOptions +} + +// NewPrintEnvImpl creates a new PrintEnvImpl +func NewPrintEnvImpl(g *GlobalImpl, p *PrintEnvOptions) *PrintEnvImpl { + return &PrintEnvImpl{ + GlobalImpl: g, + PrintEnvOptions: p, + } +} + +// Output returns the output format +func (c *PrintEnvImpl) Output() string { + return c.PrintEnvOptions.Output +}