From e7e94d13b7ecb4ca0eb7628d2196426b1b11cfe3 Mon Sep 17 00:00:00 2001 From: Sergio Morales Date: Sun, 28 Jun 2020 21:25:53 -0400 Subject: [PATCH] feat: Add support for custom output formats Signed-off-by: Sergio Morales --- cmd/release.go | 5 + cmd/revision.go | 7 ++ cmd/rollback.go | 5 + cmd/upgrade.go | 7 +- diff/constant.go | 13 ++ diff/diff.go | 18 +-- diff/diff_test.go | 65 +++++++++- diff/report.go | 206 +++++++++++++++++++++++++++++++ diff/testdata/customTemplate.tpl | 3 + 9 files changed, 315 insertions(+), 14 deletions(-) create mode 100644 diff/constant.go create mode 100644 diff/report.go create mode 100644 diff/testdata/customTemplate.tpl diff --git a/cmd/release.go b/cmd/release.go index e6e431f8..7d3104a0 100644 --- a/cmd/release.go +++ b/cmd/release.go @@ -20,6 +20,7 @@ type release struct { outputContext int includeTests bool showSecrets bool + output string } const releaseCmdLongUsage = ` @@ -77,6 +78,8 @@ func releaseCmd() *cobra.Command { releaseCmd.Flags().StringArrayVar(&diff.suppressedKinds, "suppress", []string{}, "allows suppression of the values listed in the diff output") releaseCmd.Flags().IntVarP(&diff.outputContext, "context", "C", -1, "output NUM lines of context around changes") releaseCmd.Flags().BoolVar(&diff.includeTests, "include-tests", false, "enable the diffing of the helm test hooks") + releaseCmd.Flags().StringVar(&diff.output, "output", "diff", "Possible values: diff, simple, template. When set to \"template\", use the env var HELM_DIFF_TPL to specify the template.") + releaseCmd.SuggestionsMinimumDistance = 1 if !isHelm3() { @@ -117,6 +120,7 @@ func (d *release) differentiateHelm3() error { d.suppressedKinds, d.showSecrets, d.outputContext, + d.output, os.Stdout) if d.detailedExitCode && seenAnyChanges { @@ -150,6 +154,7 @@ func (d *release) differentiate() error { d.suppressedKinds, d.showSecrets, d.outputContext, + d.output, os.Stdout) if d.detailedExitCode && seenAnyChanges { diff --git a/cmd/revision.go b/cmd/revision.go index c70b7381..e9b4903f 100644 --- a/cmd/revision.go +++ b/cmd/revision.go @@ -22,6 +22,7 @@ type revision struct { outputContext int includeTests bool showSecrets bool + output string } const revisionCmdLongUsage = ` @@ -87,6 +88,8 @@ func revisionCmd() *cobra.Command { revisionCmd.Flags().StringArrayVar(&diff.suppressedKinds, "suppress", []string{}, "allows suppression of the values listed in the diff output") revisionCmd.Flags().IntVarP(&diff.outputContext, "context", "C", -1, "output NUM lines of context around changes") revisionCmd.Flags().BoolVar(&diff.includeTests, "include-tests", false, "enable the diffing of the helm test hooks") + revisionCmd.Flags().StringVar(&diff.output, "output", "diff", "Possible values: diff, simple, template. When set to \"template\", use the env var HELM_DIFF_TPL to specify the template.") + revisionCmd.SuggestionsMinimumDistance = 1 if !isHelm3() { @@ -122,6 +125,7 @@ func (d *revision) differentiateHelm3() error { d.suppressedKinds, d.showSecrets, d.outputContext, + d.output, os.Stdout) case 2: @@ -147,6 +151,7 @@ func (d *revision) differentiateHelm3() error { d.suppressedKinds, d.showSecrets, d.outputContext, + d.output, os.Stdout) if d.detailedExitCode && seenAnyChanges { @@ -185,6 +190,7 @@ func (d *revision) differentiate() error { d.suppressedKinds, d.showSecrets, d.outputContext, + d.output, os.Stdout) case 2: @@ -210,6 +216,7 @@ func (d *revision) differentiate() error { d.suppressedKinds, d.showSecrets, d.outputContext, + d.output, os.Stdout) if d.detailedExitCode && seenAnyChanges { diff --git a/cmd/rollback.go b/cmd/rollback.go index 42133663..7c5a4f07 100644 --- a/cmd/rollback.go +++ b/cmd/rollback.go @@ -22,6 +22,7 @@ type rollback struct { outputContext int includeTests bool showSecrets bool + output string } const rollbackCmdLongUsage = ` @@ -79,6 +80,8 @@ func rollbackCmd() *cobra.Command { rollbackCmd.Flags().StringArrayVar(&diff.suppressedKinds, "suppress", []string{}, "allows suppression of the values listed in the diff output") rollbackCmd.Flags().IntVarP(&diff.outputContext, "context", "C", -1, "output NUM lines of context around changes") rollbackCmd.Flags().BoolVar(&diff.includeTests, "include-tests", false, "enable the diffing of the helm test hooks") + rollbackCmd.Flags().StringVar(&diff.output, "output", "diff", "Possible values: diff, simple, template. When set to \"template\", use the env var HELM_DIFF_TPL to specify the template.") + rollbackCmd.SuggestionsMinimumDistance = 1 if !isHelm3() { @@ -115,6 +118,7 @@ func (d *rollback) backcastHelm3() error { d.suppressedKinds, d.showSecrets, d.outputContext, + d.output, os.Stdout) if d.detailedExitCode && seenAnyChanges { @@ -150,6 +154,7 @@ func (d *rollback) backcast() error { d.suppressedKinds, d.showSecrets, d.outputContext, + d.output, os.Stdout) if d.detailedExitCode && seenAnyChanges { diff --git a/cmd/upgrade.go b/cmd/upgrade.go index c4a6e0ed..c53ec896 100644 --- a/cmd/upgrade.go +++ b/cmd/upgrade.go @@ -35,6 +35,7 @@ type diffCmd struct { outputContext int showSecrets bool postRenderer string + output string } const globalUsage = `Show a diff explaining what a helm upgrade would change. @@ -100,6 +101,7 @@ func newChartCommand() *cobra.Command { f.IntVarP(&diff.outputContext, "context", "C", -1, "output NUM lines of context around changes") f.BoolVar(&diff.disableOpenAPIValidation, "disable-openapi-validation", false, "disables rendered templates validation against the Kubernetes OpenAPI Schema") f.StringVar(&diff.postRenderer, "post-renderer", "", "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") + f.StringVar(&diff.output, "output", "diff", "Possible values: diff, simple, template. When set to \"template\", use the env var HELM_DIFF_TPL to specify the template.") if !isHelm3() { f.StringVar(&diff.namespace, "namespace", "default", "namespace to assume the release to be installed into") } @@ -160,8 +162,7 @@ func (d *diffCmd) runHelm3() error { } else { newSpecs = manifest.Parse(string(installManifest), d.namespace, helm3TestHook, helm2TestSuccessHook) } - - seenAnyChanges := diff.Manifests(currentSpecs, newSpecs, d.suppressedKinds, d.showSecrets, d.outputContext, os.Stdout) + seenAnyChanges := diff.Manifests(currentSpecs, newSpecs, d.suppressedKinds, d.showSecrets, d.outputContext, d.output, os.Stdout) if d.detailedExitCode && seenAnyChanges { return Error{ @@ -247,7 +248,7 @@ func (d *diffCmd) run() error { } } - seenAnyChanges := diff.Manifests(currentSpecs, newSpecs, d.suppressedKinds, d.showSecrets, d.outputContext, os.Stdout) + seenAnyChanges := diff.Manifests(currentSpecs, newSpecs, d.suppressedKinds, d.showSecrets, d.outputContext, d.output, os.Stdout) if d.detailedExitCode && seenAnyChanges { return Error{ diff --git a/diff/constant.go b/diff/constant.go new file mode 100644 index 00000000..c67935ee --- /dev/null +++ b/diff/constant.go @@ -0,0 +1,13 @@ +package diff + +const defaultTemplateReport = `[ +{{- $global := . -}} +{{- range $idx, $entry := . -}} +{ + "Api": "{{ $entry.API }}", + "Kind": "{{ $entry.Kind }}", + "Namespace": "{{ $entry.Namespace }}", + "Name": "{{ $entry.Name }}", + "Change": "{{ $entry.Change }}" +}{{ if not (last $idx $global) }},{{ end }} +{{- end }}]` diff --git a/diff/diff.go b/diff/diff.go index 9a152196..91a06f52 100644 --- a/diff/diff.go +++ b/diff/diff.go @@ -19,14 +19,14 @@ import ( ) // Manifests diff on manifests -func Manifests(oldIndex, newIndex map[string]*manifest.MappingResult, suppressedKinds []string, showSecrets bool, context int, to io.Writer) bool { +func Manifests(oldIndex, newIndex map[string]*manifest.MappingResult, suppressedKinds []string, showSecrets bool, context int, output string, to io.Writer) bool { + report.setupReportFormat(output) seenAnyChanges := false emptyMapping := &manifest.MappingResult{} for key, oldContent := range oldIndex { if newContent, ok := newIndex[key]; ok { if oldContent.Content != newContent.Content { // modified - fmt.Fprintf(to, ansi.Color("%s has changed:", "yellow")+"\n", key) if !showSecrets { redactSecrets(oldContent, newContent) } @@ -35,11 +35,10 @@ func Manifests(oldIndex, newIndex map[string]*manifest.MappingResult, suppressed if len(diffs) > 0 { seenAnyChanges = true } - printDiffRecords(suppressedKinds, oldContent.Kind, context, diffs, to) + report.addEntry(key, suppressedKinds, oldContent.Kind, context, diffs, "MODIFY") } } else { // removed - fmt.Fprintf(to, ansi.Color("%s has been removed:", "yellow")+"\n", key) if !showSecrets { redactSecrets(oldContent, nil) @@ -48,14 +47,13 @@ func Manifests(oldIndex, newIndex map[string]*manifest.MappingResult, suppressed if len(diffs) > 0 { seenAnyChanges = true } - printDiffRecords(suppressedKinds, oldContent.Kind, context, diffs, to) + report.addEntry(key, suppressedKinds, oldContent.Kind, context, diffs, "REMOVE") } } for key, newContent := range newIndex { if _, ok := oldIndex[key]; !ok { // added - fmt.Fprintf(to, ansi.Color("%s has been added:", "yellow")+"\n", key) if !showSecrets { redactSecrets(nil, newContent) } @@ -63,9 +61,11 @@ func Manifests(oldIndex, newIndex map[string]*manifest.MappingResult, suppressed if len(diffs) > 0 { seenAnyChanges = true } - printDiffRecords(suppressedKinds, newContent.Kind, context, diffs, to) + report.addEntry(key, suppressedKinds, newContent.Kind, context, diffs, "ADD") } } + report.print(to) + report.clean() return seenAnyChanges } @@ -138,10 +138,10 @@ func getComment(s string) string { } // Releases reindex the content based on the template names and pass it to Manifests -func Releases(oldIndex, newIndex map[string]*manifest.MappingResult, suppressedKinds []string, showSecrets bool, context int, to io.Writer) bool { +func Releases(oldIndex, newIndex map[string]*manifest.MappingResult, suppressedKinds []string, showSecrets bool, context int, output string, to io.Writer) bool { oldIndex = reIndexForRelease(oldIndex) newIndex = reIndexForRelease(newIndex) - return Manifests(oldIndex, newIndex, suppressedKinds, showSecrets, context, to) + return Manifests(oldIndex, newIndex, suppressedKinds, showSecrets, context, output, to) } func diffMappingResults(oldContent *manifest.MappingResult, newContent *manifest.MappingResult) []difflib.DiffRecord { diff --git a/diff/diff_test.go b/diff/diff_test.go index b80a99f0..ad36b436 100644 --- a/diff/diff_test.go +++ b/diff/diff_test.go @@ -2,6 +2,7 @@ package diff import ( "bytes" + "os" "testing" "github.com/mgutz/ansi" @@ -158,7 +159,7 @@ metadata: var buf1 bytes.Buffer - if changesSeen := Manifests(specBeta, specRelease, []string{}, true, 10, &buf1); !changesSeen { + if changesSeen := Manifests(specBeta, specRelease, []string{}, true, 10, "diff", &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`") } @@ -176,10 +177,70 @@ metadata: t.Run("OnNoChange", func(t *testing.T) { var buf2 bytes.Buffer - if changesSeen := Manifests(specRelease, specRelease, []string{}, true, 10, &buf2); changesSeen { + if changesSeen := Manifests(specRelease, specRelease, []string{}, true, 10, "diff", &buf2); changesSeen { t.Error("Unexpected return value from Manifests: Expected the return value to be `false` to indicate that it has NOT seen any change(s), but was `true`") } require.Equal(t, ``, buf2.String()) }) + + t.Run("OnChangeSimple", func(t *testing.T) { + + var buf1 bytes.Buffer + + if changesSeen := Manifests(specBeta, specRelease, []string{}, true, 10, "simple", &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.Equal(t, `default, nginx, Deployment (apps) to be changed. +Plan: 0 to add, 1 to change, 0 to destroy. +`, buf1.String()) + }) + + t.Run("OnNoChangeSimple", func(t *testing.T) { + var buf2 bytes.Buffer + + if changesSeen := Manifests(specRelease, specRelease, []string{}, true, 10, "simple", &buf2); changesSeen { + t.Error("Unexpected return value from Manifests: Expected the return value to be `false` to indicate that it has NOT seen any change(s), but was `true`") + } + + require.Equal(t, "Plan: 0 to add, 0 to change, 0 to destroy.\n", buf2.String()) + }) + + t.Run("OnChangeTemplate", func(t *testing.T) { + + var buf1 bytes.Buffer + + if changesSeen := Manifests(specBeta, specRelease, []string{}, true, 10, "template", &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.Equal(t, `[{ + "Api": "apps", + "Kind": "Deployment", + "Namespace": "default", + "Name": "nginx", + "Change": "MODIFY" +}]`, buf1.String()) + }) + + t.Run("OnNoChangeTemplate", func(t *testing.T) { + var buf2 bytes.Buffer + + if changesSeen := Manifests(specRelease, specRelease, []string{}, true, 10, "template", &buf2); changesSeen { + t.Error("Unexpected return value from Manifests: Expected the return value to be `false` to indicate that it has NOT seen any change(s), but was `true`") + } + + require.Equal(t, "[]", buf2.String()) + }) + + t.Run("OnChangeCustomTemplate", func(t *testing.T) { + var buf1 bytes.Buffer + os.Setenv("HELM_DIFF_TPL", "testdata/customTemplate.tpl") + if changesSeen := Manifests(specBeta, specRelease, []string{}, true, 10, "template", &buf1); !changesSeen { + t.Error("Unexpected return value from Manifests: Expected the return value to be `false` to indicate that it has NOT seen any change(s), but was `true`") + } + + require.Equal(t, "Resource name: nginx", buf1.String()) + }) } diff --git a/diff/report.go b/diff/report.go new file mode 100644 index 00000000..d805f96d --- /dev/null +++ b/diff/report.go @@ -0,0 +1,206 @@ +package diff + +import ( + "errors" + "fmt" + "io" + "log" + "os" + "path/filepath" + "reflect" + "regexp" + "text/template" + + "github.com/aryann/difflib" + "github.com/mgutz/ansi" +) + +// Report to store report data and format +type Report struct { + format ReportFormat + entries []ReportEntry +} + +// ReportEntry to store changes between releases +type ReportEntry struct { + key string + suppressedKinds []string + kind string + context int + diffs []difflib.DiffRecord + changeType string +} + +// ReportFormat to the context to make a changes report +type ReportFormat struct { + output string + changestyles map[string]ChangeStyle +} + +// ChangeStyle for styling the report +type ChangeStyle struct { + color string + message string +} + +// ReportTemplateSpec for common template spec +type ReportTemplateSpec struct { + Namespace string + Name string + Kind string + API string + Change string +} + +var report Report + +// setupReportFormat: process output argument. +func (r *Report) setupReportFormat(format string) { + switch format { + case "simple": + setupSimpleReport(&report) + case "template": + setupTemplateReport(&report) + default: + setupDiffReport(&report) + } +} + +// addEntry: stores diff changes. +func (r *Report) addEntry(key string, suppressedKinds []string, kind string, context int, diffs []difflib.DiffRecord, changeType string) { + entry := ReportEntry{ + key, + suppressedKinds, + kind, + context, + diffs, + changeType, + } + r.entries = append(r.entries, entry) +} + +// print: prints entries added to the report. +func (r *Report) print(to io.Writer) { + switch r.format.output { + case "simple": + printSimpleReport(r, to) + case "template": + printTemplateReport(r, to) + default: + printDiffReport(r, to) + } +} + +// clean: needed for testing +func (r *Report) clean() { + r.entries = nil +} + +// setup report for default output: diff +func setupDiffReport(r *Report) { + r.format.output = "diff" + r.format.changestyles = make(map[string]ChangeStyle) + r.format.changestyles["ADD"] = ChangeStyle{color: "green", message: "has been added:"} + r.format.changestyles["REMOVE"] = ChangeStyle{color: "red", message: "has been removed:"} + r.format.changestyles["MODIFY"] = ChangeStyle{color: "yellow", message: "has changed:"} +} + +// print report for default output: diff +func printDiffReport(r *Report, to io.Writer) { + for _, entry := range r.entries { + fmt.Fprintf(to, ansi.Color("%s %s", "yellow")+"\n", entry.key, r.format.changestyles[entry.changeType].message) + printDiffRecords(entry.suppressedKinds, entry.kind, entry.context, entry.diffs, to) + } + +} + +// setup report for simple output. +func setupSimpleReport(r *Report) { + r.format.output = "simple" + r.format.changestyles = make(map[string]ChangeStyle) + r.format.changestyles["ADD"] = ChangeStyle{color: "green", message: "to be added."} + r.format.changestyles["REMOVE"] = ChangeStyle{color: "red", message: "to be removed."} + r.format.changestyles["MODIFY"] = ChangeStyle{color: "yellow", message: "to be changed."} +} + +// print report for simple output +func printSimpleReport(r *Report, to io.Writer) { + var summary = map[string]int{ + "ADD": 0, + "REMOVE": 0, + "MODIFY": 0, + } + for _, entry := range r.entries { + fmt.Fprintf(to, ansi.Color("%s %s", report.format.changestyles[entry.changeType].color)+"\n", + entry.key, + r.format.changestyles[entry.changeType].message, + ) + summary[entry.changeType]++ + } + fmt.Fprintf(to, "Plan: %d to add, %d to change, %d to destroy.\n", summary["ADD"], summary["MODIFY"], summary["REMOVE"]) +} + +// setup report for template output +func setupTemplateReport(r *Report) { + r.format.output = "template" + r.format.changestyles = make(map[string]ChangeStyle) + r.format.changestyles["ADD"] = ChangeStyle{color: "green", message: ""} + r.format.changestyles["REMOVE"] = ChangeStyle{color: "red", message: ""} + r.format.changestyles["MODIFY"] = ChangeStyle{color: "yellow", message: ""} +} + +// report with template output will only have access to ReportTemplateSpec. +// This function reverts parsedMetadata.String() +func (t *ReportTemplateSpec) loadFromKey(key string) error { + pattern := regexp.MustCompile(`(?P[a-z0-9-]+), (?P[a-z0-9-]+), (?P\w+) \((?P[a-z0-9.]+)\)`) + matches := pattern.FindStringSubmatch(key) + if len(matches) > 1 { + t.Namespace = matches[1] + t.Name = matches[2] + t.Kind = matches[3] + t.API = matches[4] + return nil + } + return errors.New("key string did't match regexp") +} + +// load and print report for template output +func printTemplateReport(r *Report, to io.Writer) { + var templateDataArray []ReportTemplateSpec + + for _, entry := range r.entries { + templateData := ReportTemplateSpec{} + err := templateData.loadFromKey(entry.key) + if err != nil { + log.Println("error processing report entry") + } else { + templateData.Change = entry.changeType + templateDataArray = append(templateDataArray, templateData) + } + } + + // Prepare template functions + var funcsMap = template.FuncMap{ + "last": func(x int, a interface{}) bool { + return x == reflect.ValueOf(a).Len()-1 + }, + } + + tplFile, present := os.LookupEnv("HELM_DIFF_TPL") + if present { + t, err := template.New(filepath.Base(tplFile)).Funcs(funcsMap).ParseFiles(tplFile) + if err != nil { + fmt.Println(err) + log.Fatalf("Error loadding custom template") + } + t.Execute(to, templateDataArray) + } else { + // Render + t, err := template.New("entries").Funcs(funcsMap).Parse(defaultTemplateReport) + if err != nil { + log.Fatalf("Error loadding default template") + } else { + t.Execute(to, templateDataArray) + } + } +} diff --git a/diff/testdata/customTemplate.tpl b/diff/testdata/customTemplate.tpl new file mode 100644 index 00000000..7c74a379 --- /dev/null +++ b/diff/testdata/customTemplate.tpl @@ -0,0 +1,3 @@ +{{- range $idx, $entry := . -}} +Resource name: {{ $entry.Name }} +{{- end -}}