diff --git a/pkg/kubernetes/diff.go b/pkg/kubernetes/diff.go index 84401e0db..7eac9b902 100644 --- a/pkg/kubernetes/diff.go +++ b/pkg/kubernetes/diff.go @@ -90,7 +90,8 @@ Please upgrade kubectl to at least version 1.18.1`) } if opts.Summarize { - return util.Diffstat(*d) + result, err := util.DiffStat(*d) + return &result, err } return d, nil diff --git a/pkg/kubernetes/kubernetes.go b/pkg/kubernetes/kubernetes.go index 0eb3c9409..8bbbb9bc5 100644 --- a/pkg/kubernetes/kubernetes.go +++ b/pkg/kubernetes/kubernetes.go @@ -71,7 +71,7 @@ func (k *Kubernetes) Close() error { // DiffOpts allow to specify additional parameters for diff operations type DiffOpts struct { - // Use `diffstat(1)` to create a histogram of the changes instead + // Create a histogram of the changes instead Summarize bool // Find orphaned resources and include them in the diff WithPrune bool diff --git a/pkg/kubernetes/util/diff.go b/pkg/kubernetes/util/diff.go index 1ffd5fc16..a979ec36f 100644 --- a/pkg/kubernetes/util/diff.go +++ b/pkg/kubernetes/util/diff.go @@ -3,12 +3,15 @@ package util import ( "bytes" "fmt" + "math" "os" "os/exec" "path/filepath" "regexp" + "sort" "strings" + "github.com/fatih/color" "github.com/grafana/tanka/pkg/kubernetes/manifest" ) @@ -60,20 +63,58 @@ func DiffStr(name, is, should string) (string, error) { return out, nil } -// Diffstat uses `diffstat(1)` utility to summarize a `diff(1)` output -func Diffstat(d string) (*string, error) { - cmd := exec.Command("diffstat", "-C") - buf := bytes.Buffer{} - cmd.Stdout = &buf - cmd.Stderr = os.Stderr - cmd.Stdin = strings.NewReader(d) +// Diffstat creates a histogram of a diff +func DiffStat(d string) (string, error) { + lines := strings.Split(d, "\n") + type diff struct { + added, removed int + } + + maxFilenameLength := 0 + maxChanges := 0 + var fileNames []string + diffMap := map[string]diff{} + + currentFileName := "" + totalAdded, added, totalRemoved, removed := 0, 0, 0, 0 + for i, line := range lines { + if strings.HasPrefix(line, "diff ") { + splitLine := strings.Split(line, " ") + currentFileName = findStringsCommonSuffix(splitLine[len(splitLine)-2], splitLine[len(splitLine)-1]) + added, removed = 0, 0 + continue + } + + if strings.HasPrefix(line, "+ ") { + added++ + } else if strings.HasPrefix(line, "- ") { + removed++ + } - if err := cmd.Run(); err != nil { - return nil, fmt.Errorf("invoking diffstat(1): %s", err.Error()) + if currentFileName != "" && (i == len(lines)-1 || strings.HasPrefix(lines[i+1], "diff ")) { + totalAdded += added + totalRemoved += removed + if added+removed > maxChanges { + maxChanges = added + removed + } + + fileNames = append(fileNames, currentFileName) + diffMap[currentFileName] = diff{added, removed} + if len(currentFileName) > maxFilenameLength { + maxFilenameLength = len(currentFileName) + } + } } + sort.Strings(fileNames) - out := buf.String() - return &out, nil + builder := strings.Builder{} + for _, fileName := range fileNames { + f := diffMap[fileName] + builder.WriteString(fmt.Sprintf("%-*s | %4d %s\n", maxFilenameLength, fileName, f.added+f.removed, printPlusAndMinuses(f.added, f.removed, maxChanges))) + } + builder.WriteString(fmt.Sprintf("%d files changed, %d insertions(+), %d deletions(-)", len(fileNames), totalAdded, totalRemoved)) + + return builder.String(), nil } // FilteredErr is a filtered Stderr. If one of the regular expressions match, the current input is discarded. @@ -88,3 +129,44 @@ func (r FilteredErr) Write(p []byte) (n int, err error) { } return os.Stderr.Write(p) } + +// printPlusAndMinuses prints colored plus and minus signs for the given number of added and removed lines. +// The number of characters is calculated based on the maximum number of changes in all files (maxChanges). +// The number of characters is capped at 40. +func printPlusAndMinuses(added, removed int, maxChanges int) string { + addedAndRemoved := float64(added + removed) + chars := math.Ceil(addedAndRemoved / float64(maxChanges) * 40) + + added = min(added, int(float64(added)/addedAndRemoved*chars)) + removed = min(removed, int(chars)-added) + + return color.New(color.FgGreen).Sprint(strings.Repeat("+", added)) + + color.New(color.FgRed).Sprint(strings.Repeat("-", removed)) +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} + +// findStringsCommonSuffix returns the common suffix of the two strings (removing leading `/` or `-`) +// e.g. findStringsCommonSuffix("foo/bar/baz", "other/bar/baz") -> "bar/baz" +func findStringsCommonSuffix(a, b string) string { + if a == b { + return a + } + + if len(a) > len(b) { + a, b = b, a + } + + for i := 0; i < len(a); i++ { + if a[len(a)-i-1] != b[len(b)-i-1] { + return strings.TrimLeft(a[len(a)-i:], "/-") + } + } + + return "" +} diff --git a/pkg/kubernetes/util/diff_test.go b/pkg/kubernetes/util/diff_test.go new file mode 100644 index 000000000..cb390b555 --- /dev/null +++ b/pkg/kubernetes/util/diff_test.go @@ -0,0 +1,31 @@ +package util + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDiffStat(t *testing.T) { + cases := []string{ + "empty", + "added-and-removed", + "changed-attributes", + "changed-lots-of-attributes", + } + for _, c := range cases { + t.Run(c, func(t *testing.T) { + content, err := os.ReadFile("testdata/" + c + ".diff") + require.NoError(t, err) + expected, err := os.ReadFile("testdata/" + c + ".stat") + require.NoError(t, err) + + got, err := DiffStat(string(content)) + require.NoError(t, err) + + assert.Equal(t, string(expected), got) + }) + } +} diff --git a/pkg/kubernetes/util/testdata/added-and-removed.diff b/pkg/kubernetes/util/testdata/added-and-removed.diff new file mode 100644 index 000000000..52e7722fe --- /dev/null +++ b/pkg/kubernetes/util/testdata/added-and-removed.diff @@ -0,0 +1,109 @@ +diff -u -N /var/folders/ps/4g9m79b14j90ljcs37lpbw500000gn/T/LIVE-4199451777/apps.v1.Deployment.bors-ng.bors-ng /var/folders/ps/4g9m79b14j90ljcs37lpbw500000gn/T/MERGED-1314023776/apps.v1.Deployment.bors-ng.bors-ng +--- /var/folders/ps/4g9m79b14j90ljcs37lpbw500000gn/T/LIVE-4199451777/apps.v1.Deployment.bors-ng.bors-ng 2022-09-29 11:48:13.345562149 -0400 ++++ /var/folders/ps/4g9m79b14j90ljcs37lpbw500000gn/T/MERGED-1314023776/apps.v1.Deployment.bors-ng.bors-ng 2022-09-29 11:48:13.347038829 -0400 +@@ -216,7 +229,7 @@ + - name: DATABASE_USE_SSL + value: "false" + - name: DATABASE_AUTO_MIGRATE +- value: "true" ++ value: tru + - name: COMMAND_TRIGGER + value: bors + - name: DASHBOARD_HEADER_HTML +diff -u -N /var/folders/ps/4g9m79b14j90ljcs37lpbw500000gn/T/LIVE-4199451777/apps.v1.Deployment.bors-ng.bors-ngtest /var/folders/ps/4g9m79b14j90ljcs37lpbw500000gn/T/MERGED-1314023776/apps.v1.Deployment.bors-ng.bors-ngtest +--- /var/folders/ps/4g9m79b14j90ljcs37lpbw500000gn/T/LIVE-4199451777/apps.v1.Deployment.bors-ng.bors-ngtest 2022-09-29 11:48:13.519024640 -0400 ++++ /var/folders/ps/4g9m79b14j90ljcs37lpbw500000gn/T/MERGED-1314023776/apps.v1.Deployment.bors-ng.bors-ngtest 2022-09-29 11:48:13.520387827 -0400 +@@ -0,0 +1,270 @@ ++apiVersion: apps/v1 ++kind: Deployment ++metadata: ++ labels: ++ tanka.dev/environment: 108b68a3c424de9dd80f2cf125b9c7a498d65dd4c8cf55b7 ++spec: ++ minReadySeconds: 10 ++ progressDeadlineSeconds: 600 ++ replicas: 1 ++ revisionHistoryLimit: 5 ++ selector: ++ matchLabels: ++ app: bors-ng ++ strategy: ++ rollingUpdate: ++ maxSurge: 25% ++ maxUnavailable: 25% ++ type: RollingUpdate ++ template: ++ metadata: ++ creationTimestamp: null ++ labels: ++ app: bors-ng ++ name: bors-ng ++ spec: ++ containers: ++ - env: ++ - name: PUBLIC_HOST ++ value: bors-ng.test.net ++ image: bors-image ++ imagePullPolicy: IfNotPresent ++ livenessProbe: ++ failureThreshold: 3 ++ httpGet: ++ path: /health ++ port: http ++ scheme: HTTP ++ periodSeconds: 10 ++ successThreshold: 1 ++ timeoutSeconds: 3 ++ name: bors-ng ++ ports: ++ - containerPort: 80 ++ name: http ++ protocol: TCP ++ readinessProbe: ++ failureThreshold: 3 ++ httpGet: ++ path: /health ++ port: http ++ scheme: HTTP ++ periodSeconds: 10 ++ successThreshold: 1 ++ timeoutSeconds: 3 ++ dnsPolicy: ClusterFirst ++ restartPolicy: Always +diff -u -N /var/folders/ps/4g9m79b14j90ljcs37lpbw500000gn/T/LIVE-4199451777/networking.k8s.io.v1.Ingress.bors-ng.bors-ng /var/folders/ps/4g9m79b14j90ljcs37lpbw500000gn/T/MERGED-1314023776/networking.k8s.io.v1.Ingress.bors-ng.bors-ng +--- /var/folders/ps/4g9m79b14j90ljcs37lpbw500000gn/T/LIVE-4199451777/networking.k8s.io.v1.Ingress.bors-ng.bors-ng 2022-09-29 11:48:13.787324397 -0400 ++++ /var/folders/ps/4g9m79b14j90ljcs37lpbw500000gn/T/MERGED-1314023776/networking.k8s.io.v1.Ingress.bors-ng.bors-ng 2022-09-29 11:48:13.787954356 -0400 +@@ -58,7 +65,7 @@ + port: + number: 80 + path: / +- pathType: Prefix ++ pathType: Exact + tls: + - hosts: + - bors-ng.test.net +diff -u -N /var/folders/ps/4g9m79b14j90ljcs37lpbw500000gn/T/diff3561117184/LIVE-v1.Service.bors-ng.bors-ng /var/folders/ps/4g9m79b14j90ljcs37lpbw500000gn/T/diff3561117184/MERGED-v1.Service.bors-ng.bors-ng +--- /var/folders/ps/4g9m79b14j90ljcs37lpbw500000gn/T/diff3561117184/LIVE-v1.Service.bors-ng.bors-ng 2022-09-29 11:54:18.319484889 -0400 ++++ /var/folders/ps/4g9m79b14j90ljcs37lpbw500000gn/T/diff3561117184/MERGED-v1.Service.bors-ng.bors-ng 2022-09-29 11:54:18.319567564 -0400 +@@ -1,78 +0,0 @@ +-apiVersion: v1 +-kind: Service +-metadata: +- creationTimestamp: "2021-08-17T16:51:55Z" +- labels: +- name: bors-ng +- tanka.dev/environment: 108b68a3c424de9dd80f2cf125b9c7a498d65dd4c8cf55b7 +- name: bors-ng +- namespace: bors-ng +-spec: +- ports: +- - name: bors-ng-http +- port: 80 +- protocol: TCP +- targetPort: 80 +- selector: +- app: bors-ng +- name: bors-ng +- sessionAffinity: None +- type: ClusterIP + diff --git a/pkg/kubernetes/util/testdata/added-and-removed.stat b/pkg/kubernetes/util/testdata/added-and-removed.stat new file mode 100644 index 000000000..5c6453743 --- /dev/null +++ b/pkg/kubernetes/util/testdata/added-and-removed.stat @@ -0,0 +1,5 @@ +apps.v1.Deployment.bors-ng.bors-ng | 2 +- +apps.v1.Deployment.bors-ng.bors-ngtest | 52 ++++++++++++++++++++++++++++++++++++++++ +networking.k8s.io.v1.Ingress.bors-ng.bors-ng | 2 +- +v1.Service.bors-ng.bors-ng | 16 ------------- +4 files changed, 54 insertions(+), 18 deletions(-) \ No newline at end of file diff --git a/pkg/kubernetes/util/testdata/changed-attributes.diff b/pkg/kubernetes/util/testdata/changed-attributes.diff new file mode 100644 index 000000000..2e994ccf0 --- /dev/null +++ b/pkg/kubernetes/util/testdata/changed-attributes.diff @@ -0,0 +1,24 @@ +diff -u -N /var/folders/ps/4g9m79b14j90ljcs37lpbw500000gn/T/LIVE-4258784861/apps.v1.Deployment.bors-ng.bors-ng /var/folders/ps/4g9m79b14j90ljcs37lpbw500000gn/T/MERGED-2011481016/apps.v1.Deployment.bors-ng.bors-ng +--- /var/folders/ps/4g9m79b14j90ljcs37lpbw500000gn/T/LIVE-4258784861/apps.v1.Deployment.bors-ng.bors-ng 2022-09-29 11:30:26.173560807 -0400 ++++ /var/folders/ps/4g9m79b14j90ljcs37lpbw500000gn/T/MERGED-2011481016/apps.v1.Deployment.bors-ng.bors-ng 2022-09-29 11:30:26.177099345 -0400 +@@ -216,7 +229,7 @@ + - name: DATABASE_USE_SSL + value: "false" + - name: DATABASE_AUTO_MIGRATE +- value: "true" ++ value: tru + - name: COMMAND_TRIGGER + value: bors + - name: DASHBOARD_HEADER_HTML +diff -u -N /var/folders/ps/4g9m79b14j90ljcs37lpbw500000gn/T/LIVE-4258784861/networking.k8s.io.v1.Ingress.bors-ng.bors-ng /var/folders/ps/4g9m79b14j90ljcs37lpbw500000gn/T/MERGED-2011481016/networking.k8s.io.v1.Ingress.bors-ng.bors-ng +--- /var/folders/ps/4g9m79b14j90ljcs37lpbw500000gn/T/LIVE-4258784861/networking.k8s.io.v1.Ingress.bors-ng.bors-ng 2022-09-29 11:30:26.451654927 -0400 ++++ /var/folders/ps/4g9m79b14j90ljcs37lpbw500000gn/T/MERGED-2011481016/networking.k8s.io.v1.Ingress.bors-ng.bors-ng 2022-09-29 11:30:26.452224214 -0400 +@@ -58,7 +65,7 @@ + port: + number: 80 + path: / +- pathType: Prefix ++ pathType: Exact + tls: + - hosts: + - bors-ng.test.net diff --git a/pkg/kubernetes/util/testdata/changed-attributes.stat b/pkg/kubernetes/util/testdata/changed-attributes.stat new file mode 100644 index 000000000..85454569c --- /dev/null +++ b/pkg/kubernetes/util/testdata/changed-attributes.stat @@ -0,0 +1,3 @@ +apps.v1.Deployment.bors-ng.bors-ng | 2 +- +networking.k8s.io.v1.Ingress.bors-ng.bors-ng | 2 +- +2 files changed, 2 insertions(+), 2 deletions(-) \ No newline at end of file diff --git a/pkg/kubernetes/util/testdata/changed-lots-of-attributes.diff b/pkg/kubernetes/util/testdata/changed-lots-of-attributes.diff new file mode 100644 index 000000000..97ea43fbf --- /dev/null +++ b/pkg/kubernetes/util/testdata/changed-lots-of-attributes.diff @@ -0,0 +1,88 @@ +diff -u -N /var/folders/ps/4g9m79b14j90ljcs37lpbw500000gn/T/LIVE-4258784861/apps.v1.Deployment.bors-ng.bors-ng /var/folders/ps/4g9m79b14j90ljcs37lpbw500000gn/T/MERGED-2011481016/apps.v1.Deployment.bors-ng.bors-ng +--- /var/folders/ps/4g9m79b14j90ljcs37lpbw500000gn/T/LIVE-4258784861/apps.v1.Deployment.bors-ng.bors-ng 2022-09-29 11:30:26.173560807 -0400 ++++ /var/folders/ps/4g9m79b14j90ljcs37lpbw500000gn/T/MERGED-2011481016/apps.v1.Deployment.bors-ng.bors-ng 2022-09-29 11:30:26.177099345 -0400 +@@ -216,7 +229,7 @@ + - name: DATABASE_USE_SSL + value: "false" + - name: DATABASE_AUTO_MIGRATE +- value: "true" ++ value: tru + - name: COMMAND_TRIGGER + value: bors + - name: DASHBOARD_HEADER_HTML +diff -u -N /var/folders/ps/4g9m79b14j90ljcs37lpbw500000gn/T/LIVE-4258784861/networking.k8s.io.v1.Ingress.bors-ng.bors-ng /var/folders/ps/4g9m79b14j90ljcs37lpbw500000gn/T/MERGED-2011481016/networking.k8s.io.v1.Ingress.bors-ng.bors-ng +--- /var/folders/ps/4g9m79b14j90ljcs37lpbw500000gn/T/LIVE-4258784861/networking.k8s.io.v1.Ingress.bors-ng.bors-ng 2022-09-29 11:30:26.451654927 -0400 ++++ /var/folders/ps/4g9m79b14j90ljcs37lpbw500000gn/T/MERGED-2011481016/networking.k8s.io.v1.Ingress.bors-ng.bors-ng 2022-09-29 11:30:26.452224214 -0400 +@@ -58,7 +65,7 @@ + port: + number: 80 + path: / +- pathType1: Prefix ++ pathType1: Exact +- pathType2: Prefix ++ pathType2: Exact +- pathType3: Prefix ++ pathType3: Exact +- pathType4: Prefix ++ pathType4: Exact +- pathType5: Prefix ++ pathType5: Exact +- pathType6: Prefix ++ pathType6: Exact +- pathType7: Prefix ++ pathType7: Exact +- pathType8: Prefix ++ pathType8: Exact +- pathType9: Prefix ++ pathType9: Exact +- pathType10: Prefix ++ pathType10: Exact +- pathType11: Prefix ++ pathType11: Exact +- pathType12: Prefix ++ pathType12: Exact +- pathType13: Prefix ++ pathType13: Exact +- pathType14: Prefix ++ pathType14: Exact +- pathType15: Prefix ++ pathType15: Exact +- pathType16: Prefix ++ pathType16: Exact +- pathType17: Prefix ++ pathType17: Exact +- pathType18: Prefix ++ pathType18: Exact +- pathType19: Prefix ++ pathType19: Exact +- pathType20: Prefix ++ pathType20: Exact +- pathType21: Prefix ++ pathType21: Exact +- pathType22: Prefix ++ pathType22: Exact +- pathType23: Prefix ++ pathType23: Exact +- pathType24: Prefix ++ pathType24: Exact +- pathType25: Prefix ++ pathType25: Exact +- pathType26: Prefix ++ pathType26: Exact +- pathType27: Prefix ++ pathType27: Exact +- pathType28: Prefix ++ pathType28: Exact +- pathType29: Prefix ++ pathType29: Exact +- pathType30: Prefix ++ pathType30: Exact +- pathType31: Prefix ++ pathType31: Exact ++ pathType32: Exact ++ pathType33: Exact ++ pathType34: Exact ++ pathType35: Exact + tls: + - hosts: + - bors-ng.test.net diff --git a/pkg/kubernetes/util/testdata/changed-lots-of-attributes.stat b/pkg/kubernetes/util/testdata/changed-lots-of-attributes.stat new file mode 100644 index 000000000..a3c31d5d4 --- /dev/null +++ b/pkg/kubernetes/util/testdata/changed-lots-of-attributes.stat @@ -0,0 +1,3 @@ +apps.v1.Deployment.bors-ng.bors-ng | 2 +- +networking.k8s.io.v1.Ingress.bors-ng.bors-ng | 66 +++++++++++++++++++++------------------- +2 files changed, 36 insertions(+), 32 deletions(-) \ No newline at end of file diff --git a/pkg/kubernetes/util/testdata/empty.diff b/pkg/kubernetes/util/testdata/empty.diff new file mode 100644 index 000000000..e69de29bb diff --git a/pkg/kubernetes/util/testdata/empty.stat b/pkg/kubernetes/util/testdata/empty.stat new file mode 100644 index 000000000..33b543a57 --- /dev/null +++ b/pkg/kubernetes/util/testdata/empty.stat @@ -0,0 +1 @@ +0 files changed, 0 insertions(+), 0 deletions(-) \ No newline at end of file diff --git a/pkg/tanka/workflow.go b/pkg/tanka/workflow.go index 145448eee..953d1678b 100644 --- a/pkg/tanka/workflow.go +++ b/pkg/tanka/workflow.go @@ -166,10 +166,9 @@ type DiffOpts struct { // Diff parses the environment at the given directory (a `baseDir`) and returns // the differences from the live cluster state in `diff(1)` format. If the -// `WithDiffSummarize` modifier is used, a histogram created using `diffstat(1)` -// is returned instead. +// `WithDiffSummarize` modifier is used, a histogram is returned instead. // The cluster information is retrieved from the environments `spec.json`. -// NOTE: This function requires on `diff(1)`, `kubectl(1)` and perhaps `diffstat(1)` +// NOTE: This function requires on `diff(1)` and `kubectl(1)` func Diff(baseDir string, opts DiffOpts) (*string, error) { l, err := Load(baseDir, opts.Opts) if err != nil {