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  }