Skip to content

Commit 3dec2e2

Browse files
committed
feat: add structured output mode for helm-diff
1 parent 572442b commit 3dec2e2

File tree

12 files changed

+561
-25
lines changed

12 files changed

+561
-25
lines changed

README.md

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ Flags:
115115
--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"
116116
--no-hooks disable diffing of hooks
117117
--normalize-manifests normalize manifests before running diff to exclude style differences from the output
118-
--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")
118+
--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")
119119
--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
120120
--post-renderer-args stringArray an argument to the post-renderer (can specify multiple)
121121
--repo string specify the chart repository url to locate the requested chart
@@ -145,6 +145,33 @@ Additional help topcis:
145145
Use "diff [command] --help" for more information about a command.
146146
```
147147

148+
### Structured JSON output
149+
150+
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:
151+
152+
```shell
153+
helm diff upgrade prod api ./charts/api --output structured
154+
```
155+
156+
```json
157+
[
158+
{
159+
"apiVersion": "apps/v1",
160+
"kind": "Deployment",
161+
"namespace": "prod",
162+
"name": "api",
163+
"changeType": "MODIFY",
164+
"resourceStatus": {"oldExists": true, "newExists": true},
165+
"changes": [
166+
{"path": "spec", "field": "replicas", "change": "replace", "oldValue": 2, "newValue": 3},
167+
{"path": "spec.template.spec.containers[0]", "field": "image", "change": "replace", "oldValue": "api:v1", "newValue": "api:v2"}
168+
]
169+
}
170+
]
171+
```
172+
173+
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`).
174+
148175
## Commands:
149176

150177
### upgrade:
@@ -211,7 +238,7 @@ Flags:
211238
--kubeconfig string This flag is ignored, to allow passing of this top level flag to helm
212239
--no-hooks disable diffing of hooks
213240
--normalize-manifests normalize manifests before running diff to exclude style differences from the output
214-
--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")
241+
--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")
215242
--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
216243
--post-renderer-args stringArray an argument to the post-renderer (can specify multiple)
217244
--repo string specify the chart repository url to locate the requested chart
@@ -266,7 +293,7 @@ Flags:
266293
-h, --help help for release
267294
--include-tests enable the diffing of the helm test hooks
268295
--normalize-manifests normalize manifests before running diff to exclude style differences from the output
269-
--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")
296+
--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")
270297
--show-secrets do not redact secret values in the output
271298
--strip-trailing-cr strip trailing carriage return on input
272299
--suppress stringArray allows suppression of the kinds listed in the diff output (can specify multiple, like '--suppress Deployment --suppress Service')
@@ -308,7 +335,7 @@ Flags:
308335
-h, --help help for revision
309336
--include-tests enable the diffing of the helm test hooks
310337
--normalize-manifests normalize manifests before running diff to exclude style differences from the output
311-
--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")
338+
--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")
312339
--show-secrets do not redact secret values in the output
313340
--show-secrets-decoded decode secret values in the output
314341
--strip-trailing-cr strip trailing carriage return on input
@@ -344,7 +371,7 @@ Flags:
344371
-h, --help help for rollback
345372
--include-tests enable the diffing of the helm test hooks
346373
--normalize-manifests normalize manifests before running diff to exclude style differences from the output
347-
--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")
374+
--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")
348375
--show-secrets do not redact secret values in the output
349376
--show-secrets-decoded decode secret values in the output
350377
--strip-trailing-cr strip trailing carriage return on input

cmd/options.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ func AddDiffOptions(f *pflag.FlagSet, o *diff.Options) {
1313
f.BoolVar(&o.ShowSecretsDecoded, "show-secrets-decoded", false, "decode secret values in the output")
1414
f.StringArrayVar(&o.SuppressedKinds, "suppress", []string{}, "allows suppression of the kinds listed in the diff output (can specify multiple, like '--suppress Deployment --suppress Service')")
1515
f.IntVarP(&o.OutputContext, "context", "C", -1, "output NUM lines of context around changes")
16-
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.")
16+
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.")
1717
f.BoolVar(&o.StripTrailingCR, "strip-trailing-cr", false, "strip trailing carriage return on input")
1818
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")
1919
f.StringArrayVar(&o.SuppressedOutputLineRegex, "suppress-output-line-regex", []string{}, "a regex to suppress diff output lines that match")

cmd/release.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ func (d *release) differentiateHelm3() error {
113113
&d.Options,
114114
os.Stdout)
115115

116-
if d.detailedExitCode && seenAnyChanges {
116+
if d.detailedExitCode && !d.Options.StructuredOutput() && seenAnyChanges {
117117
return Error{
118118
error: errors.New("identified at least one change, exiting with non-zero exit code (detailed-exitcode parameter enabled)"),
119119
Code: 2,

cmd/revision.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ func (d *revision) differentiateHelm3() error {
126126
&d.Options,
127127
os.Stdout)
128128

129-
if d.detailedExitCode && seenAnyChanges {
129+
if d.detailedExitCode && !d.Options.StructuredOutput() && seenAnyChanges {
130130
return Error{
131131
error: errors.New("identified at least one change, exiting with non-zero exit code (detailed-exitcode parameter enabled)"),
132132
Code: 2,

cmd/rollback.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ func (d *rollback) backcastHelm3() error {
9494
&d.Options,
9595
os.Stdout)
9696

97-
if d.detailedExitCode && seenAnyChanges {
97+
if d.detailedExitCode && !d.Options.StructuredOutput() && seenAnyChanges {
9898
return Error{
9999
error: errors.New("identified at least one change, exiting with non-zero exit code (detailed-exitcode parameter enabled)"),
100100
Code: 2,

cmd/upgrade.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -347,7 +347,7 @@ func (d *diffCmd) runHelm3() error {
347347

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

350-
if d.detailedExitCode && seenAnyChanges {
350+
if d.detailedExitCode && !d.Options.StructuredOutput() && seenAnyChanges {
351351
return Error{
352352
error: errors.New("identified at least one change, exiting with non-zero exit code (detailed-exitcode parameter enabled)"),
353353
Code: 2,

diff/diff.go

Lines changed: 51 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@ type Options struct {
3131
SuppressedOutputLineRegex []string
3232
}
3333

34+
// StructuredOutput returns true when the structured JSON output is requested.
35+
func (o *Options) StructuredOutput() bool {
36+
return o != nil && o.OutputFormat == "structured"
37+
}
38+
3439
type OwnershipDiff struct {
3540
OldRelease string
3641
NewRelease string
@@ -65,7 +70,7 @@ func generateReport(oldIndex, newIndex map[string]*manifest.MappingResult, newOw
6570

6671
for name, diff := range newOwnedReleases {
6772
diff := diffStrings(diff.OldRelease, diff.NewRelease, true)
68-
report.addEntry(name, options.SuppressedKinds, "", 0, diff, "OWNERSHIP")
73+
report.addEntry(name, options.SuppressedKinds, "", 0, diff, "OWNERSHIP", nil)
6974
}
7075

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

162-
filteredReport.addEntry(entry.Key, entry.SuppressedKinds, entry.Kind, entry.Context, diffRecords, entry.ChangeType)
167+
filteredReport.addEntry(entry.Key, entry.SuppressedKinds, entry.Kind, entry.Context, diffRecords, entry.ChangeType, entry.Structured)
163168
}
164169

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

238-
if oldContent == nil {
239-
emptyMapping := &manifest.MappingResult{}
240-
diffs := diffMappingResults(emptyMapping, newContent, options.StripTrailingCR)
241-
report.addEntry(key, options.SuppressedKinds, newContent.Kind, options.OutputContext, diffs, "ADD")
242-
} else if newContent == nil {
243-
emptyMapping := &manifest.MappingResult{}
244-
diffs := diffMappingResults(oldContent, emptyMapping, options.StripTrailingCR)
245-
report.addEntry(key, options.SuppressedKinds, oldContent.Kind, options.OutputContext, diffs, "REMOVE")
246-
} else {
247-
diffs := diffMappingResults(oldContent, newContent, options.StripTrailingCR)
248-
if actualChanges(diffs) > 0 {
249-
report.addEntry(key, options.SuppressedKinds, oldContent.Kind, options.OutputContext, diffs, "MODIFY")
243+
var changeType string
244+
var subjectKind string
245+
var diffs []difflib.DiffRecord
246+
switch {
247+
case oldContent == nil:
248+
changeType = "ADD"
249+
if newContent != nil {
250+
subjectKind = newContent.Kind
251+
}
252+
if report.mode != "structured" && newContent != nil {
253+
emptyMapping := &manifest.MappingResult{}
254+
diffs = diffMappingResults(emptyMapping, newContent, options.StripTrailingCR)
255+
}
256+
case newContent == nil:
257+
changeType = "REMOVE"
258+
if oldContent != nil {
259+
subjectKind = oldContent.Kind
260+
}
261+
if report.mode != "structured" && oldContent != nil {
262+
emptyMapping := &manifest.MappingResult{}
263+
diffs = diffMappingResults(oldContent, emptyMapping, options.StripTrailingCR)
264+
}
265+
default:
266+
changeType = "MODIFY"
267+
subjectKind = oldContent.Kind
268+
if report.mode != "structured" {
269+
diffs = diffMappingResults(oldContent, newContent, options.StripTrailingCR)
270+
if actualChanges(diffs) == 0 {
271+
return
272+
}
273+
}
274+
}
275+
276+
var structured *StructuredEntry
277+
if report.mode == "structured" {
278+
entry, err := buildStructuredEntry(key, changeType, subjectKind, options.SuppressedKinds, oldContent, newContent)
279+
if err != nil {
280+
panic(err)
281+
}
282+
if changeType == "MODIFY" && !entry.ChangesSuppressed && len(entry.Changes) == 0 {
283+
return
250284
}
285+
structured = entry
251286
}
287+
288+
report.addEntry(key, options.SuppressedKinds, subjectKind, options.OutputContext, diffs, changeType, structured)
252289
}
253290

254291
func preHandleSecrets(old, new *manifest.MappingResult) (v1.Secret, v1.Secret, error, error) {

diff/diff_test.go

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package diff
22

33
import (
44
"bytes"
5+
"encoding/json"
56
"os"
67
"testing"
78

@@ -585,6 +586,145 @@ Plan: 0 to add, 1 to change, 0 to destroy, 0 to change ownership.
585586
})
586587
}
587588

589+
func TestStructuredOutputModify(t *testing.T) {
590+
ansi.DisableColors(true)
591+
opts := &Options{OutputFormat: "structured"}
592+
oldManifest := `
593+
apiVersion: apps/v1
594+
kind: Deployment
595+
metadata:
596+
name: web
597+
namespace: prod
598+
spec:
599+
replicas: 2
600+
template:
601+
spec:
602+
containers:
603+
- name: app
604+
image: demo:v1
605+
`
606+
newManifest := `
607+
apiVersion: apps/v1
608+
kind: Deployment
609+
metadata:
610+
name: web
611+
namespace: prod
612+
spec:
613+
replicas: 3
614+
template:
615+
spec:
616+
containers:
617+
- name: app
618+
image: demo:v2
619+
`
620+
oldIndex := manifest.Parse(oldManifest, "prod", true)
621+
newIndex := manifest.Parse(newManifest, "prod", true)
622+
623+
var buf bytes.Buffer
624+
changed := Manifests(oldIndex, newIndex, opts, &buf)
625+
require.True(t, changed)
626+
627+
var entries []StructuredEntry
628+
require.NoError(t, json.Unmarshal(buf.Bytes(), &entries))
629+
require.Len(t, entries, 1)
630+
entry := entries[0]
631+
require.Equal(t, "MODIFY", entry.ChangeType)
632+
require.Equal(t, "apps/v1", entry.APIVersion)
633+
require.Equal(t, "Deployment", entry.Kind)
634+
require.Equal(t, "prod", entry.Namespace)
635+
require.Equal(t, "web", entry.Name)
636+
require.Len(t, entry.Changes, 2)
637+
replicasChange, ok := findChange(entry.Changes, "spec", "replicas")
638+
require.True(t, ok)
639+
require.Equal(t, float64(2), replicasChange.OldValue)
640+
require.Equal(t, float64(3), replicasChange.NewValue)
641+
642+
imageChange, ok := findChange(entry.Changes, "spec.template.spec.containers[0]", "image")
643+
require.True(t, ok)
644+
require.Equal(t, "demo:v1", imageChange.OldValue)
645+
require.Equal(t, "demo:v2", imageChange.NewValue)
646+
}
647+
648+
func TestStructuredOutputAddAndRemove(t *testing.T) {
649+
ansi.DisableColors(true)
650+
opts := &Options{OutputFormat: "structured"}
651+
newManifest := `
652+
apiVersion: batch/v1
653+
kind: Job
654+
metadata:
655+
name: migrate
656+
namespace: ops
657+
spec: {}
658+
`
659+
newIndex := manifest.Parse(newManifest, "ops", true)
660+
661+
var buf bytes.Buffer
662+
changed := Manifests(map[string]*manifest.MappingResult{}, newIndex, opts, &buf)
663+
require.True(t, changed)
664+
665+
var entries []StructuredEntry
666+
require.NoError(t, json.Unmarshal(buf.Bytes(), &entries))
667+
require.Len(t, entries, 1)
668+
require.Equal(t, "ADD", entries[0].ChangeType)
669+
require.True(t, entries[0].ResourceStatus.NewExists)
670+
require.False(t, entries[0].ResourceStatus.OldExists)
671+
672+
// Now test removal
673+
buf.Reset()
674+
changed = Manifests(newIndex, map[string]*manifest.MappingResult{}, opts, &buf)
675+
require.True(t, changed)
676+
require.NoError(t, json.Unmarshal(buf.Bytes(), &entries))
677+
require.Len(t, entries, 1)
678+
require.Equal(t, "REMOVE", entries[0].ChangeType)
679+
require.True(t, entries[0].ResourceStatus.OldExists)
680+
require.False(t, entries[0].ResourceStatus.NewExists)
681+
}
682+
683+
func TestStructuredOutputSuppressedKind(t *testing.T) {
684+
ansi.DisableColors(true)
685+
opts := &Options{
686+
OutputFormat: "structured",
687+
SuppressedKinds: []string{"Secret"},
688+
}
689+
oldManifest := `
690+
apiVersion: v1
691+
kind: Secret
692+
metadata:
693+
name: creds
694+
data:
695+
password: c29tZQ==
696+
`
697+
newManifest := `
698+
apiVersion: v1
699+
kind: Secret
700+
metadata:
701+
name: creds
702+
data:
703+
password: Zm9v
704+
`
705+
oldIndex := manifest.Parse(oldManifest, "default", true)
706+
newIndex := manifest.Parse(newManifest, "default", true)
707+
708+
var buf bytes.Buffer
709+
changed := Manifests(oldIndex, newIndex, opts, &buf)
710+
require.True(t, changed)
711+
712+
var entries []StructuredEntry
713+
require.NoError(t, json.Unmarshal(buf.Bytes(), &entries))
714+
require.Len(t, entries, 1)
715+
require.True(t, entries[0].ChangesSuppressed)
716+
require.Len(t, entries[0].Changes, 0)
717+
}
718+
719+
func findChange(changes []FieldChange, path, field string) (FieldChange, bool) {
720+
for _, change := range changes {
721+
if change.Path == path && change.Field == field {
722+
return change, true
723+
}
724+
}
725+
return FieldChange{}, false
726+
}
727+
588728
func TestManifestsWithRedactedSecrets(t *testing.T) {
589729
ansi.DisableColors(true)
590730

0 commit comments

Comments
 (0)