github.com/wmuizelaar/kpt@v0.0.0-20221018115725-bd564717b2ed/internal/util/diff/diff.go (about)

     1  // Copyright 2019 Google LLC
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //      http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  // Package diff contains libraries for diffing packages.
    16  package diff
    17  
    18  import (
    19  	"context"
    20  	"fmt"
    21  	"io"
    22  	"os"
    23  	"os/exec"
    24  	"path/filepath"
    25  	"strings"
    26  
    27  	"github.com/GoogleContainerTools/kpt/internal/gitutil"
    28  	"github.com/GoogleContainerTools/kpt/internal/pkg"
    29  	"github.com/GoogleContainerTools/kpt/internal/util/addmergecomment"
    30  	"github.com/GoogleContainerTools/kpt/internal/util/fetch"
    31  	"github.com/GoogleContainerTools/kpt/internal/util/pkgutil"
    32  	kptfilev1 "github.com/GoogleContainerTools/kpt/pkg/api/kptfile/v1"
    33  	"github.com/GoogleContainerTools/kpt/pkg/kptfile/kptfileutil"
    34  	"sigs.k8s.io/kustomize/kyaml/errors"
    35  	"sigs.k8s.io/kustomize/kyaml/filesys"
    36  )
    37  
    38  // Type represents type of diff comparison to be performed.
    39  type Type string
    40  
    41  const (
    42  	// TypeLocal shows the changes in local pkg relative to upstream source pkg at original version
    43  	TypeLocal Type = "local"
    44  	// TypeRemote shows changes in the upstream source pkg between original and target version
    45  	TypeRemote Type = "remote"
    46  	// TypeCombined shows changes in local pkg relative to upstream source pkg at target version
    47  	TypeCombined Type = "combined"
    48  	// 3way shows changes in local and remote changes side-by-side
    49  	Type3Way Type = "3way"
    50  )
    51  
    52  // A collection of user-readable "source" definitions for diffed packages.
    53  const (
    54  	// localPackageSource represents the local package
    55  	LocalPackageSource string = "local"
    56  	// remotePackageSource represents the remote version of the package
    57  	RemotePackageSource string = "remote"
    58  	// targetRemotePackageSource represents the targeted remote version of a package
    59  	TargetRemotePackageSource string = "target"
    60  )
    61  
    62  const (
    63  	exitCodeDiffWarning string = "\nThe selected diff tool (%s) exited with an " +
    64  		"error. It may not support the chosen diff type (%s). To use a different " +
    65  		"diff tool please provide the tool using the --diff-tool flag. \n\nFor " +
    66  		"more information about using kpt's diff command please see the commands " +
    67  		"--help.\n"
    68  )
    69  
    70  // String implements Stringer.
    71  func (dt Type) String() string {
    72  	return string(dt)
    73  }
    74  
    75  var SupportedDiffTypes = []Type{TypeLocal, TypeRemote, TypeCombined, Type3Way}
    76  
    77  func SupportedDiffTypesLabel() string {
    78  	var labels []string
    79  	for _, dt := range SupportedDiffTypes {
    80  		labels = append(labels, dt.String())
    81  	}
    82  	return strings.Join(labels, ", ")
    83  }
    84  
    85  // Command shows changes in local package relative to upstream source pkg, changes in
    86  // upstream source package between original and target version etc.
    87  type Command struct {
    88  	// Path to the local package directory
    89  	Path string
    90  
    91  	// Ref is the target Ref in the upstream source package to compare against
    92  	Ref string
    93  
    94  	// DiffType specifies the type of changes to show
    95  	DiffType Type
    96  
    97  	// Difftool refers to diffing commandline tool for showing changes.
    98  	DiffTool string
    99  
   100  	// DiffToolOpts refers to the commandline options to for the diffing tool.
   101  	DiffToolOpts string
   102  
   103  	// When Debug is true, command will run with verbose logging and will not
   104  	// cleanup the staged packages to assist with debugging.
   105  	Debug bool
   106  
   107  	// Output is an io.Writer where command will write the output of the
   108  	// command.
   109  	Output io.Writer
   110  
   111  	// PkgDiffer specifies package differ
   112  	PkgDiffer PkgDiffer
   113  
   114  	// PkgGetter specifies packaging sourcing adapter
   115  	PkgGetter PkgGetter
   116  }
   117  
   118  func (c *Command) Run(ctx context.Context) error {
   119  	c.DefaultValues()
   120  
   121  	kptFile, err := pkg.ReadKptfile(filesys.FileSystemOrOnDisk{}, c.Path)
   122  	if err != nil {
   123  		return errors.Errorf("package missing Kptfile at '%s': %v", c.Path, err)
   124  	}
   125  
   126  	// Return early if upstream is not set
   127  	if kptFile.Upstream == nil || kptFile.Upstream.Git == nil {
   128  		return errors.Errorf("package missing upstream in Kptfile at '%s'", c.Path)
   129  	}
   130  
   131  	// Create a staging directory to store all compared packages
   132  	stagingDirectory, err := os.MkdirTemp("", "kpt-")
   133  	if err != nil {
   134  		return errors.Errorf("failed to create stage dir: %v", err)
   135  	}
   136  	defer func() {
   137  		// Cleanup staged content after diff. Ignore cleanup if debugging.
   138  		if !c.Debug {
   139  			defer os.RemoveAll(stagingDirectory)
   140  		}
   141  	}()
   142  
   143  	// Stage current package
   144  	// This prevents prepareForDiff from modifying the local package
   145  	localPkgName := NameStagingDirectory(LocalPackageSource,
   146  		kptFile.Upstream.Git.Ref)
   147  	currPkg, err := stageDirectory(stagingDirectory, localPkgName)
   148  	if err != nil {
   149  		return errors.Errorf("failed to create stage dir for current package: %v", err)
   150  	}
   151  
   152  	err = pkgutil.CopyPackage(c.Path, currPkg, true, pkg.Local)
   153  	if err != nil {
   154  		return errors.Errorf("failed to stage current package: %v", err)
   155  	}
   156  
   157  	// get the upstreamPkg at current version
   158  	upstreamPkgName := NameStagingDirectory(RemotePackageSource,
   159  		kptFile.Upstream.Git.Ref)
   160  	upstreamPkg, err := c.PkgGetter.GetPkg(ctx,
   161  		stagingDirectory,
   162  		upstreamPkgName,
   163  		kptFile.Upstream.Git.Repo,
   164  		kptFile.Upstream.Git.Directory,
   165  		kptFile.Upstream.Git.Ref)
   166  	if err != nil {
   167  		return err
   168  	}
   169  
   170  	var upstreamTargetPkg string
   171  
   172  	if c.Ref == "" {
   173  		gur, err := gitutil.NewGitUpstreamRepo(ctx, kptFile.UpstreamLock.Git.Repo)
   174  		if err != nil {
   175  			return err
   176  		}
   177  		c.Ref, err = gur.GetDefaultBranch(ctx)
   178  		if err != nil {
   179  			return err
   180  		}
   181  	}
   182  
   183  	if c.DiffType == TypeRemote ||
   184  		c.DiffType == TypeCombined ||
   185  		c.DiffType == Type3Way {
   186  		// get the upstream pkg at the target version
   187  		upstreamTargetPkgName := NameStagingDirectory(TargetRemotePackageSource,
   188  			c.Ref)
   189  		upstreamTargetPkg, err = c.PkgGetter.GetPkg(ctx, stagingDirectory,
   190  			upstreamTargetPkgName,
   191  			kptFile.Upstream.Git.Repo,
   192  			kptFile.Upstream.Git.Directory,
   193  			c.Ref)
   194  		if err != nil {
   195  			return err
   196  		}
   197  	}
   198  
   199  	if c.Debug {
   200  		fmt.Fprintf(c.Output, "diffing currPkg: %v, upstreamPkg: %v, upstreamTargetPkg: %v \n",
   201  			currPkg, upstreamPkg, upstreamTargetPkg)
   202  	}
   203  
   204  	switch c.DiffType {
   205  	case TypeLocal:
   206  		return c.PkgDiffer.Diff(currPkg, upstreamPkg)
   207  	case TypeRemote:
   208  		return c.PkgDiffer.Diff(upstreamPkg, upstreamTargetPkg)
   209  	case TypeCombined:
   210  		return c.PkgDiffer.Diff(currPkg, upstreamTargetPkg)
   211  	case Type3Way:
   212  		return c.PkgDiffer.Diff(currPkg, upstreamPkg, upstreamTargetPkg)
   213  	default:
   214  		return errors.Errorf("unsupported diff type '%s'", c.DiffType)
   215  	}
   216  }
   217  
   218  func (c *Command) Validate() error {
   219  	switch c.DiffType {
   220  	case TypeLocal, TypeCombined, TypeRemote, Type3Way:
   221  	default:
   222  		return errors.Errorf("invalid diff-type '%s': supported diff-types are: %s",
   223  			c.DiffType, SupportedDiffTypesLabel())
   224  	}
   225  
   226  	path, err := exec.LookPath(c.DiffTool)
   227  	if err != nil {
   228  		return errors.Errorf("diff-tool '%s' not found in the PATH", c.DiffTool)
   229  	}
   230  	c.DiffTool = path
   231  	return nil
   232  }
   233  
   234  // DefaultValues sets up the default values for the command.
   235  func (c *Command) DefaultValues() {
   236  	if c.Output == nil {
   237  		c.Output = os.Stdout
   238  	}
   239  	if c.PkgGetter == nil {
   240  		c.PkgGetter = defaultPkgGetter{}
   241  	}
   242  	if c.PkgDiffer == nil {
   243  		c.PkgDiffer = &defaultPkgDiffer{
   244  			DiffType:     c.DiffType,
   245  			DiffTool:     c.DiffTool,
   246  			DiffToolOpts: c.DiffToolOpts,
   247  			Debug:        c.Debug,
   248  			Output:       c.Output,
   249  		}
   250  	}
   251  }
   252  
   253  // PkgDiffer knows how to compare given packages.
   254  type PkgDiffer interface {
   255  	Diff(pkgs ...string) error
   256  }
   257  
   258  type defaultPkgDiffer struct {
   259  	// DiffType specifies the type of changes to show
   260  	DiffType Type
   261  
   262  	// Difftool refers to diffing commandline tool for showing changes.
   263  	DiffTool string
   264  
   265  	// DiffToolOpts refers to the commandline options to for the diffing tool.
   266  	DiffToolOpts string
   267  
   268  	// When Debug is true, command will run with verbose logging and will not
   269  	// cleanup the staged packages to assist with debugging.
   270  	Debug bool
   271  
   272  	// Output is an io.Writer where command will write the output of the
   273  	// command.
   274  	Output io.Writer
   275  }
   276  
   277  func (d *defaultPkgDiffer) Diff(pkgs ...string) error {
   278  	// add merge comments before comparing so that there are no unwanted diffs
   279  	if err := addmergecomment.Process(pkgs...); err != nil {
   280  		return err
   281  	}
   282  	for _, pkg := range pkgs {
   283  		if err := d.prepareForDiff(pkg); err != nil {
   284  			return err
   285  		}
   286  	}
   287  	var args []string
   288  	if d.DiffToolOpts != "" {
   289  		args = strings.Split(d.DiffToolOpts, " ")
   290  		args = append(args, pkgs...)
   291  	} else {
   292  		args = pkgs
   293  	}
   294  	cmd := exec.Command(d.DiffTool, args...)
   295  	cmd.Stdout = d.Output
   296  	cmd.Stderr = d.Output
   297  
   298  	if d.Debug {
   299  		fmt.Fprintf(d.Output, "%s\n", strings.Join(cmd.Args, " "))
   300  	}
   301  	err := cmd.Run()
   302  	if err != nil {
   303  		exitErr, ok := err.(*exec.ExitError)
   304  		if ok && exitErr.ExitCode() == 1 {
   305  			// diff tool will exit with return code 1 if there are differences
   306  			// between two dirs. This suppresses those errors.
   307  			err = nil
   308  		} else if ok {
   309  			// An error occurred but was not one of the excluded ones
   310  			// Attempt to display help information to assist with resolving
   311  			fmt.Printf(exitCodeDiffWarning, d.DiffTool, d.DiffType)
   312  		}
   313  	}
   314  	return err
   315  }
   316  
   317  // prepareForDiff removes metadata such as .git and Kptfile from a staged package
   318  // to exclude them from diffing.
   319  func (d *defaultPkgDiffer) prepareForDiff(dir string) error {
   320  	excludePaths := []string{".git", kptfilev1.KptFileName}
   321  	for _, path := range excludePaths {
   322  		path = filepath.Join(dir, path)
   323  		if err := os.RemoveAll(path); err != nil {
   324  			return err
   325  		}
   326  	}
   327  	return nil
   328  }
   329  
   330  // PkgGetter knows how to fetch a package given a git repo, path and ref.
   331  type PkgGetter interface {
   332  	GetPkg(ctx context.Context, stagingDir, targetDir, repo, path, ref string) (dir string, err error)
   333  }
   334  
   335  // defaultPkgGetter uses fetch.Command abstraction to implement PkgGetter.
   336  type defaultPkgGetter struct{}
   337  
   338  // GetPkg checks out a repository into a temporary directory for diffing
   339  // and returns the directory containing the checked out package or an error.
   340  // repo is the git repository the package was cloned from.  e.g. https://
   341  // path is the sub directory of the git repository that the package was cloned from
   342  // ref is the git ref the package was cloned from
   343  func (pg defaultPkgGetter) GetPkg(ctx context.Context, stagingDir, targetDir, repo, path, ref string) (string, error) {
   344  	dir, err := stageDirectory(stagingDir, targetDir)
   345  	if err != nil {
   346  		return dir, err
   347  	}
   348  
   349  	name := filepath.Base(dir)
   350  	kf := kptfileutil.DefaultKptfile(name)
   351  	kf.Upstream = &kptfilev1.Upstream{
   352  		Type: kptfilev1.GitOrigin,
   353  		Git: &kptfilev1.Git{
   354  			Repo:      repo,
   355  			Directory: path,
   356  			Ref:       ref,
   357  		},
   358  	}
   359  	err = kptfileutil.WriteFile(dir, kf)
   360  	if err != nil {
   361  		return dir, err
   362  	}
   363  
   364  	p, err := pkg.New(filesys.FileSystemOrOnDisk{}, dir)
   365  	if err != nil {
   366  		return dir, err
   367  	}
   368  
   369  	cmdGet := &fetch.Command{
   370  		Pkg: p,
   371  	}
   372  	err = cmdGet.Run(ctx)
   373  	return dir, err
   374  }
   375  
   376  // stageDirectory creates a subdirectory of the provided path for temporary operations
   377  // path is the parent staged directory and should already exist
   378  // subpath is the subdirectory that should be created inside path
   379  func stageDirectory(path, subpath string) (string, error) {
   380  	targetPath := filepath.Join(path, subpath)
   381  	err := os.Mkdir(targetPath, os.ModePerm)
   382  	return targetPath, err
   383  }
   384  
   385  // NameStagingDirectory assigns a name that matches the package source information
   386  func NameStagingDirectory(source, ref string) string {
   387  	// Using tags may result in references like /refs/tags/version
   388  	// To avoid creating additional directory's use only the last name after a /
   389  	splitRef := strings.Split(ref, "/")
   390  	reducedRef := splitRef[len(splitRef)-1]
   391  
   392  	return fmt.Sprintf("%s-%s",
   393  		source,
   394  		reducedRef)
   395  }