diff --git a/README.md b/README.md index c9839acf..ca6e17e2 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, dyff, ai. 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 @@ -211,7 +211,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, dyff, ai. 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 +266,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, dyff, ai. 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 +308,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, dyff, ai. 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 +344,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, dyff, ai. 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..a013fdd6 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, dyff, ai. 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/diff/diff_test.go b/diff/diff_test.go index 56b844f7..d0d68986 100644 --- a/diff/diff_test.go +++ b/diff/diff_test.go @@ -561,6 +561,24 @@ Plan: 0 to add, 1 to change, 0 to destroy, 0 to change ownership. `, buf1.String()) }) + t.Run("OnChangeAI", func(t *testing.T) { + var buf1 bytes.Buffer + diffOptions := Options{"ai", 10, false, true, false, []string{}, 0.0, []string{}} + + if changesSeen := Manifests(specBeta, specRelease, &diffOptions, &buf1); !changesSeen { + t.Error("Unexpected return value from Manifests: Expected the return value to be `true` to indicate that it has seen any change(s), but was `false`") + } + + require.Contains(t, buf1.String(), `"api": "apps"`) + require.Contains(t, buf1.String(), `"kind": "Deployment"`) + require.Contains(t, buf1.String(), `"namespace": "default"`) + require.Contains(t, buf1.String(), `"name": "nginx"`) + require.Contains(t, buf1.String(), `"change": "MODIFY"`) + require.Contains(t, buf1.String(), `"summary"`) + require.Contains(t, buf1.String(), `"metadata"`) + require.Contains(t, buf1.String(), `"content"`) + }) + t.Run("OnNoChangeTemplate", func(t *testing.T) { var buf2 bytes.Buffer diffOptions := Options{"template", 10, false, true, false, []string{}, 0.0, []string{}} diff --git a/diff/report.go b/diff/report.go index ac456365..52a79f33 100644 --- a/diff/report.go +++ b/diff/report.go @@ -1,6 +1,7 @@ package diff import ( + "encoding/json" "errors" "fmt" "io" @@ -65,6 +66,8 @@ func (r *Report) setupReportFormat(format string) { setupJSONReport(r) case "dyff": setupDyffReport(r) + case "ai": + setupAIReport(r) default: setupDiffReport(r) } @@ -74,6 +77,10 @@ func setupDyffReport(r *Report) { r.format.output = printDyffReport } +func setupAIReport(r *Report) { + r.format.output = printAIReport +} + func printDyffReport(r *Report, to io.Writer) { currentFile, _ := os.CreateTemp("", "existing-values") defer func() { @@ -113,6 +120,110 @@ func printDyffReport(r *Report, to io.Writer) { _ = reportWriter.WriteReport(to) } +type aiResourceChange struct { + Metadata aiResourceMetadata `json:"metadata"` + Change string `json:"change"` + Summary string `json:"summary"` + Content aiChangeContent `json:"content,omitempty"` +} + +type aiResourceMetadata struct { + Namespace string `json:"namespace"` + Name string `json:"name"` + Kind string `json:"kind"` + API string `json:"api"` +} + +type aiChangeContent struct { + Added []string `json:"added,omitempty"` + Removed []string `json:"removed,omitempty"` + Modified []string `json:"modified,omitempty"` +} + +func printAIReport(r *Report, to io.Writer) { + encoder := json.NewEncoder(to) + encoder.SetIndent("", " ") + + changes := make([]aiResourceChange, 0, len(r.Entries)) + + for _, entry := range r.Entries { + templateData := ReportTemplateSpec{} + if err := templateData.loadFromKey(entry.Key); err != nil { + log.Println("error processing report entry") + continue + } + + change := aiResourceChange{ + Metadata: aiResourceMetadata{ + Namespace: templateData.Namespace, + Name: templateData.Name, + Kind: templateData.Kind, + API: templateData.API, + }, + Change: entry.ChangeType, + } + + var added, removed, modified []string + + for _, record := range entry.Diffs { + switch record.Delta { + case difflib.LeftOnly: + removed = append(removed, record.Payload) + case difflib.RightOnly: + added = append(added, record.Payload) + case difflib.Common: + modified = append(modified, record.Payload) + } + } + + if len(added) > 0 || len(removed) > 0 { + change.Content = aiChangeContent{ + Added: added, + Removed: removed, + Modified: modified, + } + + change.Summary = generateChangeSummary(added, removed, entry.ChangeType) + } else { + change.Summary = getChangeDescription(entry.ChangeType) + } + + changes = append(changes, change) + } + + _ = encoder.Encode(changes) +} + +func generateChangeSummary(added, removed []string, changeType string) string { + addedCount := len(added) + removedCount := len(removed) + + switch { + case changeType == "ADD" && addedCount > 0: + return fmt.Sprintf("Added %d lines", addedCount) + case changeType == "REMOVE" && removedCount > 0: + return fmt.Sprintf("Removed %d lines", removedCount) + case changeType == "MODIFY": + return fmt.Sprintf("Modified: +%d, -%d", addedCount, removedCount) + default: + return fmt.Sprintf("%d additions, %d deletions", addedCount, removedCount) + } +} + +func getChangeDescription(changeType string) string { + descriptions := map[string]string{ + "ADD": "Resource created", + "REMOVE": "Resource deleted", + "MODIFY": "Resource updated", + "OWNERSHIP": "Ownership changed", + "MODIFY_SUPPRESSED": "Resource modified (diff suppressed)", + } + if desc, ok := descriptions[changeType]; ok { + return desc + } + return changeType +} + // addEntry: stores diff changes. func (r *Report) addEntry(key string, suppressedKinds []string, kind string, context int, diffs []difflib.DiffRecord, changeType string) { entry := ReportEntry{ diff --git a/diff/report_test.go b/diff/report_test.go index 1a2aff60..65781dba 100644 --- a/diff/report_test.go +++ b/diff/report_test.go @@ -1,8 +1,12 @@ package diff import ( + "bytes" + "encoding/json" + "strings" "testing" + "github.com/aryann/difflib" "github.com/stretchr/testify/require" ) @@ -34,3 +38,154 @@ func TestLoadFromKey(t *testing.T) { require.Equal(t, expectedTemplateSpec, *templateSpec) } } + +func TestPrintAIReport(t *testing.T) { + tests := []struct { + name string + report *Report + expected string + }{ + { + name: "single entry with modify diff", + report: &Report{ + Entries: []ReportEntry{ + { + Key: "default, nginx, Deployment (apps)", + ChangeType: "MODIFY", + Diffs: []difflib.DiffRecord{ + {Delta: difflib.Common, Payload: "spec:"}, + {Delta: difflib.RightOnly, Payload: " replicas: 3"}, + {Delta: difflib.LeftOnly, Payload: " replicas: 2"}, + }, + }, + }, + }, + expected: `[ + { + "change": "MODIFY", + "summary": "Modified: +1, -1", + "metadata": { + "namespace": "default", + "name": "nginx", + "kind": "Deployment", + "api": "apps" + }, + "content": { + "added": [ + " replicas: 3" + ], + "removed": [ + " replicas: 2" + ], + "modified": [ + "spec:" + ] + } + } +]`, + }, + { + name: "multiple entries", + report: &Report{ + Entries: []ReportEntry{ + { + Key: "default, nginx, Deployment (apps)", + ChangeType: "ADD", + Diffs: []difflib.DiffRecord{{Delta: difflib.RightOnly, Payload: "spec:"}}, + }, + { + Key: "default, redis, Service (v1)", + ChangeType: "REMOVE", + Diffs: []difflib.DiffRecord{{Delta: difflib.LeftOnly, Payload: "spec:"}}, + }, + }, + }, + expected: `[ + { + "change": "ADD", + "summary": "Added 1 lines", + "metadata": { + "namespace": "default", + "name": "nginx", + "kind": "Deployment", + "api": "apps" + }, + "content": { + "added": [ + "spec:" + ] + } + }, + { + "change": "REMOVE", + "summary": "Removed 1 lines", + "metadata": { + "namespace": "default", + "name": "redis", + "kind": "Service", + "api": "v1" + }, + "content": { + "removed": [ + "spec:" + ] + } + } +]`, + }, + { + name: "empty report", + report: &Report{Entries: []ReportEntry{}}, + expected: "[]\n", + }, + { + name: "entry with special characters in content", + report: &Report{ + Entries: []ReportEntry{ + { + Key: "default, test-config, ConfigMap (v1)", + ChangeType: "MODIFY", + Diffs: []difflib.DiffRecord{ + {Delta: difflib.RightOnly, Payload: `key: "value with \"quotes\""`}, + {Delta: difflib.RightOnly, Payload: "another: new\nline"}, + }, + }, + }, + }, + expected: `[ + { + "change": "MODIFY", + "summary": "Modified: +2, -0", + "metadata": { + "namespace": "default", + "name": "test-config", + "kind": "ConfigMap", + "api": "v1" + }, + "content": { + "added": [ + "key: \"value with \\\"quotes\\\"\"", + "another: new\nline" + ] + } + } +]`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var buf bytes.Buffer + printAIReport(tt.report, &buf) + + actual := strings.TrimSpace(buf.String()) + expected := strings.TrimSpace(tt.expected) + + var actualJSON, expectedJSON interface{} + require.NoError(t, json.Unmarshal([]byte(actual), &actualJSON)) + require.NoError(t, json.Unmarshal([]byte(expected), &expectedJSON)) + + require.Equal(t, expectedJSON, actualJSON) + }) + } +}