Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ Always reference these instructions first and fallback to search or bash command
- `HELM_BIN` - Path to helm binary (for direct testing)
- `HELM_DIFF_USE_UPGRADE_DRY_RUN` - Use helm upgrade --dry-run instead of template
- `HELM_DIFF_THREE_WAY_MERGE` - Enable three-way merge diffing
- `HELM_DIFF_THREE_WAY_MERGE_MODE` - Apply the three-way merge patch via the API server (`auto`/`server`) or locally (`client`)
- `HELM_DIFF_NORMALIZE_MANIFESTS` - Normalize YAML before diffing
- `HELM_DIFF_OUTPUT_CONTEXT` - Configure output context lines

Expand Down
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@ Flags:
-q, --suppress-secrets suppress secrets in the output
--take-ownership if set, upgrade will ignore the check for helm annotations and take ownership of the existing resources
--three-way-merge use three-way-merge to compute patch and generate diff output
--three-way-merge-mode string how --three-way-merge applies the computed patch. Must be "auto", "server" or "client". "server" dry-runs the patch against the API server, which requires the patch permission. "client" merges locally and needs read access only, at the cost of not applying server-side defaulting and mutating webhooks. "auto" uses the server and falls back to the client when patching is not permitted (default "auto")
-f, --values valueFiles specify values in a YAML file (can specify multiple) (default [])
--version string specify the exact chart version to use. If this is not specified, the latest version is used

Expand Down Expand Up @@ -256,6 +257,35 @@ Notes:
- helm-diff's own exit code is unaffected by the tool: `--detailed-exitcode` still returns `2` based on the changes helm-diff detected.
- `--context`/`-C` is not applied; use the equivalent option of the external tool (for example `diff -U3`).

### Three-way merge

`--three-way-merge` diffs against what is actually in the cluster rather than against the manifests of the last release, so changes made outside of Helm show up too. To do that helm-diff has to compute the object that the upgrade would produce: it reads the live object, builds a three-way merge patch from the old release manifest, the new release manifest and the live object, and then applies that patch.

`--three-way-merge-mode` controls how the patch is applied:

- `server` sends the patch to the API server as a dry-run (`PATCH ...?dryRun=All`). The API server fills in defaults and runs mutating webhooks, so the result is the most faithful preview of the upgrade — but the credentials need the `patch` permission on every diffed resource.
- `client` applies the patch locally, using the same strategic-merge (or JSON merge patch, for custom resources) logic the API server would use. Only `get` is required. The merged object is then round-tripped through its Go type, the way the API server does before it answers, and a field is copied back from the live object whenever the old and the new release manifest agree about it — without that, the defaults the API server re-applies after patching would show up as spurious removals. An empty list, an empty map and a `null` are also treated as the same value, because Kubernetes stores objects as protobuf and cannot tell them apart: a chart that writes `rules: []` gets `rules: null` back from the cluster. Validation and mutating webhooks are still not applied.
- `auto` (the default) tries `server` first and falls back to `client` per run when the API server rejects the dry-run with `Forbidden` or `MethodNotAllowed`, printing a note on stderr. Any other error still aborts the diff.

Because the defaulting functions are not part of client-go, `client` mode cannot reproduce them exactly. What it can do is leave the live value alone: a field is only reported as gone when the two release manifests disagree about it. That matters more than it sounds, because a chart that leaves a value unset usually renders the field as an explicit `null` — a bare `replicas:` — and a `null` in a manifest reaches the patch as a change rather than a deletion, wiping a value the API server had defaulted in even when the chart did not change at all.

The remaining known deviation from `server` mode is the case where the manifests really do disagree and the field is one the API server defaults: a chart that stops pinning `replicas: 3` is reported as removing the field, where `server` mode shows it changing from `3` to the defaulted `1`. The change is reported either way, but `client` mode cannot name the value that replaces it.

So a read-only account is enough for a three-way merge diff out of the box. Set `--three-way-merge-mode=client` (or `HELM_DIFF_THREE_WAY_MERGE_MODE=client`) to skip the rejected dry-run request entirely, and `--three-way-merge-mode=server` to make a missing `patch` permission a hard error instead of silently degrading the diff.

The minimal RBAC for the `client` mode is read access to the diffed kinds plus the release storage:

```yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: helm-diff
rules:
- apiGroups: ["*"]
resources: ["*"]
verbs: ["get", "list"]
```

## Commands:

### local:
Expand Down Expand Up @@ -349,6 +379,12 @@ Examples:
# Read the flag usage below for more information on --three-way-merge.
HELM_DIFF_THREE_WAY_MERGE=true helm diff upgrade my-release datadog/datadog

# Set HELM_DIFF_THREE_WAY_MERGE_MODE=client to compute the three-way merge
# locally, so that no permission to patch the cluster resources is needed.
# This is equivalent to specifying the --three-way-merge-mode flag.
# Read the flag usage below for more information on --three-way-merge-mode.
HELM_DIFF_THREE_WAY_MERGE_MODE=client helm diff upgrade my-release datadog/datadog

# Set HELM_DIFF_NORMALIZE_MANIFESTS=true to
# normalize the yaml file content when using helm diff.
# This is equivalent to specifying the --normalize-manifests flag.
Expand Down Expand Up @@ -418,6 +454,7 @@ Flags:
-q, --suppress-secrets suppress secrets in the output
--take-ownership if set, upgrade will ignore the check for helm annotations and take ownership of the existing resources
--three-way-merge use three-way-merge to compute patch and generate diff output
--three-way-merge-mode string how --three-way-merge applies the computed patch. Must be "auto", "server" or "client". "server" dry-runs the patch against the API server, which requires the patch permission. "client" merges locally and needs read access only, at the cost of not applying server-side defaulting and mutating webhooks. "auto" uses the server and falls back to the client when patching is not permitted (default "auto")
-f, --values valueFiles specify values in a YAML file (can specify multiple) (default [])
--version string specify the exact chart version to use. If this is not specified, the latest version is used

Expand Down
24 changes: 23 additions & 1 deletion cmd/upgrade.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ type diffCmd struct {
normalizeManifests bool
takeOwnership bool
threeWayMerge bool
threeWayMergeMode string
serverSide string
extraAPIs []string
kubeVersion string
Expand Down Expand Up @@ -162,6 +163,12 @@ func newChartCommand() *cobra.Command {
" # Read the flag usage below for more information on --three-way-merge.",
" HELM_DIFF_THREE_WAY_MERGE=true helm diff upgrade my-release datadog/datadog",
"",
" # Set HELM_DIFF_THREE_WAY_MERGE_MODE=client to compute the three-way merge",
" # locally, so that no permission to patch the cluster resources is needed.",
" # This is equivalent to specifying the --three-way-merge-mode flag.",
" # Read the flag usage below for more information on --three-way-merge-mode.",
" HELM_DIFF_THREE_WAY_MERGE_MODE=client helm diff upgrade my-release datadog/datadog",
"",
" # Set HELM_DIFF_NORMALIZE_MANIFESTS=true to",
" # normalize the yaml file content when using helm diff.",
" # This is equivalent to specifying the --normalize-manifests flag.",
Expand All @@ -187,6 +194,10 @@ func newChartCommand() *cobra.Command {
return fmt.Errorf("flag %q must be %q, %q or %q, but got %q", "server-side", envTrue, envFalse, serverSideAuto, diff.serverSide)
}

if !slices.Contains(manifest.ValidThreeWayMergeModes, diff.threeWayMergeMode) {
return fmt.Errorf("flag %q must be one of %q, but got %q", "three-way-merge-mode", manifest.ValidThreeWayMergeModes, diff.threeWayMergeMode)
}

if err := diff.validateRevision(cmd.Flags().Changed("revision")); err != nil {
return err
}
Expand All @@ -206,6 +217,15 @@ func newChartCommand() *cobra.Command {
}
}

if !cmd.Flags().Changed("three-way-merge-mode") {
if mode := os.Getenv("HELM_DIFF_THREE_WAY_MERGE_MODE"); mode != "" {
if !slices.Contains(manifest.ValidThreeWayMergeModes, mode) {
return fmt.Errorf("env var %q must be one of %q, but got %q", "HELM_DIFF_THREE_WAY_MERGE_MODE", manifest.ValidThreeWayMergeModes, mode)
}
diff.threeWayMergeMode = mode
}
}
Comment on lines +220 to +227

if !diff.normalizeManifests && !cmd.Flags().Changed("normalize-manifests") {
enabled := os.Getenv("HELM_DIFF_NORMALIZE_MANIFESTS") == envTrue
diff.normalizeManifests = enabled
Expand Down Expand Up @@ -241,6 +261,7 @@ func newChartCommand() *cobra.Command {
f.StringVar(&kubeconfig, "kubeconfig", "", "This flag is ignored, to allow passing of this top level flag to helm")
addNamespaceFlags(f, &diff.namespaces)
f.BoolVar(&diff.threeWayMerge, "three-way-merge", false, "use three-way-merge to compute patch and generate diff output")
f.StringVar(&diff.threeWayMergeMode, "three-way-merge-mode", string(manifest.ThreeWayMergeAuto), `how --three-way-merge applies the computed patch. Must be "auto", "server" or "client". "server" dry-runs the patch against the API server, which requires the patch permission. "client" merges locally and needs read access only, at the cost of not applying server-side defaulting and mutating webhooks. "auto" uses the server and falls back to the client when patching is not permitted`)
f.StringVar(&diff.kubeContext, "kube-context", "", "name of the kubeconfig context to use")
f.StringVar(&diff.chartVersion, "version", "", "specify the exact chart version to use. If this is not specified, the latest version is used")
f.StringVar(&diff.chartRepo, "repo", "", "specify the chart repository url to locate the requested chart")
Expand Down Expand Up @@ -344,7 +365,8 @@ func (d *diffCmd) runHelm3() error {
}

if d.threeWayMerge {
releaseManifest, installManifest, err = manifest.Generate(actionConfig, releaseManifest, installManifest)
releaseManifest, installManifest, err = manifest.Generate(actionConfig, releaseManifest, installManifest,
manifest.WithThreeWayMergeMode(manifest.ThreeWayMergeMode(d.threeWayMergeMode)))
if err != nil {
return fmt.Errorf("unable to generate manifests: %w", err)
}
Expand Down
46 changes: 46 additions & 0 deletions cmd/upgrade_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -367,3 +367,49 @@ data:
}
})
}

func TestThreeWayMergeModeFlag(t *testing.T) {
if f := newChartCommand().Flags().Lookup("three-way-merge-mode"); f == nil {
t.Fatal("expected flag --three-way-merge-mode to be registered")
} else if f.DefValue != "auto" {
t.Errorf("expected --three-way-merge-mode to default to auto, got %q", f.DefValue)
}

cases := []struct {
name string
args []string
env string
expectErr string
}{
{name: "server", args: []string{"--three-way-merge-mode", "server"}},
{name: "client", args: []string{"--three-way-merge-mode", "client"}},
{name: "auto", args: []string{"--three-way-merge-mode", "auto"}},
{name: "invalid flag", args: []string{"--three-way-merge-mode", "local"}, expectErr: "three-way-merge-mode"},
{name: "empty flag", args: []string{"--three-way-merge-mode", ""}, expectErr: "three-way-merge-mode"},
{name: "env var", env: "client"},
{name: "invalid env var", env: "local", expectErr: "HELM_DIFF_THREE_WAY_MERGE_MODE"},
{name: "flag wins over invalid env var", args: []string{"--three-way-merge-mode", "client"}, env: "local"},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("HELM_DIFF_THREE_WAY_MERGE_MODE", tc.env)

chartDir := t.TempDir()
setupFakeHelm(t, "capture_args", "", chartDir+"/args", "")

cmd := newChartCommand()
cmd.SetArgs(append([]string{"my-release", chartDir}, tc.args...))

err := cmd.Execute()
switch {
case tc.expectErr == "" && err != nil:
t.Fatalf("unexpected error: %v", err)
case tc.expectErr != "" && err == nil:
t.Fatalf("expected an error mentioning %q, got none", tc.expectErr)
case tc.expectErr != "" && !strings.Contains(err.Error(), tc.expectErr):
t.Fatalf("expected error mentioning %q, got %v", tc.expectErr, err)
}
})
}
}
Loading
Loading