Skip to content

Commit 518a2d2

Browse files
yxxherobo0tzz
andauthored
feat: add local subcommand to compare two chart folders (#982)
* feat: add local subcommand to compare two chart folders * fix: format * fix: address PR review comments for local subcommand - Lowercase error messages to follow Go conventions - Extract stdin reading into prepareStdinValues() to avoid double-read when rendering both charts - Add HELM_BIN env var validation with "helm" default fallback Signed-off-by: yxxhero <aiopsclub@163.com> * fix: pass []byte directly to manifest.Parse instead of converting to string Signed-off-by: yxxhero <aiopsclub@163.com> * fix: check error return values for errcheck linter Signed-off-by: yxxhero <aiopsclub@163.com> * test: add integration tests for local subcommand Signed-off-by: yxxhero <aiopsclub@163.com> * fix: use errors.As instead of type assertion for errorlint Signed-off-by: yxxhero <aiopsclub@163.com> * fix: address PR review comments - Fix prepareStdinValues temp file cleanup: return cleanup func so the temp file survives until after both charts are rendered - Rewrite tests to use cross-platform TestMain re-exec pattern instead of #!/bin/sh shell scripts - Update README --output flag description to include json, structured * fix: address PR review comments from copilot - Remove unreachable --version flag check in local command - Fix prepareStdinValues to handle all stdin value files - Add proper error handling for os.Pipe and ReadFrom in tests - Handle errors in fake helm test helpers Signed-off-by: yxxhero <aiopsclub@163.com> * fix: address PR review comments - Fix temp file leak in prepareStdinValues on error paths - Use %q for quoted chart paths in error messages - Nil out manifest byte slices after parsing to reduce peak memory - Refactor tests to use captureStdout helper - Add tests for prepareStdinValues stdin handling Signed-off-by: yxxhero <aiopsclub@163.com> * fix: resolve ineffectual assignment lint errors in cmd/local.go Signed-off-by: yxxhero <aiopsclub@163.com> * fix: use nolint:ineffassign for manifest nil-ing consistent with other commands Signed-off-by: yxxhero <aiopsclub@163.com> * fix: address PR review comments - reduce peak memory and guard fake helm mode Signed-off-by: yxxhero <aiopsclub@163.com> --------- Signed-off-by: yxxhero <aiopsclub@163.com> Co-authored-by: bo0tzz <git@bo0tzz.me>
1 parent 9e650f3 commit 518a2d2

5 files changed

Lines changed: 744 additions & 0 deletions

File tree

README.md

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ Usage:
107107
108108
Available Commands:
109109
completion Generate the autocompletion script for the specified shell
110+
local Shows diff between two local chart directories
110111
release Shows diff between release's manifests
111112
revision Shows diff between revision's manifests
112113
rollback Show a diff explaining what a helm rollback could perform
@@ -194,6 +195,63 @@ When a kind is suppressed via `--suppress`, `changesSuppressed` is set to `true`
194195
195196
## Commands:
196197
198+
### local:
199+
200+
```
201+
$ helm diff local -h
202+
203+
This command compares the manifests of two local chart directories.
204+
205+
It renders both charts using 'helm template' and shows the differences
206+
between the resulting manifests.
207+
208+
This is useful for:
209+
- Comparing different versions of a chart
210+
- Previewing changes before committing
211+
- Validating chart modifications
212+
213+
Usage:
214+
diff local [flags] CHART1 CHART2
215+
216+
Examples:
217+
helm diff local ./chart-v1 ./chart-v2
218+
helm diff local ./chart-v1 ./chart-v2 -f values.yaml
219+
helm diff local /path/to/chart-a /path/to/chart-b --set replicas=3
220+
221+
Flags:
222+
-a, --api-versions stringArray Kubernetes api versions used for Capabilities.APIVersions
223+
-C, --context int output NUM lines of context around changes (default -1)
224+
--detailed-exitcode return a non-zero exit code when there are changes
225+
--enable-dns enable DNS lookups when rendering templates
226+
-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
227+
-h, --help help for local
228+
--include-crds include CRDs in the diffing
229+
--include-tests enable the diffing of the helm test hooks
230+
--kube-version string Kubernetes version used for Capabilities.KubeVersion
231+
--namespace string namespace to use for template rendering
232+
--normalize-manifests normalize manifests before running diff to exclude style differences from the output
233+
--output string Possible values: diff, simple, template, json, structured, dyff. When set to "template", use the env var HELM_DIFF_TPL to specify the template. (default "diff")
234+
--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
235+
--post-renderer-args stringArray an argument to the post-renderer (can specify multiple)
236+
--release string release name to use for template rendering (default "release")
237+
--set stringArray set values on the command line (can specify multiple or separate values with commas: key1=val1,key2=val2)
238+
--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)
239+
--set-json stringArray set JSON values on the command line (can specify multiple or separate values with commas: key1=jsonval1,key2=jsonval2)
240+
--set-literal stringArray set STRING literal values on the command line
241+
--set-string stringArray set STRING values on the command line (can specify multiple or separate values with commas: key1=val1,key2=val2)
242+
--show-secrets do not redact secret values in the output
243+
--show-secrets-decoded decode secret values in the output
244+
--strip-trailing-cr strip trailing carriage return on input
245+
--suppress stringArray allows suppression of the kinds listed in the diff output (can specify multiple, like '--suppress Deployment --suppress Service')
246+
--suppress-output-line-regex stringArray a regex to suppress diff output lines that match
247+
-q, --suppress-secrets suppress secrets in the output
248+
-f, --values valueFiles specify values in a YAML file (can specify multiple) (default [])
249+
250+
Global Flags:
251+
--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"
252+
--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"
253+
```
254+
197255
### upgrade:
198256
199257
```

cmd/local.go

Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
package cmd
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"io"
7+
"os"
8+
"os/exec"
9+
"strings"
10+
11+
"github.com/spf13/cobra"
12+
13+
"github.com/databus23/helm-diff/v3/diff"
14+
"github.com/databus23/helm-diff/v3/manifest"
15+
)
16+
17+
type local struct {
18+
chart1 string
19+
chart2 string
20+
release string
21+
namespace string
22+
detailedExitCode bool
23+
includeTests bool
24+
includeCRDs bool
25+
normalizeManifests bool
26+
enableDNS bool
27+
valueFiles valueFiles
28+
values []string
29+
stringValues []string
30+
stringLiteralValues []string
31+
jsonValues []string
32+
fileValues []string
33+
postRenderer string
34+
postRendererArgs []string
35+
extraAPIs []string
36+
kubeVersion string
37+
diff.Options
38+
}
39+
40+
const localCmdLongUsage = `
41+
This command compares the manifests of two local chart directories.
42+
43+
It renders both charts using 'helm template' and shows the differences
44+
between the resulting manifests.
45+
46+
This is useful for:
47+
- Comparing different versions of a chart
48+
- Previewing changes before committing
49+
- Validating chart modifications
50+
`
51+
52+
func localCmd() *cobra.Command {
53+
diff := local{
54+
release: "release",
55+
}
56+
57+
localCmd := &cobra.Command{
58+
Use: "local [flags] CHART1 CHART2",
59+
Short: "Shows diff between two local chart directories",
60+
Long: localCmdLongUsage,
61+
Example: strings.Join([]string{
62+
" helm diff local ./chart-v1 ./chart-v2",
63+
" helm diff local ./chart-v1 ./chart-v2 -f values.yaml",
64+
" helm diff local /path/to/chart-a /path/to/chart-b --set replicas=3",
65+
}, "\n"),
66+
RunE: func(cmd *cobra.Command, args []string) error {
67+
cmd.SilenceUsage = true
68+
69+
if err := checkArgsLength(len(args), "chart1 path", "chart2 path"); err != nil {
70+
return err
71+
}
72+
73+
ProcessDiffOptions(cmd.Flags(), &diff.Options)
74+
75+
diff.chart1 = args[0]
76+
diff.chart2 = args[1]
77+
78+
if diff.namespace == "" {
79+
diff.namespace = os.Getenv("HELM_NAMESPACE")
80+
}
81+
82+
return diff.run()
83+
},
84+
}
85+
86+
localCmd.Flags().StringVar(&diff.release, "release", "release", "release name to use for template rendering")
87+
localCmd.Flags().StringVar(&diff.namespace, "namespace", "", "namespace to use for template rendering")
88+
localCmd.Flags().BoolVar(&diff.detailedExitCode, "detailed-exitcode", false, "return a non-zero exit code when there are changes")
89+
localCmd.Flags().BoolVar(&diff.includeTests, "include-tests", false, "enable the diffing of the helm test hooks")
90+
localCmd.Flags().BoolVar(&diff.includeCRDs, "include-crds", false, "include CRDs in the diffing")
91+
localCmd.Flags().BoolVar(&diff.normalizeManifests, "normalize-manifests", false, "normalize manifests before running diff to exclude style differences from the output")
92+
localCmd.Flags().BoolVar(&diff.enableDNS, "enable-dns", false, "enable DNS lookups when rendering templates")
93+
localCmd.Flags().VarP(&diff.valueFiles, "values", "f", "specify values in a YAML file (can specify multiple)")
94+
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)")
95+
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)")
96+
localCmd.Flags().StringArrayVar(&diff.stringLiteralValues, "set-literal", []string{}, "set STRING literal values on the command line")
97+
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)")
98+
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)")
99+
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")
100+
localCmd.Flags().StringArrayVar(&diff.postRendererArgs, "post-renderer-args", []string{}, "an argument to the post-renderer (can specify multiple)")
101+
localCmd.Flags().StringArrayVarP(&diff.extraAPIs, "api-versions", "a", []string{}, "Kubernetes api versions used for Capabilities.APIVersions")
102+
localCmd.Flags().StringVar(&diff.kubeVersion, "kube-version", "", "Kubernetes version used for Capabilities.KubeVersion")
103+
104+
AddDiffOptions(localCmd.Flags(), &diff.Options)
105+
106+
localCmd.SuggestionsMinimumDistance = 1
107+
108+
return localCmd
109+
}
110+
111+
func (l *local) run() error {
112+
cleanup, err := l.prepareStdinValues()
113+
if err != nil {
114+
return err
115+
}
116+
if cleanup != nil {
117+
defer cleanup()
118+
}
119+
120+
excludes := []string{manifest.Helm3TestHook, manifest.Helm2TestSuccessHook}
121+
if l.includeTests {
122+
excludes = []string{}
123+
}
124+
125+
manifest1, err := l.renderChart(l.chart1)
126+
if err != nil {
127+
return fmt.Errorf("failed to render chart %q: %w", l.chart1, err)
128+
}
129+
specs1 := manifest.Parse(manifest1, l.namespace, l.normalizeManifests, excludes...)
130+
manifest1 = nil //nolint:ineffassign // nil to allow GC to reclaim raw bytes before rendering the second chart
131+
132+
manifest2, err := l.renderChart(l.chart2)
133+
if err != nil {
134+
return fmt.Errorf("failed to render chart %q: %w", l.chart2, err)
135+
}
136+
specs2 := manifest.Parse(manifest2, l.namespace, l.normalizeManifests, excludes...)
137+
138+
seenAnyChanges := diff.Manifests(specs1, specs2, &l.Options, os.Stdout)
139+
140+
if l.detailedExitCode && seenAnyChanges {
141+
return Error{
142+
error: errors.New("identified at least one change, exiting with non-zero exit code (detailed-exitcode parameter enabled)"),
143+
Code: 2,
144+
}
145+
}
146+
147+
return nil
148+
}
149+
150+
func (l *local) prepareStdinValues() (func(), error) {
151+
var name string
152+
153+
for i, valueFile := range l.valueFiles {
154+
if strings.TrimSpace(valueFile) == "-" {
155+
if name == "" {
156+
data, err := io.ReadAll(os.Stdin)
157+
if err != nil {
158+
return nil, err
159+
}
160+
161+
tmpfile, err := os.CreateTemp("", "helm-diff-stdin-values")
162+
if err != nil {
163+
return nil, err
164+
}
165+
166+
if _, err := tmpfile.Write(data); err != nil {
167+
_ = tmpfile.Close()
168+
_ = os.Remove(tmpfile.Name())
169+
return nil, err
170+
}
171+
172+
if err := tmpfile.Close(); err != nil {
173+
_ = os.Remove(tmpfile.Name())
174+
return nil, err
175+
}
176+
177+
name = tmpfile.Name()
178+
}
179+
180+
l.valueFiles[i] = name
181+
}
182+
}
183+
184+
if name != "" {
185+
return func() { _ = os.Remove(name) }, nil
186+
}
187+
return nil, nil
188+
}
189+
190+
func (l *local) renderChart(chartPath string) ([]byte, error) {
191+
flags := []string{}
192+
193+
if l.includeCRDs {
194+
flags = append(flags, "--include-crds")
195+
}
196+
197+
if l.namespace != "" {
198+
flags = append(flags, "--namespace", l.namespace)
199+
}
200+
201+
if l.postRenderer != "" {
202+
flags = append(flags, "--post-renderer", l.postRenderer)
203+
}
204+
205+
for _, arg := range l.postRendererArgs {
206+
flags = append(flags, "--post-renderer-args", arg)
207+
}
208+
209+
for _, valueFile := range l.valueFiles {
210+
flags = append(flags, "--values", valueFile)
211+
}
212+
213+
for _, value := range l.values {
214+
flags = append(flags, "--set", value)
215+
}
216+
217+
for _, stringValue := range l.stringValues {
218+
flags = append(flags, "--set-string", stringValue)
219+
}
220+
221+
for _, stringLiteralValue := range l.stringLiteralValues {
222+
flags = append(flags, "--set-literal", stringLiteralValue)
223+
}
224+
225+
for _, jsonValue := range l.jsonValues {
226+
flags = append(flags, "--set-json", jsonValue)
227+
}
228+
229+
for _, fileValue := range l.fileValues {
230+
flags = append(flags, "--set-file", fileValue)
231+
}
232+
233+
if l.enableDNS {
234+
flags = append(flags, "--enable-dns")
235+
}
236+
237+
for _, a := range l.extraAPIs {
238+
flags = append(flags, "--api-versions", a)
239+
}
240+
241+
if l.kubeVersion != "" {
242+
flags = append(flags, "--kube-version", l.kubeVersion)
243+
}
244+
245+
args := []string{"template", l.release, chartPath}
246+
args = append(args, flags...)
247+
248+
helmBin := os.Getenv("HELM_BIN")
249+
if helmBin == "" {
250+
helmBin = "helm"
251+
}
252+
cmd := exec.Command(helmBin, args...)
253+
return outputWithRichError(cmd)
254+
}

0 commit comments

Comments
 (0)