-
Notifications
You must be signed in to change notification settings - Fork 315
feat: add structured output mode for helm-diff #899
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Conversation
|
Hi @yxxhero Any thoughts on this PR for the structured output mode? Would you prefer using the existing JSON mode instead for the proposed change? Happy to address any questions or feedback and refine the implementation further. |
|
@madhunzv what's the difference? do you think which is better? |
|
The current JSON output reports high-level metadata and the change type (add / remove / replace) at the resource level. I don’t think one is inherently better than the other, the structured format essentially extends what the JSON output already provides. I chose to introduce it as a separate output mode to avoid changing the existing JSON format and any potentially breaking consumers that rely on it. |
|
@madhunzv make senses. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
This PR introduces a new --output structured format for helm-diff that produces machine-readable JSON output with detailed per-field change information. The structured format uses JSON merge patches to track changes and formats paths using a dot-notation with array indices, making the output suitable for programmatic consumption and CI/CD automation.
Key Changes
- New structured output format with API/Kind metadata, resource existence flags, and field-level change tracking
- Integration of JSON patch library to compute fine-grained diffs between manifests
- Modification of exit code behavior to disable detailed-exitcode when structured output is enabled
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| diff/structured.go | Implements structured output format with JSON patch-based diff calculation and path formatting logic for field changes |
| diff/report.go | Adds structured report setup and print function, stores mode field to track output format |
| diff/diff.go | Adds StructuredOutput() helper method and integrates structured entry building into diff generation flow |
| diff/diff_test.go | Adds test cases for structured output covering MODIFY, ADD, REMOVE operations and suppressed kinds |
| cmd/upgrade.go | Disables detailed-exitcode when structured output is enabled |
| cmd/rollback.go | Disables detailed-exitcode when structured output is enabled |
| cmd/revision.go | Disables detailed-exitcode when structured output is enabled |
| cmd/release.go | Disables detailed-exitcode when structured output is enabled |
| cmd/options.go | Updates flag documentation to include "structured" as a valid output format |
| README.md | Adds documentation section explaining structured output format with examples |
| } | ||
| encoder := json.NewEncoder(to) | ||
| encoder.SetIndent("", " ") | ||
| _ = encoder.Encode(entries) |
Copilot
AI
Jan 3, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The error from encoder.Encode() should be handled instead of being silently discarded. JSON encoding can fail for various reasons (e.g., I/O errors, circular references) and these errors should be reported to the user. Consider returning the error or at least logging it.
| if report.mode == "structured" { | ||
| entry, err := buildStructuredEntry(key, changeType, subjectKind, options.SuppressedKinds, oldContent, newContent) | ||
| if err != nil { | ||
| panic(err) |
Copilot
AI
Jan 3, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The panic on error is inconsistent with standard Go error handling practices. While there is an existing panic at line 52, introducing a new panic here makes error recovery difficult for consumers of this package. Consider propagating the error up the call stack by modifying doDiff to return an error, or at least logging the error with more context before panicking.
| panic(err) | |
| fmt.Printf("failed to build structured diff entry for key %s: %v\n", key, err) | |
| return |
| } | ||
|
|
||
| seenAnyChanges := diff.ManifestsOwnership(currentSpecs, newSpecs, newOwnedReleases, &d.Options, os.Stdout) | ||
|
|
Copilot
AI
Jan 3, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The conditional logic that skips detailed exit code when structured output is enabled should be documented in the README. Users relying on --detailed-exitcode for CI/CD automation need to know that this behavior changes when using --output structured. Consider adding a note to the "Structured JSON output" section explaining that detailed-exitcode is disabled with structured output, or provide justification for why this is the intended behavior.
| // When using structured output (e.g. `--output structured`), callers are expected | |
| // to inspect the JSON result to determine whether changes were detected, rather | |
| // than relying on the special exit code used by `--detailed-exitcode`. | |
| // Therefore, detailed exit codes are only honored when structured output is | |
| // disabled to avoid conflicting signaling mechanisms in CI/CD pipelines. |
| 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.InDelta(t, float64(2), replicasChange.OldValue, 0.001) | ||
| require.InDelta(t, float64(3), replicasChange.NewValue, 0.001) | ||
|
|
||
| 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.Empty(t, entries[0].Changes) | ||
| } |
Copilot
AI
Jan 3, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Test coverage is missing for error paths in structured output. Specifically, there are no tests for scenarios where buildStructuredEntry might fail (e.g., invalid YAML that cannot be converted to JSON, malformed JSON that cannot be unmarshaled). Given that these errors result in panics, it's important to verify that the error handling works correctly and provides useful error messages. Consider adding tests that exercise error paths such as invalid YAML content or JSON parsing failures.
| 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" { |
Copilot
AI
Jan 3, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Using report.mode to check for "structured" output creates indirect coupling and makes the code less maintainable. The mode field duplicates information already available in options.OutputFormat, and checking the string value directly in multiple places increases the risk of typos or inconsistencies. Consider using the Options.StructuredOutput() method that's already defined in this file, which provides a cleaner, more maintainable interface and is less error-prone.
Summary
introduce a new
--output structuredto render machine-readable JSON with API/Kind metadata, resource existence state, and per-field change records (path + field) built from JSON Pointer tokensstructured output was added as a separate format (rather than altering the existing json output) to avoid breaking changes.
Note: this change set was implemented with assistance from an LLM.