github.com/wmuizelaar/kpt@v0.0.0-20221018115725-bd564717b2ed/internal/util/update/update.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 update contains libraries for updating packages.
    16  package update
    17  
    18  import (
    19  	"context"
    20  	"fmt"
    21  	"os"
    22  	"path"
    23  	"path/filepath"
    24  	"strings"
    25  
    26  	"github.com/GoogleContainerTools/kpt/internal/errors"
    27  	"github.com/GoogleContainerTools/kpt/internal/gitutil"
    28  	"github.com/GoogleContainerTools/kpt/internal/pkg"
    29  	"github.com/GoogleContainerTools/kpt/internal/printer"
    30  	"github.com/GoogleContainerTools/kpt/internal/types"
    31  	"github.com/GoogleContainerTools/kpt/internal/util/addmergecomment"
    32  	"github.com/GoogleContainerTools/kpt/internal/util/fetch"
    33  	"github.com/GoogleContainerTools/kpt/internal/util/git"
    34  	"github.com/GoogleContainerTools/kpt/internal/util/pkgutil"
    35  	"github.com/GoogleContainerTools/kpt/internal/util/stack"
    36  	kptfilev1 "github.com/GoogleContainerTools/kpt/pkg/api/kptfile/v1"
    37  	"github.com/GoogleContainerTools/kpt/pkg/kptfile/kptfileutil"
    38  	"sigs.k8s.io/kustomize/kyaml/copyutil"
    39  	"sigs.k8s.io/kustomize/kyaml/filesys"
    40  )
    41  
    42  // PkgNotGitRepoError is the error type returned if the package being updated is not inside
    43  // a git repository.
    44  type PkgNotGitRepoError struct {
    45  	Path types.UniquePath
    46  }
    47  
    48  func (p *PkgNotGitRepoError) Error() string {
    49  	return fmt.Sprintf("package %q is not a git repository", p.Path.String())
    50  }
    51  
    52  // PkgRepoDirtyError is the error type returned if the package being updated contains
    53  // uncommitted changes.
    54  type PkgRepoDirtyError struct {
    55  	Path types.UniquePath
    56  }
    57  
    58  func (p *PkgRepoDirtyError) Error() string {
    59  	return fmt.Sprintf("package %q contains uncommitted changes", p.Path.String())
    60  }
    61  
    62  type Options struct {
    63  	// RelPackagePath is the relative path of a subpackage to the root. If the
    64  	// package is root, the value here will be ".".
    65  	RelPackagePath string
    66  
    67  	// LocalPath is the absolute path to the package on the local fork.
    68  	LocalPath string
    69  
    70  	// OriginPath is the absolute path to the package in the on-disk clone
    71  	// of the origin ref of the repo.
    72  	OriginPath string
    73  
    74  	// UpdatedPath is the absolute path to the package in the on-disk clone
    75  	// of the updated ref of the repo.
    76  	UpdatedPath string
    77  
    78  	// IsRoot is true if the package is the root, i.e. the clones of
    79  	// updated and origin were fetched based on the information in the
    80  	// Kptfile from this package.
    81  	IsRoot bool
    82  }
    83  
    84  // Updater updates a local package
    85  type Updater interface {
    86  	Update(options Options) error
    87  }
    88  
    89  var strategies = map[kptfilev1.UpdateStrategyType]func() Updater{
    90  	kptfilev1.FastForward:        func() Updater { return FastForwardUpdater{} },
    91  	kptfilev1.ForceDeleteReplace: func() Updater { return ReplaceUpdater{} },
    92  	kptfilev1.ResourceMerge:      func() Updater { return ResourceMergeUpdater{} },
    93  }
    94  
    95  // Command updates the contents of a local package to a different version.
    96  type Command struct {
    97  	// Pkg captures information about the package that should be updated.
    98  	Pkg *pkg.Pkg
    99  
   100  	// Ref is the ref to update to
   101  	Ref string
   102  
   103  	// Strategy is the update strategy to use
   104  	Strategy kptfilev1.UpdateStrategyType
   105  
   106  	// cachedUpstreamRepos is an upstream repo already fetched for a given repoSpec CloneRef
   107  	cachedUpstreamRepos map[string]*gitutil.GitUpstreamRepo
   108  }
   109  
   110  // Run runs the Command.
   111  func (u *Command) Run(ctx context.Context) error {
   112  	const op errors.Op = "update.Run"
   113  	pr := printer.FromContextOrDie(ctx)
   114  
   115  	if u.Pkg == nil {
   116  		return errors.E(op, errors.MissingParam, "pkg must be provided")
   117  	}
   118  
   119  	rootKf, err := u.Pkg.Kptfile()
   120  	if err != nil {
   121  		return errors.E(op, u.Pkg.UniquePath, err)
   122  	}
   123  
   124  	if rootKf.Upstream == nil || rootKf.Upstream.Git == nil {
   125  		return errors.E(op, u.Pkg.UniquePath,
   126  			fmt.Errorf("package must have an upstream reference"))
   127  	}
   128  	originalRootKfRef := rootKf.Upstream.Git.Ref
   129  	if u.Ref != "" {
   130  		rootKf.Upstream.Git.Ref = u.Ref
   131  	}
   132  	if u.Strategy != "" {
   133  		rootKf.Upstream.UpdateStrategy = u.Strategy
   134  	}
   135  	err = kptfileutil.WriteFile(u.Pkg.UniquePath.String(), rootKf)
   136  	if err != nil {
   137  		return errors.E(op, u.Pkg.UniquePath, err)
   138  	}
   139  	if u.cachedUpstreamRepos == nil {
   140  		u.cachedUpstreamRepos = make(map[string]*gitutil.GitUpstreamRepo)
   141  	}
   142  	packageCount := 0
   143  
   144  	// Use stack to keep track of paths with a Kptfile that might contain
   145  	// information about remote subpackages.
   146  	s := stack.NewPkgStack()
   147  	s.Push(u.Pkg)
   148  
   149  	for s.Len() > 0 {
   150  		p := s.Pop()
   151  		packageCount++
   152  
   153  		if err := u.updateRootPackage(ctx, p); err != nil {
   154  			return errors.E(op, p.UniquePath, err)
   155  		}
   156  
   157  		subPkgs, err := p.DirectSubpackages()
   158  		if err != nil {
   159  			return errors.E(op, p.UniquePath, err)
   160  		}
   161  		for _, subPkg := range subPkgs {
   162  			subKf, err := subPkg.Kptfile()
   163  			if err != nil {
   164  				return errors.E(op, p.UniquePath, err)
   165  			}
   166  
   167  			if subKf.Upstream != nil && subKf.Upstream.Git != nil {
   168  				// update subpackage kf ref/strategy if current pkg is a subpkg of root pkg or is root pkg
   169  				// and if original root pkg ref matches the subpkg ref
   170  				if shouldUpdateSubPkgRef(subKf, rootKf, originalRootKfRef) {
   171  					updateSubKf(subKf, u.Ref, u.Strategy)
   172  					err = kptfileutil.WriteFile(subPkg.UniquePath.String(), subKf)
   173  					if err != nil {
   174  						return errors.E(op, subPkg.UniquePath, err)
   175  					}
   176  				}
   177  				s.Push(subPkg)
   178  			}
   179  		}
   180  	}
   181  	pr.Printf("\nUpdated %d package(s).\n", packageCount)
   182  
   183  	// finally, make sure that the merge comments are added to all resources in the updated package
   184  	if err := addmergecomment.Process(string(u.Pkg.UniquePath)); err != nil {
   185  		return errors.E(op, u.Pkg.UniquePath, err)
   186  	}
   187  	return nil
   188  }
   189  
   190  // GetCachedUpstreamRepos returns repos cached during update
   191  func (u Command) GetCachedUpstreamRepos() map[string]*gitutil.GitUpstreamRepo {
   192  	return u.cachedUpstreamRepos
   193  }
   194  
   195  // updateSubKf updates subpackage with given ref and update strategy
   196  func updateSubKf(subKf *kptfilev1.KptFile, ref string, strategy kptfilev1.UpdateStrategyType) {
   197  	// check if explicit ref provided
   198  	if ref != "" {
   199  		subKf.Upstream.Git.Ref = ref
   200  	}
   201  	if strategy != "" {
   202  		subKf.Upstream.UpdateStrategy = strategy
   203  	}
   204  }
   205  
   206  // shouldUpdateSubPkgRef checks if subpkg ref should be updated.
   207  // This is true if pkg has the same upstream repo, upstream directory is within or equal to root pkg directory and original root pkg ref matches the subpkg ref.
   208  func shouldUpdateSubPkgRef(subKf, rootKf *kptfilev1.KptFile, originalRootKfRef string) bool {
   209  	return subKf.Upstream.Git.Repo == rootKf.Upstream.Git.Repo &&
   210  		subKf.Upstream.Git.Ref == originalRootKfRef &&
   211  		strings.HasPrefix(path.Clean(subKf.Upstream.Git.Directory), path.Clean(rootKf.Upstream.Git.Directory))
   212  }
   213  
   214  // repoClone is an interface that represents a clone of a repo on the local
   215  // disk.
   216  type repoClone interface {
   217  	AbsPath() string
   218  }
   219  
   220  // newNilRepoClone creates a new nilRepoClone that implements the repoClone
   221  // interface
   222  func newNilRepoClone() (*nilRepoClone, error) {
   223  	const op errors.Op = "update.newNilRepoClone"
   224  	dir, err := os.MkdirTemp("", "kpt-empty-")
   225  	if err != nil {
   226  		return nil, errors.E(op, errors.IO, fmt.Errorf("errors creating a temporary directory: %w", err))
   227  	}
   228  	return &nilRepoClone{
   229  		dir: dir,
   230  	}, nil
   231  }
   232  
   233  // nilRepoClone is an implementation of the repoClone interface, but that
   234  // just represents an empty directory. This simplifies the logic for update
   235  // since we don't have to special case situations where we don't have
   236  // upstream and/or origin.
   237  type nilRepoClone struct {
   238  	dir string
   239  }
   240  
   241  // AbsPath returns the absolute path to the local directory for the repo. For
   242  // the nilRepoClone, this will always be an empty directory.
   243  func (nrc *nilRepoClone) AbsPath() string {
   244  	return nrc.dir
   245  }
   246  
   247  // updateRootPackage updates a local package. It will use the information
   248  // about upstream in the Kptfile to fetch upstream and origin, and then
   249  // recursively traverse the hierarchy to add/update/delete packages.
   250  func (u Command) updateRootPackage(ctx context.Context, p *pkg.Pkg) error {
   251  	const op errors.Op = "update.updateRootPackage"
   252  	kf, err := p.Kptfile()
   253  	if err != nil {
   254  		return errors.E(op, p.UniquePath, err)
   255  	}
   256  
   257  	pr := printer.FromContextOrDie(ctx)
   258  	pr.PrintPackage(p, !(p == u.Pkg))
   259  
   260  	g := kf.Upstream.Git
   261  	updated := &git.RepoSpec{OrgRepo: g.Repo, Path: g.Directory, Ref: g.Ref}
   262  	pr.Printf("Fetching upstream from %s@%s\n", kf.Upstream.Git.Repo, kf.Upstream.Git.Ref)
   263  	cloner := fetch.NewCloner(updated, fetch.WithCachedRepo(u.cachedUpstreamRepos))
   264  	if err := cloner.ClonerUsingGitExec(ctx); err != nil {
   265  		return errors.E(op, p.UniquePath, err)
   266  	}
   267  	defer os.RemoveAll(updated.AbsPath())
   268  
   269  	var origin repoClone
   270  	if kf.UpstreamLock != nil {
   271  		gLock := kf.UpstreamLock.Git
   272  		originRepoSpec := &git.RepoSpec{OrgRepo: gLock.Repo, Path: gLock.Directory, Ref: gLock.Commit}
   273  		pr.Printf("Fetching origin from %s@%s\n", kf.Upstream.Git.Repo, kf.Upstream.Git.Ref)
   274  		if err := fetch.NewCloner(originRepoSpec, fetch.WithCachedRepo(u.cachedUpstreamRepos)).ClonerUsingGitExec(ctx); err != nil {
   275  			return errors.E(op, p.UniquePath, err)
   276  		}
   277  		origin = originRepoSpec
   278  	} else {
   279  		origin, err = newNilRepoClone()
   280  		if err != nil {
   281  			return errors.E(op, p.UniquePath, err)
   282  		}
   283  	}
   284  	defer os.RemoveAll(origin.AbsPath())
   285  
   286  	s := stack.New()
   287  	s.Push(".")
   288  
   289  	for s.Len() > 0 {
   290  		relPath := s.Pop()
   291  		localPath := filepath.Join(p.UniquePath.String(), relPath)
   292  		updatedPath := filepath.Join(updated.AbsPath(), relPath)
   293  		originPath := filepath.Join(origin.AbsPath(), relPath)
   294  
   295  		isRoot := false
   296  		if relPath == "." {
   297  			isRoot = true
   298  		}
   299  
   300  		if err := u.updatePackage(ctx, relPath, localPath, updatedPath, originPath, isRoot); err != nil {
   301  			return errors.E(op, p.UniquePath, err)
   302  		}
   303  
   304  		paths, err := pkgutil.FindSubpackagesForPaths(pkg.Remote, false,
   305  			localPath, updatedPath, originPath)
   306  		if err != nil {
   307  			return errors.E(op, p.UniquePath, err)
   308  		}
   309  		for _, path := range paths {
   310  			s.Push(filepath.Join(relPath, path))
   311  		}
   312  	}
   313  
   314  	if err := kptfileutil.UpdateUpstreamLockFromGit(p.UniquePath.String(), updated); err != nil {
   315  		return errors.E(op, p.UniquePath, err)
   316  	}
   317  	return nil
   318  }
   319  
   320  // updatePackage takes care of updating a single package. The absolute paths to
   321  // the local, updated and origin packages are provided, as well as the path to the
   322  // package relative to the root.
   323  // The last parameter tells if this package is the root, i.e. the package
   324  // from which we got the information about upstream and origin.
   325  //
   326  //nolint:gocyclo
   327  func (u Command) updatePackage(ctx context.Context, subPkgPath, localPath, updatedPath, originPath string, isRootPkg bool) error {
   328  	const op errors.Op = "update.updatePackage"
   329  	pr := printer.FromContextOrDie(ctx)
   330  
   331  	localExists, err := pkg.IsPackageDir(filesys.FileSystemOrOnDisk{}, localPath)
   332  	if err != nil {
   333  		return errors.E(op, types.UniquePath(localPath), err)
   334  	}
   335  
   336  	// We need to handle the root package special here, since the copies
   337  	// from updated and origin might not have a Kptfile at the root.
   338  	updatedExists := isRootPkg
   339  	if !isRootPkg {
   340  		updatedExists, err = pkg.IsPackageDir(filesys.FileSystemOrOnDisk{}, updatedPath)
   341  		if err != nil {
   342  			return errors.E(op, types.UniquePath(localPath), err)
   343  		}
   344  	}
   345  
   346  	originExists := isRootPkg
   347  	if !isRootPkg {
   348  		originExists, err = pkg.IsPackageDir(filesys.FileSystemOrOnDisk{}, originPath)
   349  		if err != nil {
   350  			return errors.E(op, types.UniquePath(localPath), err)
   351  		}
   352  	}
   353  
   354  	switch {
   355  	case !originExists && !localExists && !updatedExists:
   356  		break
   357  	// Check if subpackage has been added both in upstream and in local. We
   358  	// can't make a sane merge here, so we treat it as an error.
   359  	case !originExists && localExists && updatedExists:
   360  		pr.Printf("Package %q added in both local and upstream.\n", packageName(localPath))
   361  		return errors.E(op, types.UniquePath(localPath),
   362  			fmt.Errorf("subpackage %q added in both upstream and local", subPkgPath))
   363  
   364  	// Package added in upstream. We just copy the package. If the package
   365  	// contains any unfetched subpackages, those will be handled when we traverse
   366  	// the package hierarchy and that package is the root.
   367  	case !originExists && !localExists && updatedExists:
   368  		pr.Printf("Adding package %q from upstream.\n", packageName(localPath))
   369  		if err := pkgutil.CopyPackage(updatedPath, localPath, !isRootPkg, pkg.None); err != nil {
   370  			return errors.E(op, types.UniquePath(localPath), err)
   371  		}
   372  
   373  	// Package added locally, so no action needed.
   374  	case !originExists && localExists && !updatedExists:
   375  		break
   376  
   377  	// Package deleted from both upstream and local, so no action needed.
   378  	case originExists && !localExists && !updatedExists:
   379  		break
   380  
   381  	// Package deleted from local
   382  	// In this case we assume the user knows what they are doing, so
   383  	// we don't re-add the updated package from upstream.
   384  	case originExists && !localExists && updatedExists:
   385  		pr.Printf("Ignoring package %q in upstream since it is deleted from local.\n", packageName(localPath))
   386  
   387  	// Package deleted from upstream
   388  	case originExists && localExists && !updatedExists:
   389  		// Check the diff. If there are local changes, we keep the subpackage.
   390  		diff, err := copyutil.Diff(originPath, localPath)
   391  		if err != nil {
   392  			return errors.E(op, types.UniquePath(localPath), err)
   393  		}
   394  		if diff.Len() == 0 {
   395  			pr.Printf("Deleting package %q from local since it is removed in upstream.\n", packageName(localPath))
   396  			if err := os.RemoveAll(localPath); err != nil {
   397  				return errors.E(op, types.UniquePath(localPath), err)
   398  			}
   399  		} else {
   400  			pr.Printf("Package %q deleted from upstream, but keeping local since it has changes.\n", packageName(localPath))
   401  		}
   402  	default:
   403  		if err := u.mergePackage(ctx, localPath, updatedPath, originPath, subPkgPath, isRootPkg); err != nil {
   404  			return errors.E(op, types.UniquePath(localPath), err)
   405  		}
   406  	}
   407  	return nil
   408  }
   409  
   410  func (u Command) mergePackage(ctx context.Context, localPath, updatedPath, originPath, relPath string, isRootPkg bool) error {
   411  	const op errors.Op = "update.mergePackage"
   412  	pr := printer.FromContextOrDie(ctx)
   413  	// at this point, the localPath, updatedPath and originPath exists and are about to be merged
   414  	// make sure that the merge comments are added to all of them so that they are merged accurately
   415  	if err := addmergecomment.Process(localPath, updatedPath, originPath); err != nil {
   416  		return errors.E(op, types.UniquePath(localPath),
   417  			fmt.Errorf("failed to add merge comments %q", err.Error()))
   418  	}
   419  	updatedUnfetched, err := pkg.IsPackageUnfetched(updatedPath)
   420  	if err != nil {
   421  		if !errors.Is(err, os.ErrNotExist) || !isRootPkg {
   422  			return errors.E(op, types.UniquePath(localPath), err)
   423  		}
   424  		// For root packages, there might not be a Kptfile in the upstream repo.
   425  		updatedUnfetched = false
   426  	}
   427  
   428  	originUnfetched, err := pkg.IsPackageUnfetched(originPath)
   429  	if err != nil {
   430  		if !errors.Is(err, os.ErrNotExist) || !isRootPkg {
   431  			return errors.E(op, types.UniquePath(localPath), err)
   432  		}
   433  		// For root packages, there might not be a Kptfile in origin.
   434  		originUnfetched = false
   435  	}
   436  
   437  	switch {
   438  	case updatedUnfetched && originUnfetched:
   439  		fallthrough
   440  	case updatedUnfetched && !originUnfetched:
   441  		// updated is unfetched, so can't have changes except for Kptfile.
   442  		// we can just merge that one.
   443  		return kptfileutil.UpdateKptfile(localPath, updatedPath, originPath, true)
   444  	case !updatedUnfetched && originUnfetched:
   445  		// This means that the package was unfetched when local forked from upstream,
   446  		// so the local fork and upstream might have fetched different versions of
   447  		// the package. We just return an error here.
   448  		// We might be able to compare the commit SHAs from local and updated
   449  		// to determine if they share the common upstream and then fetch origin
   450  		// using the common commit SHA. But this is a very advanced scenario,
   451  		// so we just return the error for now.
   452  		return errors.E(op, types.UniquePath(localPath), fmt.Errorf("no origin available for package"))
   453  	default:
   454  		// Both exists, so just go ahead as normal.
   455  	}
   456  
   457  	pkgKf, err := pkg.ReadKptfile(filesys.FileSystemOrOnDisk{}, localPath)
   458  	if err != nil {
   459  		return errors.E(op, types.UniquePath(localPath), err)
   460  	}
   461  	updater, found := strategies[pkgKf.Upstream.UpdateStrategy]
   462  	if !found {
   463  		return errors.E(op, types.UniquePath(localPath),
   464  			fmt.Errorf("unrecognized update strategy %s", u.Strategy))
   465  	}
   466  	pr.Printf("Updating package %q with strategy %q.\n", packageName(localPath), pkgKf.Upstream.UpdateStrategy)
   467  	if err := updater().Update(Options{
   468  		RelPackagePath: relPath,
   469  		LocalPath:      localPath,
   470  		UpdatedPath:    updatedPath,
   471  		OriginPath:     originPath,
   472  		IsRoot:         isRootPkg,
   473  	}); err != nil {
   474  		return errors.E(op, types.UniquePath(localPath), err)
   475  	}
   476  
   477  	return nil
   478  }
   479  
   480  func packageName(path string) string {
   481  	return filepath.Base(path)
   482  }