feat: add local subcommand to compare two chart folders#866
feat: add local subcommand to compare two chart folders#866bo0tzz wants to merge 7 commits intodatabus23:masterfrom
Conversation
|
@bo0tzz maybe we should add an e2e test? |
|
The rest is LGTM. @bo0tzz |
There was a problem hiding this comment.
Pull Request Overview
This PR adds a new local subcommand to the helm-diff plugin that allows comparing two local chart directories. The feature enables developers to see differences between chart versions during development and CI/CD workflows by rendering both charts with helm template and showing the resulting manifest differences.
Key changes:
- Implements a new
localsubcommand with comprehensive flag support for chart rendering options - Adds comprehensive test coverage for argument validation and command execution
- Updates documentation to include the new subcommand usage and examples
Reviewed Changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| cmd/root.go | Registers the new localCmd() subcommand in the main command structure |
| cmd/local.go | Complete implementation of the local subcommand with chart rendering and diffing logic |
| cmd/local_test.go | Test suite covering argument validation and basic execution scenarios |
| README.md | Documentation update adding the local subcommand help text and usage examples |
cmd/local.go
Outdated
| 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) |
There was a problem hiding this comment.
Error messages should start with lowercase letters to follow Go conventions.
| return fmt.Errorf("Failed to render chart %s: %w", l.chart1, err) | |
| return fmt.Errorf("failed to render chart %s: %w", l.chart1, err) |
cmd/local.go
Outdated
|
|
||
| manifest2, err := l.renderChart(l.chart2) | ||
| if err != nil { | ||
| return fmt.Errorf("Failed to render chart %s: %w", l.chart2, err) |
There was a problem hiding this comment.
Error messages should start with lowercase letters to follow Go conventions.
| return fmt.Errorf("Failed to render chart %s: %w", l.chart2, err) | |
| return fmt.Errorf("failed to render chart %s: %w", l.chart2, err) |
cmd/local.go
Outdated
| args := []string{"template", l.release, chartPath} | ||
| args = append(args, flags...) | ||
|
|
||
| cmd := exec.Command(os.Getenv("HELM_BIN"), args...) |
There was a problem hiding this comment.
Using os.Getenv("HELM_BIN") without validation could allow execution of arbitrary commands. Consider validating the HELM_BIN path or providing a default fallback.
| cmd := exec.Command(os.Getenv("HELM_BIN"), args...) | |
| helmBin := os.Getenv("HELM_BIN") | |
| if helmBin == "" { | |
| helmBin = "helm" | |
| } | |
| cmd := exec.Command(helmBin, args...) |
|
@yxxhero I'd be down for e2e tests, but I don't think the project has any yet right? How would you like those to look? |
|
@bo0tzz it will be better if we add e2e test. this is a new feature. thanks so much. |
|
@copilot open a new pull request to apply changes based on the comments in this thread |
|
@copilot open a new pull request to apply changes based on the comments in this thread |
cmd/local.go
Outdated
| 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) |
There was a problem hiding this comment.
Error messages should start with lowercase letters following Go conventions and consistency with other error messages in the codebase.
For example, change:
"Failed to render chart %s: %w"to"failed to render chart %s: %w"
This matches the pattern used in cmd/upgrade.go:292 and cmd/helm3.go:31-41.
| 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) | |
| 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) |
cmd/local.go
Outdated
|
|
||
| manifest2, err := l.renderChart(l.chart2) | ||
| if err != nil { | ||
| return fmt.Errorf("Failed to render chart %s: %w", l.chart2, err) |
There was a problem hiding this comment.
Error message should start with a lowercase letter following Go conventions and consistency with other error messages in the codebase.
For example, change:
"Failed to render chart %s: %w"to"failed to render chart %s: %w"
This matches the pattern used in cmd/upgrade.go:292 and cmd/helm3.go:31-41.
| return fmt.Errorf("Failed to render chart %s: %w", l.chart2, err) | |
| return fmt.Errorf("failed to render chart %s: %w", l.chart2, err) |
| 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) | ||
| } |
There was a problem hiding this comment.
The renderChart method is called twice (for chart1 and chart2), but it reads from stdin when -f - is used (line 169). Since stdin can only be read once, the second call to renderChart will fail or receive empty input.
This is different from the upgrade command where renderChart is only called once. For the local command, you would need to either:
- Read stdin once before calling
renderChartand pass the content to both calls, or - Document that stdin values (
-f -) are not supported for this command, or - Reuse the same temp file for both chart renders
Consider refactoring to handle stdin reading outside of the loop or before multiple renderChart calls.
- 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>
…string Signed-off-by: yxxhero <aiopsclub@163.com>
bbbfc81 to
cc56dae
Compare
Signed-off-by: yxxhero <aiopsclub@163.com>
Signed-off-by: yxxhero <aiopsclub@163.com>
Signed-off-by: yxxhero <aiopsclub@163.com>
| --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") |
There was a problem hiding this comment.
The --output flag help text here omits supported formats (json, structured) that are listed for other commands and are defined in AddDiffOptions (see cmd/options.go). This makes the README inconsistent with the actual CLI/help output. Update this line to reflect the complete set of supported output formats.
| --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") | |
| --output string Possible values: diff, simple, template, dyff, json, structured. When set to "template", use the env var HELM_DIFF_TPL to specify the template. (default "diff") |
| if err != nil { | ||
| return err | ||
| } | ||
| defer func() { _ = os.Remove(tmpfile.Name()) }() |
There was a problem hiding this comment.
prepareStdinValues creates a temp file for -f - but defers os.Remove inside this function. Because the defer runs when prepareStdinValues returns (before renderChart is executed), the temp file path stored in l.valueFiles[i] can be deleted before helm template tries to read it, causing helm template to fail. Keep the temp file alive until after both renderChart calls complete (e.g., defer cleanup in run() or handle stdin values within renderChart).
| defer func() { _ = os.Remove(tmpfile.Name()) }() |
| for i, valueFile := range l.valueFiles { | ||
| if strings.TrimSpace(valueFile) == "-" { | ||
| data, err := io.ReadAll(os.Stdin) | ||
| if err != nil { | ||
| return err | ||
| } | ||
|
|
||
| tmpfile, err := os.CreateTemp("", "helm-diff-stdin-values") | ||
| if err != nil { | ||
| return err | ||
| } | ||
| defer func() { _ = os.Remove(tmpfile.Name()) }() | ||
|
|
||
| if _, err := tmpfile.Write(data); err != nil { | ||
| _ = tmpfile.Close() | ||
| return err | ||
| } | ||
|
|
||
| if err := tmpfile.Close(); err != nil { | ||
| return err | ||
| } | ||
|
|
||
| l.valueFiles[i] = tmpfile.Name() | ||
| break |
There was a problem hiding this comment.
prepareStdinValues stops after the first - values file (break). If the user provides multiple -f - entries, later occurrences remain as "-" and will cause helm template to read from stdin after this function already consumed it via io.ReadAll(os.Stdin). Consider either replacing all "-" entries with the same temp file path (read stdin once) or returning an explicit error when more than one stdin values file is provided.
| for i, valueFile := range l.valueFiles { | |
| if strings.TrimSpace(valueFile) == "-" { | |
| data, err := io.ReadAll(os.Stdin) | |
| if err != nil { | |
| return err | |
| } | |
| tmpfile, err := os.CreateTemp("", "helm-diff-stdin-values") | |
| if err != nil { | |
| return err | |
| } | |
| defer func() { _ = os.Remove(tmpfile.Name()) }() | |
| if _, err := tmpfile.Write(data); err != nil { | |
| _ = tmpfile.Close() | |
| return err | |
| } | |
| if err := tmpfile.Close(); err != nil { | |
| return err | |
| } | |
| l.valueFiles[i] = tmpfile.Name() | |
| break | |
| var ( | |
| stdinData []byte | |
| stdinRead bool | |
| stdinTmpFilePath string | |
| ) | |
| for i, valueFile := range l.valueFiles { | |
| if strings.TrimSpace(valueFile) == "-" { | |
| // Read from stdin and create the temp file only once, even if | |
| // "-" appears multiple times in the values files list. | |
| if !stdinRead { | |
| data, err := io.ReadAll(os.Stdin) | |
| if err != nil { | |
| return err | |
| } | |
| tmpfile, err := os.CreateTemp("", "helm-diff-stdin-values") | |
| if err != nil { | |
| return err | |
| } | |
| if _, err := tmpfile.Write(data); err != nil { | |
| _ = tmpfile.Close() | |
| return err | |
| } | |
| if err := tmpfile.Close(); err != nil { | |
| return err | |
| } | |
| stdinData = data | |
| stdinTmpFilePath = tmpfile.Name() | |
| stdinRead = true | |
| _ = stdinData // avoid unused variable if not referenced elsewhere | |
| } | |
| l.valueFiles[i] = stdinTmpFilePath |
| } | ||
|
|
||
| specs1 := manifest.Parse(manifest1, l.namespace, l.normalizeManifests, excludes...) | ||
| specs2 := manifest.Parse(manifest2, l.namespace, l.normalizeManifests, excludes...) |
There was a problem hiding this comment.
manifest1/manifest2 can be large. Other commands in this repo set the raw manifest byte slices to nil after manifest.Parse(...) to allow GC to reclaim memory before diff computation. Consider doing the same here after parsing to reduce peak memory usage when diffing large charts.
| specs2 := manifest.Parse(manifest2, l.namespace, l.normalizeManifests, excludes...) | |
| specs2 := manifest.Parse(manifest2, l.namespace, l.normalizeManifests, excludes...) | |
| // Allow GC to reclaim memory used by raw manifests before diff computation. | |
| manifest1 = nil | |
| manifest2 = nil |
| 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 <<EOF | ||
| `+manifestYAML+` | ||
| EOF | ||
| `), 0755) |
There was a problem hiding this comment.
These tests create a fake Helm binary as a #!/bin/sh script and use Unix-specific assumptions (e.g., /bin/sh, heredocs, POSIX paths). This will fail when running go test on Windows. Consider switching to the existing Go-based fake-helm approach used elsewhere in the repo (e.g., main_test.go's runFakeHelm pattern) or skipping these tests on non-POSIX platforms.
This adds a new
localsubcommand that diffs two local chart directories. My intended usecase is mainly for development and CI/CD, to see what effects a change has.This code was written entirely by an LLM; if that is a dealbreaker, feel free to just close this PR outright.
If there is appetite for it, I'd like to next build on top of this with a
gitsubcommand that diffs two git references against eachother (by checking them out to a temporary folder and then runninglocalagainst them).