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
37 changes: 32 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ Flags:
--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"
--no-hooks disable diffing of hooks
--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")
--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")
--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)
--repo string specify the chart repository url to locate the requested chart
Expand Down Expand Up @@ -145,6 +145,33 @@ Additional help topcis:
Use "diff [command] --help" for more information about a command.
```

### Structured JSON output

Set `--output structured` (or `HELM_DIFF_OUTPUT=structured`) to emit machine-readable JSON. Each entry reports the Kubernetes object metadata, resource existence, and per-field changes using JSON Pointer paths:

```shell
helm diff upgrade prod api ./charts/api --output structured
```

```json
[
{
"apiVersion": "apps/v1",
"kind": "Deployment",
"namespace": "prod",
"name": "api",
"changeType": "MODIFY",
"resourceStatus": {"oldExists": true, "newExists": true},
"changes": [
{"path": "spec", "field": "replicas", "change": "replace", "oldValue": 2, "newValue": 3},
{"path": "spec.template.spec.containers[0]", "field": "image", "change": "replace", "oldValue": "api:v1", "newValue": "api:v2"}
]
}
]
```

When a kind is suppressed via `--suppress`, `changesSuppressed` is set to `true` and field details are omitted. Nested metadata such as labels show the container path (`metadata.labels`) and expose the label key through the `field` property (for example `app.kubernetes.io/version`).

## Commands:

### upgrade:
Expand Down Expand Up @@ -211,7 +238,7 @@ Flags:
--kubeconfig string This flag is ignored, to allow passing of this top level flag to helm
--no-hooks disable diffing of hooks
--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")
--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")
--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)
--repo string specify the chart repository url to locate the requested chart
Expand Down Expand Up @@ -266,7 +293,7 @@ Flags:
-h, --help help for release
--include-tests enable the diffing of the helm test hooks
--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")
--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")
--show-secrets do not redact 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')
Expand Down Expand Up @@ -308,7 +335,7 @@ Flags:
-h, --help help for revision
--include-tests enable the diffing of the helm test hooks
--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")
--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")
--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
Expand Down Expand Up @@ -344,7 +371,7 @@ Flags:
-h, --help help for rollback
--include-tests enable the diffing of the helm test hooks
--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")
--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")
--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
Expand Down
2 changes: 1 addition & 1 deletion cmd/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ func AddDiffOptions(f *pflag.FlagSet, o *diff.Options) {
f.BoolVar(&o.ShowSecretsDecoded, "show-secrets-decoded", false, "decode secret values in the output")
f.StringArrayVar(&o.SuppressedKinds, "suppress", []string{}, "allows suppression of the kinds listed in the diff output (can specify multiple, like '--suppress Deployment --suppress Service')")
f.IntVarP(&o.OutputContext, "context", "C", -1, "output NUM lines of context around changes")
f.StringVar(&o.OutputFormat, "output", "diff", "Possible values: diff, simple, template, dyff. When set to \"template\", use the env var HELM_DIFF_TPL to specify the template.")
f.StringVar(&o.OutputFormat, "output", "diff", "Possible values: diff, simple, template, json, structured, dyff. When set to \"template\", use the env var HELM_DIFF_TPL to specify the template.")
f.BoolVar(&o.StripTrailingCR, "strip-trailing-cr", false, "strip trailing carriage return on input")
f.Float32VarP(&o.FindRenames, "find-renames", "D", 0, "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")
f.StringArrayVar(&o.SuppressedOutputLineRegex, "suppress-output-line-regex", []string{}, "a regex to suppress diff output lines that match")
Expand Down
2 changes: 1 addition & 1 deletion cmd/release.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ func (d *release) differentiateHelm3() error {
&d.Options,
os.Stdout)

if d.detailedExitCode && seenAnyChanges {
if d.detailedExitCode && !d.Options.StructuredOutput() && seenAnyChanges {
return Error{
error: errors.New("identified at least one change, exiting with non-zero exit code (detailed-exitcode parameter enabled)"),
Code: 2,
Expand Down
2 changes: 1 addition & 1 deletion cmd/revision.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ func (d *revision) differentiateHelm3() error {
&d.Options,
os.Stdout)

if d.detailedExitCode && seenAnyChanges {
if d.detailedExitCode && !d.Options.StructuredOutput() && seenAnyChanges {
return Error{
error: errors.New("identified at least one change, exiting with non-zero exit code (detailed-exitcode parameter enabled)"),
Code: 2,
Expand Down
2 changes: 1 addition & 1 deletion cmd/rollback.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ func (d *rollback) backcastHelm3() error {
&d.Options,
os.Stdout)

if d.detailedExitCode && seenAnyChanges {
if d.detailedExitCode && !d.Options.StructuredOutput() && seenAnyChanges {
return Error{
error: errors.New("identified at least one change, exiting with non-zero exit code (detailed-exitcode parameter enabled)"),
Code: 2,
Expand Down
2 changes: 1 addition & 1 deletion cmd/upgrade.go
Original file line number Diff line number Diff line change
Expand Up @@ -347,7 +347,7 @@ func (d *diffCmd) runHelm3() error {

seenAnyChanges := diff.ManifestsOwnership(currentSpecs, newSpecs, newOwnedReleases, &d.Options, os.Stdout)

if d.detailedExitCode && seenAnyChanges {
if d.detailedExitCode && !d.Options.StructuredOutput() && seenAnyChanges {
return Error{
error: errors.New("identified at least one change, exiting with non-zero exit code (detailed-exitcode parameter enabled)"),
Code: 2,
Expand Down
65 changes: 51 additions & 14 deletions diff/diff.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ type Options struct {
SuppressedOutputLineRegex []string
}

// StructuredOutput returns true when the structured JSON output is requested.
func (o *Options) StructuredOutput() bool {
return o != nil && o.OutputFormat == "structured"
}

type OwnershipDiff struct {
OldRelease string
NewRelease string
Expand Down Expand Up @@ -65,7 +70,7 @@ func generateReport(oldIndex, newIndex map[string]*manifest.MappingResult, newOw

for name, diff := range newOwnedReleases {
diff := diffStrings(diff.OldRelease, diff.NewRelease, true)
report.addEntry(name, options.SuppressedKinds, "", 0, diff, "OWNERSHIP")
report.addEntry(name, options.SuppressedKinds, "", 0, diff, "OWNERSHIP", nil)
}

for _, key := range sortedKeys(oldIndex) {
Expand Down Expand Up @@ -159,7 +164,7 @@ func doSuppress(report Report, suppressedOutputLineRegex []string) (Report, erro
entry.ChangeType = "MODIFY_SUPPRESSED"
}

filteredReport.addEntry(entry.Key, entry.SuppressedKinds, entry.Kind, entry.Context, diffRecords, entry.ChangeType)
filteredReport.addEntry(entry.Key, entry.SuppressedKinds, entry.Kind, entry.Context, diffRecords, entry.ChangeType, entry.Structured)
}

return filteredReport, nil
Expand Down Expand Up @@ -235,20 +240,52 @@ func doDiff(report *Report, key string, oldContent *manifest.MappingResult, newC
redactSecrets(oldContent, newContent)
}

if oldContent == nil {
emptyMapping := &manifest.MappingResult{}
diffs := diffMappingResults(emptyMapping, newContent, options.StripTrailingCR)
report.addEntry(key, options.SuppressedKinds, newContent.Kind, options.OutputContext, diffs, "ADD")
} else if newContent == nil {
emptyMapping := &manifest.MappingResult{}
diffs := diffMappingResults(oldContent, emptyMapping, options.StripTrailingCR)
report.addEntry(key, options.SuppressedKinds, oldContent.Kind, options.OutputContext, diffs, "REMOVE")
} else {
diffs := diffMappingResults(oldContent, newContent, options.StripTrailingCR)
if actualChanges(diffs) > 0 {
report.addEntry(key, options.SuppressedKinds, oldContent.Kind, options.OutputContext, diffs, "MODIFY")
var changeType string
var subjectKind string
var diffs []difflib.DiffRecord
switch {
case oldContent == nil:
changeType = "ADD"
if newContent != nil {
subjectKind = newContent.Kind
}
if report.mode != "structured" && newContent != nil {
emptyMapping := &manifest.MappingResult{}
diffs = diffMappingResults(emptyMapping, newContent, options.StripTrailingCR)
}
case newContent == nil:
changeType = "REMOVE"
if oldContent != nil {
subjectKind = oldContent.Kind
}
if report.mode != "structured" && oldContent != nil {
emptyMapping := &manifest.MappingResult{}
diffs = diffMappingResults(oldContent, emptyMapping, options.StripTrailingCR)
}
default:
changeType = "MODIFY"
subjectKind = oldContent.Kind
if report.mode != "structured" {
diffs = diffMappingResults(oldContent, newContent, options.StripTrailingCR)
if actualChanges(diffs) == 0 {
return
}
}
}

var structured *StructuredEntry
if report.mode == "structured" {
entry, err := buildStructuredEntry(key, changeType, subjectKind, options.SuppressedKinds, oldContent, newContent)
if err != nil {
panic(err)
}
if changeType == "MODIFY" && !entry.ChangesSuppressed && len(entry.Changes) == 0 {
return
}
structured = entry
}

report.addEntry(key, options.SuppressedKinds, subjectKind, options.OutputContext, diffs, changeType, structured)
}

func preHandleSecrets(old, new *manifest.MappingResult) (v1.Secret, v1.Secret, error, error) {
Expand Down
140 changes: 140 additions & 0 deletions diff/diff_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import (
"bytes"
"encoding/json"
"os"
"testing"

Expand Down Expand Up @@ -585,6 +586,145 @@
})
}

func TestStructuredOutputModify(t *testing.T) {
ansi.DisableColors(true)
opts := &Options{OutputFormat: "structured"}
oldManifest := `
apiVersion: apps/v1
kind: Deployment
metadata:
name: web
namespace: prod
spec:
replicas: 2
template:
spec:
containers:
- name: app
image: demo:v1
`
newManifest := `
apiVersion: apps/v1
kind: Deployment
metadata:
name: web
namespace: prod
spec:
replicas: 3
template:
spec:
containers:
- name: app
image: demo:v2
`
oldIndex := manifest.Parse(oldManifest, "prod", true)
newIndex := manifest.Parse(newManifest, "prod", true)

var buf bytes.Buffer
changed := Manifests(oldIndex, newIndex, opts, &buf)
require.True(t, changed)

var entries []StructuredEntry
require.NoError(t, json.Unmarshal(buf.Bytes(), &entries))
require.Len(t, entries, 1)
entry := entries[0]
require.Equal(t, "MODIFY", entry.ChangeType)
require.Equal(t, "apps/v1", entry.APIVersion)
require.Equal(t, "Deployment", entry.Kind)
require.Equal(t, "prod", entry.Namespace)
require.Equal(t, "web", entry.Name)
require.Len(t, entry.Changes, 2)
replicasChange, ok := findChange(entry.Changes, "spec", "replicas")
require.True(t, ok)
require.Equal(t, float64(2), replicasChange.OldValue)

Check failure on line 639 in diff/diff_test.go

View workflow job for this annotation

GitHub Actions / Lint

float-compare: use require.InEpsilon (or InDelta) (testifylint)
require.Equal(t, float64(3), replicasChange.NewValue)

Check failure on line 640 in diff/diff_test.go

View workflow job for this annotation

GitHub Actions / Lint

float-compare: use require.InEpsilon (or InDelta) (testifylint)

imageChange, ok := findChange(entry.Changes, "spec.template.spec.containers[0]", "image")
require.True(t, ok)
require.Equal(t, "demo:v1", imageChange.OldValue)
require.Equal(t, "demo:v2", imageChange.NewValue)
}

func TestStructuredOutputAddAndRemove(t *testing.T) {
ansi.DisableColors(true)
opts := &Options{OutputFormat: "structured"}
newManifest := `
apiVersion: batch/v1
kind: Job
metadata:
name: migrate
namespace: ops
spec: {}
`
newIndex := manifest.Parse(newManifest, "ops", true)

var buf bytes.Buffer
changed := Manifests(map[string]*manifest.MappingResult{}, newIndex, opts, &buf)
require.True(t, changed)

var entries []StructuredEntry
require.NoError(t, json.Unmarshal(buf.Bytes(), &entries))
require.Len(t, entries, 1)
require.Equal(t, "ADD", entries[0].ChangeType)
require.True(t, entries[0].ResourceStatus.NewExists)
require.False(t, entries[0].ResourceStatus.OldExists)

// Now test removal
buf.Reset()
changed = Manifests(newIndex, map[string]*manifest.MappingResult{}, opts, &buf)
require.True(t, changed)
require.NoError(t, json.Unmarshal(buf.Bytes(), &entries))
require.Len(t, entries, 1)
require.Equal(t, "REMOVE", entries[0].ChangeType)
require.True(t, entries[0].ResourceStatus.OldExists)
require.False(t, entries[0].ResourceStatus.NewExists)
}

func TestStructuredOutputSuppressedKind(t *testing.T) {
ansi.DisableColors(true)
opts := &Options{
OutputFormat: "structured",
SuppressedKinds: []string{"Secret"},
}
oldManifest := `
apiVersion: v1
kind: Secret
metadata:
name: creds
data:
password: c29tZQ==
`
newManifest := `
apiVersion: v1
kind: Secret
metadata:
name: creds
data:
password: Zm9v
`
oldIndex := manifest.Parse(oldManifest, "default", true)
newIndex := manifest.Parse(newManifest, "default", true)

var buf bytes.Buffer
changed := Manifests(oldIndex, newIndex, opts, &buf)
require.True(t, changed)

var entries []StructuredEntry
require.NoError(t, json.Unmarshal(buf.Bytes(), &entries))
require.Len(t, entries, 1)
require.True(t, entries[0].ChangesSuppressed)
require.Len(t, entries[0].Changes, 0)

Check failure on line 716 in diff/diff_test.go

View workflow job for this annotation

GitHub Actions / Lint

empty: use require.Empty (testifylint)
}

func findChange(changes []FieldChange, path, field string) (FieldChange, bool) {
for _, change := range changes {
if change.Path == path && change.Field == field {
return change, true
}
}
return FieldChange{}, false
}

func TestManifestsWithRedactedSecrets(t *testing.T) {
ansi.DisableColors(true)

Expand Down
Loading
Loading