github.com/grafana/tanka@v0.26.1-0.20240506093700-c22cfc35c21a/pkg/kubernetes/util/diff.go (about) 1 package util 2 3 import ( 4 "bytes" 5 "fmt" 6 "math" 7 "os" 8 "os/exec" 9 "path/filepath" 10 "regexp" 11 "sort" 12 "strings" 13 14 "github.com/fatih/color" 15 "github.com/grafana/tanka/pkg/kubernetes/manifest" 16 ) 17 18 // DiffName computes the filename for use with `DiffStr` 19 func DiffName(m manifest.Manifest) string { 20 return strings.ReplaceAll(fmt.Sprintf("%s.%s.%s.%s", 21 m.APIVersion(), 22 m.Kind(), 23 m.Metadata().Namespace(), 24 m.Metadata().Name(), 25 ), "/", "-") 26 } 27 28 // DiffStr computes the differences between the strings `is` and `should` using the 29 // UNIX `diff(1)` utility. 30 func DiffStr(name, is, should string) (string, error) { 31 dir, err := os.MkdirTemp("", "diff") 32 if err != nil { 33 return "", err 34 } 35 defer os.RemoveAll(dir) 36 37 if err := os.WriteFile(filepath.Join(dir, "LIVE-"+name), []byte(is), os.ModePerm); err != nil { 38 return "", err 39 } 40 if err := os.WriteFile(filepath.Join(dir, "MERGED-"+name), []byte(should), os.ModePerm); err != nil { 41 return "", err 42 } 43 44 buf := bytes.Buffer{} 45 merged := filepath.Join(dir, "MERGED-"+name) 46 live := filepath.Join(dir, "LIVE-"+name) 47 cmd := exec.Command("diff", "-u", "-N", live, merged) 48 cmd.Stdout = &buf 49 err = cmd.Run() 50 51 // the diff utility exits with `1` if there are differences. We need to not fail there. 52 if exitError, ok := err.(*exec.ExitError); ok && err != nil { 53 if exitError.ExitCode() != 1 { 54 return "", err 55 } 56 } 57 58 out := buf.String() 59 if out != "" { 60 out = fmt.Sprintf("diff -u -N %s %s\n%s", live, merged, out) 61 } 62 63 return out, nil 64 } 65 66 // Diffstat creates a histogram of a diff 67 func DiffStat(d string) (string, error) { 68 lines := strings.Split(d, "\n") 69 type diff struct { 70 added, removed int 71 } 72 73 maxFilenameLength := 0 74 maxChanges := 0 75 var fileNames []string 76 diffMap := map[string]diff{} 77 78 currentFileName := "" 79 totalAdded, added, totalRemoved, removed := 0, 0, 0, 0 80 for i, line := range lines { 81 if strings.HasPrefix(line, "diff ") { 82 splitLine := strings.Split(line, " ") 83 currentFileName = findStringsCommonSuffix(splitLine[len(splitLine)-2], splitLine[len(splitLine)-1]) 84 added, removed = 0, 0 85 continue 86 } 87 88 if strings.HasPrefix(line, "+ ") { 89 added++ 90 } else if strings.HasPrefix(line, "- ") { 91 removed++ 92 } 93 94 if currentFileName != "" && (i == len(lines)-1 || strings.HasPrefix(lines[i+1], "diff ")) { 95 totalAdded += added 96 totalRemoved += removed 97 if added+removed > maxChanges { 98 maxChanges = added + removed 99 } 100 101 fileNames = append(fileNames, currentFileName) 102 diffMap[currentFileName] = diff{added, removed} 103 if len(currentFileName) > maxFilenameLength { 104 maxFilenameLength = len(currentFileName) 105 } 106 } 107 } 108 sort.Strings(fileNames) 109 110 builder := strings.Builder{} 111 for _, fileName := range fileNames { 112 f := diffMap[fileName] 113 builder.WriteString(fmt.Sprintf("%-*s | %4d %s\n", maxFilenameLength, fileName, f.added+f.removed, printPlusAndMinuses(f.added, f.removed, maxChanges))) 114 } 115 builder.WriteString(fmt.Sprintf("%d files changed, %d insertions(+), %d deletions(-)", len(fileNames), totalAdded, totalRemoved)) 116 117 return builder.String(), nil 118 } 119 120 // FilteredErr is a filtered Stderr. If one of the regular expressions match, the current input is discarded. 121 type FilteredErr []*regexp.Regexp 122 123 func (r FilteredErr) Write(p []byte) (n int, err error) { 124 for _, re := range r { 125 if re.Match(p) { 126 // silently discard 127 return len(p), nil 128 } 129 } 130 return os.Stderr.Write(p) 131 } 132 133 // printPlusAndMinuses prints colored plus and minus signs for the given number of added and removed lines. 134 // The number of characters is calculated based on the maximum number of changes in all files (maxChanges). 135 // The number of characters is capped at 40. 136 func printPlusAndMinuses(added, removed int, maxChanges int) string { 137 addedAndRemoved := float64(added + removed) 138 chars := math.Ceil(addedAndRemoved / float64(maxChanges) * 40) 139 140 added = min(added, int(float64(added)/addedAndRemoved*chars)) 141 removed = min(removed, int(chars)-added) 142 143 return color.New(color.FgGreen).Sprint(strings.Repeat("+", added)) + 144 color.New(color.FgRed).Sprint(strings.Repeat("-", removed)) 145 } 146 147 func min(a, b int) int { 148 if a < b { 149 return a 150 } 151 return b 152 } 153 154 // findStringsCommonSuffix returns the common suffix of the two strings (removing leading `/` or `-`) 155 // e.g. findStringsCommonSuffix("foo/bar/baz", "other/bar/baz") -> "bar/baz" 156 func findStringsCommonSuffix(a, b string) string { 157 if a == b { 158 return a 159 } 160 161 if len(a) > len(b) { 162 a, b = b, a 163 } 164 165 for i := 0; i < len(a); i++ { 166 if a[len(a)-i-1] != b[len(b)-i-1] { 167 return strings.TrimLeft(a[len(a)-i:], "/-") 168 } 169 } 170 171 return "" 172 }