From d5e02102f4a347d6d70ad9c9cfd0d92fd69b1702 Mon Sep 17 00:00:00 2001 From: bo0tzz Date: Fri, 3 Oct 2025 20:47:39 +0200 Subject: [PATCH 1/2] feat: add local subcommand to compare two chart folders --- README.md | 58 ++++++++++++ cmd/local.go | 234 ++++++++++++++++++++++++++++++++++++++++++++++ cmd/local_test.go | 82 ++++++++++++++++ cmd/root.go | 1 + 4 files changed, 375 insertions(+) create mode 100644 cmd/local.go create mode 100644 cmd/local_test.go diff --git a/README.md b/README.md index 5fcde05c..82053781 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,7 @@ Usage: Available Commands: completion Generate the autocompletion script for the specified shell + local Shows diff between two local chart directories release Shows diff between release's manifests revision Shows diff between revision's manifests rollback Show a diff explaining what a helm rollback could perform @@ -134,6 +135,63 @@ Use "diff [command] --help" for more information about a command. ## Commands: +### local: + +``` +$ helm diff local -h + +This command compares the manifests of two local chart directories. + +It renders both charts using 'helm template' and shows the differences +between the resulting manifests. + +This is useful for: + - Comparing different versions of a chart + - Previewing changes before committing + - Validating chart modifications + +Usage: + diff local [flags] CHART1 CHART2 + +Examples: + helm diff local ./chart-v1 ./chart-v2 + helm diff local ./chart-v1 ./chart-v2 -f values.yaml + helm diff local /path/to/chart-a /path/to/chart-b --set replicas=3 + +Flags: + -a, --api-versions stringArray Kubernetes api versions used for Capabilities.APIVersions + -C, --context int output NUM lines of context around changes (default -1) + --detailed-exitcode return a non-zero exit code when there are changes + --enable-dns enable DNS lookups when rendering templates + -D, --find-renames float32 Enable rename detection if set to any value greater than 0. If specified, the value denotes the maximum fraction of changed content as lines added + removed compared to total lines in a diff for considering it a rename. Only objects of the same Kind are attempted to be matched + -h, --help help for local + --include-crds include CRDs in the diffing + --include-tests enable the diffing of the helm test hooks + --kube-version string Kubernetes version used for Capabilities.KubeVersion + --namespace string namespace to use for template rendering + --normalize-manifests normalize manifests before running diff to exclude style differences from the output + --output string Possible values: diff, simple, template, dyff. When set to "template", use the env var HELM_DIFF_TPL to specify the template. (default "diff") + --post-renderer string the path to an executable to be used for post rendering. If it exists in $PATH, the binary will be used, otherwise it will try to look for the executable at the given path + --post-renderer-args stringArray an argument to the post-renderer (can specify multiple) + --release string release name to use for template rendering (default "release") + --set stringArray set values on the command line (can specify multiple or separate values with commas: key1=val1,key2=val2) + --set-file stringArray set values from respective files specified via the command line (can specify multiple or separate values with commas: key1=path1,key2=path2) + --set-json stringArray set JSON values on the command line (can specify multiple or separate values with commas: key1=jsonval1,key2=jsonval2) + --set-literal stringArray set STRING literal values on the command line + --set-string stringArray set STRING values on the command line (can specify multiple or separate values with commas: key1=val1,key2=val2) + --show-secrets do not redact secret values in the output + --show-secrets-decoded decode secret values in the output + --strip-trailing-cr strip trailing carriage return on input + --suppress stringArray allows suppression of the kinds listed in the diff output (can specify multiple, like '--suppress Deployment --suppress Service') + --suppress-output-line-regex stringArray a regex to suppress diff output lines that match + -q, --suppress-secrets suppress secrets in the output + -f, --values valueFiles specify values in a YAML file (can specify multiple) (default []) + +Global Flags: + --color color output. You can control the value for this flag via HELM_DIFF_COLOR=[true|false]. If both --no-color and --color are unspecified, coloring enabled only when the stdout is a term and TERM is not "dumb" + --no-color remove colors from the output. If both --no-color and --color are unspecified, coloring enabled only when the stdout is a term and TERM is not "dumb" +``` + ### upgrade: ``` diff --git a/cmd/local.go b/cmd/local.go new file mode 100644 index 00000000..bbcf2f02 --- /dev/null +++ b/cmd/local.go @@ -0,0 +1,234 @@ +package cmd + +import ( + "errors" + "fmt" + "io" + "os" + "os/exec" + "strings" + + "github.com/spf13/cobra" + + "github.com/databus23/helm-diff/v3/diff" + "github.com/databus23/helm-diff/v3/manifest" +) + +type local struct { + chart1 string + chart2 string + release string + namespace string + detailedExitCode bool + includeTests bool + includeCRDs bool + normalizeManifests bool + enableDNS bool + valueFiles valueFiles + values []string + stringValues []string + stringLiteralValues []string + jsonValues []string + fileValues []string + postRenderer string + postRendererArgs []string + extraAPIs []string + kubeVersion string + diff.Options +} + +const localCmdLongUsage = ` +This command compares the manifests of two local chart directories. + +It renders both charts using 'helm template' and shows the differences +between the resulting manifests. + +This is useful for: + - Comparing different versions of a chart + - Previewing changes before committing + - Validating chart modifications +` + +func localCmd() *cobra.Command { + diff := local{ + release: "release", + } + + localCmd := &cobra.Command{ + Use: "local [flags] CHART1 CHART2", + Short: "Shows diff between two local chart directories", + Long: localCmdLongUsage, + Example: strings.Join([]string{ + " helm diff local ./chart-v1 ./chart-v2", + " helm diff local ./chart-v1 ./chart-v2 -f values.yaml", + " helm diff local /path/to/chart-a /path/to/chart-b --set replicas=3", + }, "\n"), + RunE: func(cmd *cobra.Command, args []string) error { + // Suppress the command usage on error. See #77 for more info + cmd.SilenceUsage = true + + if v, _ := cmd.Flags().GetBool("version"); v { + fmt.Println(Version) + return nil + } + + if err := checkArgsLength(len(args), "chart1 path", "chart2 path"); err != nil { + return err + } + + ProcessDiffOptions(cmd.Flags(), &diff.Options) + + diff.chart1 = args[0] + diff.chart2 = args[1] + + if diff.namespace == "" { + diff.namespace = os.Getenv("HELM_NAMESPACE") + } + + return diff.run() + }, + } + + localCmd.Flags().StringVar(&diff.release, "release", "release", "release name to use for template rendering") + localCmd.Flags().StringVar(&diff.namespace, "namespace", "", "namespace to use for template rendering") + localCmd.Flags().BoolVar(&diff.detailedExitCode, "detailed-exitcode", false, "return a non-zero exit code when there are changes") + localCmd.Flags().BoolVar(&diff.includeTests, "include-tests", false, "enable the diffing of the helm test hooks") + localCmd.Flags().BoolVar(&diff.includeCRDs, "include-crds", false, "include CRDs in the diffing") + localCmd.Flags().BoolVar(&diff.normalizeManifests, "normalize-manifests", false, "normalize manifests before running diff to exclude style differences from the output") + localCmd.Flags().BoolVar(&diff.enableDNS, "enable-dns", false, "enable DNS lookups when rendering templates") + localCmd.Flags().VarP(&diff.valueFiles, "values", "f", "specify values in a YAML file (can specify multiple)") + localCmd.Flags().StringArrayVar(&diff.values, "set", []string{}, "set values on the command line (can specify multiple or separate values with commas: key1=val1,key2=val2)") + localCmd.Flags().StringArrayVar(&diff.stringValues, "set-string", []string{}, "set STRING values on the command line (can specify multiple or separate values with commas: key1=val1,key2=val2)") + localCmd.Flags().StringArrayVar(&diff.stringLiteralValues, "set-literal", []string{}, "set STRING literal values on the command line") + localCmd.Flags().StringArrayVar(&diff.jsonValues, "set-json", []string{}, "set JSON values on the command line (can specify multiple or separate values with commas: key1=jsonval1,key2=jsonval2)") + localCmd.Flags().StringArrayVar(&diff.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)") + localCmd.Flags().StringVar(&diff.postRenderer, "post-renderer", "", "the path to an executable to be used for post rendering. If it exists in $PATH, the binary will be used, otherwise it will try to look for the executable at the given path") + localCmd.Flags().StringArrayVar(&diff.postRendererArgs, "post-renderer-args", []string{}, "an argument to the post-renderer (can specify multiple)") + localCmd.Flags().StringArrayVarP(&diff.extraAPIs, "api-versions", "a", []string{}, "Kubernetes api versions used for Capabilities.APIVersions") + localCmd.Flags().StringVar(&diff.kubeVersion, "kube-version", "", "Kubernetes version used for Capabilities.KubeVersion") + + AddDiffOptions(localCmd.Flags(), &diff.Options) + + localCmd.SuggestionsMinimumDistance = 1 + + return localCmd +} + +func (l *local) run() error { + manifest1, err := l.renderChart(l.chart1) + if err != nil { + return fmt.Errorf("Failed to render chart %s: %w", l.chart1, err) + } + + manifest2, err := l.renderChart(l.chart2) + if err != nil { + return fmt.Errorf("Failed to render chart %s: %w", l.chart2, err) + } + + excludes := []string{manifest.Helm3TestHook, manifest.Helm2TestSuccessHook} + if l.includeTests { + excludes = []string{} + } + + specs1 := manifest.Parse(string(manifest1), l.namespace, l.normalizeManifests, excludes...) + specs2 := manifest.Parse(string(manifest2), l.namespace, l.normalizeManifests, excludes...) + + seenAnyChanges := diff.Manifests(specs1, specs2, &l.Options, os.Stdout) + + if l.detailedExitCode && seenAnyChanges { + return Error{ + error: errors.New("identified at least one change, exiting with non-zero exit code (detailed-exitcode parameter enabled)"), + Code: 2, + } + } + + return nil +} + +func (l *local) renderChart(chartPath string) ([]byte, error) { + flags := []string{} + + if l.includeCRDs { + flags = append(flags, "--include-crds") + } + + if l.namespace != "" { + flags = append(flags, "--namespace", l.namespace) + } + + if l.postRenderer != "" { + flags = append(flags, "--post-renderer", l.postRenderer) + } + + for _, arg := range l.postRendererArgs { + flags = append(flags, "--post-renderer-args", arg) + } + + for _, valueFile := range l.valueFiles { + if strings.TrimSpace(valueFile) == "-" { + bytes, err := io.ReadAll(os.Stdin) + if err != nil { + return nil, err + } + + tmpfile, err := os.CreateTemp("", "helm-diff-stdin-values") + if err != nil { + return nil, err + } + defer func() { + _ = os.Remove(tmpfile.Name()) + }() + + if _, err := tmpfile.Write(bytes); err != nil { + _ = tmpfile.Close() + return nil, err + } + + if err := tmpfile.Close(); err != nil { + return nil, err + } + + flags = append(flags, "--values", tmpfile.Name()) + } else { + flags = append(flags, "--values", valueFile) + } + } + + for _, value := range l.values { + flags = append(flags, "--set", value) + } + + for _, stringValue := range l.stringValues { + flags = append(flags, "--set-string", stringValue) + } + + for _, stringLiteralValue := range l.stringLiteralValues { + flags = append(flags, "--set-literal", stringLiteralValue) + } + + for _, jsonValue := range l.jsonValues { + flags = append(flags, "--set-json", jsonValue) + } + + for _, fileValue := range l.fileValues { + flags = append(flags, "--set-file", fileValue) + } + + if l.enableDNS { + flags = append(flags, "--enable-dns") + } + + for _, a := range l.extraAPIs { + flags = append(flags, "--api-versions", a) + } + + if l.kubeVersion != "" { + flags = append(flags, "--kube-version", l.kubeVersion) + } + + args := []string{"template", l.release, chartPath} + args = append(args, flags...) + + cmd := exec.Command(os.Getenv("HELM_BIN"), args...) + return outputWithRichError(cmd) +} diff --git a/cmd/local_test.go b/cmd/local_test.go new file mode 100644 index 00000000..1a9ba8ad --- /dev/null +++ b/cmd/local_test.go @@ -0,0 +1,82 @@ +package cmd + +import ( + "os" + "testing" +) + +func TestLocalCmdArgValidation(t *testing.T) { + cases := []struct { + name string + args []string + expectError bool + }{ + { + name: "no arguments", + args: []string{}, + expectError: true, + }, + { + name: "one argument", + args: []string{"chart1"}, + expectError: true, + }, + { + name: "three arguments", + args: []string{"chart1", "chart2", "chart3"}, + expectError: true, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cmd := localCmd() + cmd.SetArgs(tc.args) + err := cmd.Execute() + + if tc.expectError && err == nil { + t.Errorf("Expected error but got none") + } + if !tc.expectError && err != nil { + t.Errorf("Expected no error but got: %v", err) + } + }) + } +} + +func TestLocalCmdExecution(t *testing.T) { + tmpDir := t.TempDir() + fakeHelm := tmpDir + "/helm" + manifestYAML := `--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: test-config + namespace: default +data: + key: value +` + + err := os.WriteFile(fakeHelm, []byte(`#!/bin/sh +cat < Date: Sat, 4 Oct 2025 12:49:18 +0200 Subject: [PATCH 2/2] fix: format --- cmd/local.go | 36 ++++++++++++++++++------------------ cmd/local_test.go | 1 - 2 files changed, 18 insertions(+), 19 deletions(-) diff --git a/cmd/local.go b/cmd/local.go index bbcf2f02..8ec8bad2 100644 --- a/cmd/local.go +++ b/cmd/local.go @@ -15,25 +15,25 @@ import ( ) type local struct { - chart1 string - chart2 string - release string - namespace string - detailedExitCode bool - includeTests bool - includeCRDs bool - normalizeManifests bool - enableDNS bool - valueFiles valueFiles - values []string - stringValues []string + chart1 string + chart2 string + release string + namespace string + detailedExitCode bool + includeTests bool + includeCRDs bool + normalizeManifests bool + enableDNS bool + valueFiles valueFiles + values []string + stringValues []string stringLiteralValues []string - jsonValues []string - fileValues []string - postRenderer string - postRendererArgs []string - extraAPIs []string - kubeVersion string + jsonValues []string + fileValues []string + postRenderer string + postRendererArgs []string + extraAPIs []string + kubeVersion string diff.Options } diff --git a/cmd/local_test.go b/cmd/local_test.go index 1a9ba8ad..7728682a 100644 --- a/cmd/local_test.go +++ b/cmd/local_test.go @@ -79,4 +79,3 @@ EOF t.Errorf("Expected no error but got: %v", err) } } -