Skip to content

Commit 75a3d21

Browse files
authored
feat: add structured output mode for helm-diff (#899)
* feat: add structured output mode for helm-diff * fix lint errors * fix: log panic msg and add tests for error paths * refactor: improve error handling in structured entry creation and update tests
1 parent 17fe41f commit 75a3d21

File tree

6 files changed

+904
-21
lines changed

6 files changed

+904
-21
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")

diff/diff.go

Lines changed: 56 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"fmt"
66
"io"
77
"math"
8+
"os"
89
"regexp"
910
"sort"
1011
"strings"
@@ -31,6 +32,11 @@ type Options struct {
3132
SuppressedOutputLineRegex []string
3233
}
3334

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

6672
for name, diff := range newOwnedReleases {
6773
diff := diffStrings(diff.OldRelease, diff.NewRelease, true)
68-
report.addEntry(name, options.SuppressedKinds, "", 0, diff, "OWNERSHIP")
74+
report.addEntry(name, options.SuppressedKinds, "", 0, diff, "OWNERSHIP", nil)
6975
}
7076

7177
for _, key := range sortedKeys(oldIndex) {
@@ -159,7 +165,7 @@ func doSuppress(report Report, suppressedOutputLineRegex []string) (Report, erro
159165
entry.ChangeType = "MODIFY_SUPPRESSED"
160166
}
161167

162-
filteredReport.addEntry(entry.Key, entry.SuppressedKinds, entry.Kind, entry.Context, diffRecords, entry.ChangeType)
168+
filteredReport.addEntry(entry.Key, entry.SuppressedKinds, entry.Kind, entry.Context, diffRecords, entry.ChangeType, entry.Structured)
163169
}
164170

165171
return filteredReport, nil
@@ -235,20 +241,56 @@ func doDiff(report *Report, key string, oldContent *manifest.MappingResult, newC
235241
redactSecrets(oldContent, newContent)
236242
}
237243

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")
244+
var changeType string
245+
var subjectKind string
246+
var diffs []difflib.DiffRecord
247+
switch {
248+
case oldContent == nil:
249+
changeType = "ADD"
250+
if newContent != nil {
251+
subjectKind = newContent.Kind
252+
}
253+
if !options.StructuredOutput() && newContent != nil {
254+
emptyMapping := &manifest.MappingResult{}
255+
diffs = diffMappingResults(emptyMapping, newContent, options.StripTrailingCR)
256+
}
257+
case newContent == nil:
258+
changeType = "REMOVE"
259+
if oldContent != nil {
260+
subjectKind = oldContent.Kind
261+
}
262+
if !options.StructuredOutput() && oldContent != nil {
263+
emptyMapping := &manifest.MappingResult{}
264+
diffs = diffMappingResults(oldContent, emptyMapping, options.StripTrailingCR)
265+
}
266+
default:
267+
changeType = "MODIFY"
268+
subjectKind = oldContent.Kind
269+
if !options.StructuredOutput() {
270+
diffs = diffMappingResults(oldContent, newContent, options.StripTrailingCR)
271+
if actualChanges(diffs) == 0 {
272+
return
273+
}
274+
}
275+
}
276+
277+
var structured *StructuredEntry
278+
if options.StructuredOutput() {
279+
entry, err := buildStructuredEntry(key, changeType, subjectKind, options.SuppressedKinds, oldContent, newContent)
280+
if err != nil {
281+
// Log warning and omit field-level changes for this entry
282+
// printStructuredReport() will still output a basic entry with name and changeType
283+
fmt.Fprintf(os.Stderr, "Warning: failed to build structured entry for %s (kind: %s, changeType: %s): %v\n",
284+
key, subjectKind, changeType, err)
285+
} else {
286+
if changeType == "MODIFY" && !entry.ChangesSuppressed && len(entry.Changes) == 0 {
287+
return
288+
}
289+
structured = entry
250290
}
251291
}
292+
293+
report.addEntry(key, options.SuppressedKinds, subjectKind, options.OutputContext, diffs, changeType, structured)
252294
}
253295

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

0 commit comments

Comments
 (0)