diff --git a/README.md b/README.md index c9839acf..0476fc48 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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: @@ -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 @@ -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') @@ -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 @@ -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 diff --git a/cmd/options.go b/cmd/options.go index 502b73d7..8733ee98 100644 --- a/cmd/options.go +++ b/cmd/options.go @@ -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") diff --git a/cmd/release.go b/cmd/release.go index f0286e58..64cd9ad1 100644 --- a/cmd/release.go +++ b/cmd/release.go @@ -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, diff --git a/cmd/revision.go b/cmd/revision.go index 8599789b..1cf0ddca 100644 --- a/cmd/revision.go +++ b/cmd/revision.go @@ -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, diff --git a/cmd/rollback.go b/cmd/rollback.go index 6f8d6d17..6510f618 100644 --- a/cmd/rollback.go +++ b/cmd/rollback.go @@ -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, diff --git a/cmd/upgrade.go b/cmd/upgrade.go index 40d01b4c..8587c79f 100644 --- a/cmd/upgrade.go +++ b/cmd/upgrade.go @@ -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, diff --git a/diff/diff.go b/diff/diff.go index f2b63589..758f2b26 100644 --- a/diff/diff.go +++ b/diff/diff.go @@ -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 @@ -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) { @@ -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 @@ -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) { diff --git a/diff/diff_test.go b/diff/diff_test.go index 56b844f7..ef00134d 100644 --- a/diff/diff_test.go +++ b/diff/diff_test.go @@ -2,6 +2,7 @@ package diff import ( "bytes" + "encoding/json" "os" "testing" @@ -585,6 +586,145 @@ Plan: 0 to add, 1 to change, 0 to destroy, 0 to change ownership. }) } +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) + require.Equal(t, float64(3), replicasChange.NewValue) + + 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) +} + +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) diff --git a/diff/report.go b/diff/report.go index ac456365..fbf8eb07 100644 --- a/diff/report.go +++ b/diff/report.go @@ -1,6 +1,7 @@ package diff import ( + "encoding/json" "errors" "fmt" "io" @@ -21,6 +22,7 @@ import ( type Report struct { format ReportFormat Entries []ReportEntry + mode string } // ReportEntry to store changes between releases @@ -31,6 +33,7 @@ type ReportEntry struct { Context int Diffs []difflib.DiffRecord ChangeType string + Structured *StructuredEntry } // ReportFormat to the context to make a changes report @@ -56,6 +59,7 @@ type ReportTemplateSpec struct { // setupReportFormat: process output argument. func (r *Report) setupReportFormat(format string) { + r.mode = format switch format { case "simple": setupSimpleReport(r) @@ -63,6 +67,8 @@ func (r *Report) setupReportFormat(format string) { setupTemplateReport(r) case "json": setupJSONReport(r) + case "structured": + setupStructuredReport(r) case "dyff": setupDyffReport(r) default: @@ -114,7 +120,7 @@ func printDyffReport(r *Report, to io.Writer) { } // addEntry: stores diff changes. -func (r *Report) addEntry(key string, suppressedKinds []string, kind string, context int, diffs []difflib.DiffRecord, changeType string) { +func (r *Report) addEntry(key string, suppressedKinds []string, kind string, context int, diffs []difflib.DiffRecord, changeType string, structured *StructuredEntry) { entry := ReportEntry{ key, suppressedKinds, @@ -122,6 +128,7 @@ func (r *Report) addEntry(key string, suppressedKinds []string, kind string, con context, diffs, changeType, + structured, } r.Entries = append(r.Entries, entry) } @@ -249,6 +256,31 @@ func setupTemplateReport(r *Report) { r.format.changestyles["MODIFY_SUPPRESSED"] = ChangeStyle{color: "blue+h", message: ""} } +func setupStructuredReport(r *Report) { + r.format.output = printStructuredReport +} + +func printStructuredReport(r *Report, to io.Writer) { + entries := make([]StructuredEntry, 0, len(r.Entries)) + for _, entry := range r.Entries { + if entry.Structured != nil { + structuredCopy := *entry.Structured + if structuredCopy.ChangeType == "" { + structuredCopy.ChangeType = entry.ChangeType + } + entries = append(entries, structuredCopy) + continue + } + entries = append(entries, StructuredEntry{ + Name: entry.Key, + ChangeType: entry.ChangeType, + }) + } + encoder := json.NewEncoder(to) + encoder.SetIndent("", " ") + _ = encoder.Encode(entries) +} + // report with template output will only have access to ReportTemplateSpec. // This function reverts parsedMetadata.String() func (t *ReportTemplateSpec) loadFromKey(key string) error { diff --git a/diff/structured.go b/diff/structured.go new file mode 100644 index 00000000..5a8dc7b3 --- /dev/null +++ b/diff/structured.go @@ -0,0 +1,297 @@ +package diff + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + + jsonpatch "gomodules.xyz/jsonpatch/v2" + "sigs.k8s.io/yaml" + + "github.com/databus23/helm-diff/v3/manifest" +) + +// StructuredEntry captures machine-readable diff information for a resource. +type StructuredEntry struct { + APIVersion string `json:"apiVersion,omitempty"` + Kind string `json:"kind,omitempty"` + Namespace string `json:"namespace,omitempty"` + Name string `json:"name,omitempty"` + ChangeType string `json:"changeType,omitempty"` + ResourceStatus ResourceStatus `json:"resourceStatus"` + Changes []FieldChange `json:"changes,omitempty"` + ChangesSuppressed bool `json:"changesSuppressed,omitempty"` +} + +// ResourceStatus indicates whether manifests existed before or after the diff. +type ResourceStatus struct { + OldExists bool `json:"oldExists"` + NewExists bool `json:"newExists"` +} + +// FieldChange stores a JSON-Pointer path and the change that occurred. +type FieldChange struct { + Path string `json:"path,omitempty"` + Field string `json:"field,omitempty"` + Change string `json:"change"` + OldValue interface{} `json:"oldValue,omitempty"` + NewValue interface{} `json:"newValue,omitempty"` +} + +func buildStructuredEntry(key, changeType, kind string, suppressedKinds []string, oldContent, newContent *manifest.MappingResult) (*StructuredEntry, error) { + entry := &StructuredEntry{ + ChangeType: changeType, + ResourceStatus: ResourceStatus{ + OldExists: manifestExists(oldContent), + NewExists: manifestExists(newContent), + }, + } + + isSuppressed := containsKind(suppressedKinds, kind) + entry.ChangesSuppressed = isSuppressed + + oldJSON, oldObj, err := manifestToJSON(oldContent) + if err != nil { + return nil, fmt.Errorf("convert old manifest: %w", err) + } + newJSON, newObj, err := manifestToJSON(newContent) + if err != nil { + return nil, fmt.Errorf("convert new manifest: %w", err) + } + + entry.populateMetadata(key, oldObj, newObj) + + if isSuppressed { + return entry, nil + } + + if changeType == "MODIFY" && oldJSON != nil && newJSON != nil { + changes, err := calculateFieldChanges(oldJSON, newJSON) + if err != nil { + return nil, err + } + entry.Changes = changes + } + + return entry, nil +} + +func manifestExists(m *manifest.MappingResult) bool { + return m != nil && strings.TrimSpace(m.Content) != "" +} + +func manifestToJSON(m *manifest.MappingResult) ([]byte, map[string]interface{}, error) { + if m == nil || strings.TrimSpace(m.Content) == "" { + return nil, nil, nil + } + jsonBytes, err := yaml.YAMLToJSON([]byte(m.Content)) + if err != nil { + return nil, nil, err + } + + if len(jsonBytes) == 0 || string(jsonBytes) == "null" { + return jsonBytes, nil, nil + } + + var obj map[string]interface{} + if err := json.Unmarshal(jsonBytes, &obj); err != nil { + return nil, nil, err + } + + return jsonBytes, obj, nil +} + +func (e *StructuredEntry) populateMetadata(key string, objects ...map[string]interface{}) { + for _, obj := range objects { + if obj == nil { + continue + } + if e.APIVersion == "" { + if v, ok := obj["apiVersion"].(string); ok { + e.APIVersion = v + } + } + if e.Kind == "" { + if v, ok := obj["kind"].(string); ok { + e.Kind = v + } + } + if meta, ok := obj["metadata"].(map[string]interface{}); ok { + if e.Name == "" { + if v, ok := meta["name"].(string); ok { + e.Name = v + } + } + if e.Namespace == "" { + if v, ok := meta["namespace"].(string); ok { + e.Namespace = v + } + } + } + } + + if e.Kind == "" || e.Name == "" || e.Namespace == "" || e.APIVersion == "" { + templateData := ReportTemplateSpec{} + if err := templateData.loadFromKey(key); err == nil { + if e.Kind == "" { + e.Kind = templateData.Kind + } + if e.Name == "" { + e.Name = templateData.Name + } + if e.Namespace == "" { + e.Namespace = templateData.Namespace + } + if e.APIVersion == "" { + e.APIVersion = templateData.API + } + } + } +} + +func calculateFieldChanges(oldJSON, newJSON []byte) ([]FieldChange, error) { + patch, err := jsonpatch.CreatePatch(oldJSON, newJSON) + if err != nil { + return nil, err + } + if len(patch) == 0 { + return nil, nil + } + + var oldDoc interface{} + if err := json.Unmarshal(oldJSON, &oldDoc); err != nil { + return nil, err + } + + changes := make([]FieldChange, 0, len(patch)) + for _, operation := range patch { + tokens := pointerTokens(operation.Path) + path, field := splitPointer(tokens) + + change := FieldChange{ + Path: path, + Field: field, + Change: operation.Operation, + } + + if (operation.Operation == "remove" || operation.Operation == "replace") && operation.Path != "" { + if value, err := resolveJSONPointer(oldDoc, operation.Path); err == nil { + change.OldValue = value + } + } + + if (operation.Operation == "add" || operation.Operation == "replace") && operation.Value != nil { + change.NewValue = operation.Value + } + + changes = append(changes, change) + } + + return changes, nil +} + +func resolveJSONPointer(doc interface{}, pointer string) (interface{}, error) { + if pointer == "" { + return doc, nil + } + + rawTokens := strings.Split(pointer, "/")[1:] + tokens := make([]string, 0, len(rawTokens)) + for _, rawToken := range rawTokens { + tokens = append(tokens, decodePointerToken(rawToken)) + } + + current := doc + + for _, rawToken := range tokens { + token := rawToken + switch typed := current.(type) { + case map[string]interface{}: + current = typed[token] + case []interface{}: + if token == "-" { + return nil, fmt.Errorf("pointer '-' not addressable") + } + index, err := strconv.Atoi(token) + if err != nil || index < 0 || index >= len(typed) { + return nil, fmt.Errorf("invalid array index %s", token) + } + current = typed[index] + default: + return nil, fmt.Errorf("unable to navigate pointer through %T", current) + } + } + + return current, nil +} + +func containsKind(list []string, target string) bool { + for _, item := range list { + if item == target { + return true + } + } + return false +} + +func pointerTokens(pointer string) []string { + if pointer == "" { + return nil + } + rawTokens := strings.Split(pointer, "/")[1:] + tokens := make([]string, 0, len(rawTokens)) + for _, token := range rawTokens { + tokens = append(tokens, decodePointerToken(token)) + } + return tokens +} + +func decodePointerToken(token string) string { + token = strings.ReplaceAll(token, "~1", "/") + token = strings.ReplaceAll(token, "~0", "~") + return token +} + +func splitPointer(tokens []string) (string, string) { + if len(tokens) == 0 { + return "", "" + } + parent := formatPath(tokens[:len(tokens)-1]) + field := tokens[len(tokens)-1] + return parent, field +} + +func formatPath(tokens []string) string { + if len(tokens) == 0 { + return "" + } + segments := []string{} + for _, token := range tokens { + if token == "" { + continue + } + if isArrayIndex(token) { + if len(segments) == 0 { + segments = append(segments, "["+token+"]") + } else { + segments[len(segments)-1] = segments[len(segments)-1] + "[" + token + "]" + } + continue + } + segments = append(segments, token) + } + return strings.Join(segments, ".") +} + +func isArrayIndex(token string) bool { + if token == "" { + return false + } + for _, r := range token { + if r < '0' || r > '9' { + return false + } + } + return true +} diff --git a/go.mod b/go.mod index 893f98ed..434c198b 100644 --- a/go.mod +++ b/go.mod @@ -120,6 +120,7 @@ require ( golang.org/x/sys v0.38.0 // indirect golang.org/x/text v0.31.0 // indirect golang.org/x/time v0.12.0 // indirect + gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect google.golang.org/protobuf v1.36.6 // indirect gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect diff --git a/go.sum b/go.sum index d5e73116..68b58a92 100644 --- a/go.sum +++ b/go.sum @@ -412,6 +412,8 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gomodules.xyz/jsonpatch/v2 v2.5.0 h1:JELs8RLM12qJGXU4u/TO3V25KW8GreMKl9pdkk14RM0= +gomodules.xyz/jsonpatch/v2 v2.5.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb h1:p31xT4yrYrSM/G4Sn2+TNUkVhFCbG9y8itM2S6Th950= google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:jbe3Bkdp+Dh2IrslsFCklNhweNTBgSYanP1UXhJDhKg= google.golang.org/genproto/googleapis/rpc v0.0.0-20250303144028-a0af3efb3deb h1:TLPQVbx1GJ8VKZxz52VAxl1EBgKXXbTiU9Fc5fZeLn4=