github.com/wmuizelaar/kpt@v0.0.0-20221018115725-bd564717b2ed/internal/util/parse/parse.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 parse
    16  
    17  import (
    18  	"context"
    19  	"fmt"
    20  	"os"
    21  	"path"
    22  	"path/filepath"
    23  	"regexp"
    24  	"strings"
    25  
    26  	"github.com/GoogleContainerTools/kpt/internal/gitutil"
    27  	kptfilev1 "github.com/GoogleContainerTools/kpt/pkg/api/kptfile/v1"
    28  	"sigs.k8s.io/kustomize/kyaml/errors"
    29  )
    30  
    31  const gitSuffixRegexp = "\\.git($|/)"
    32  
    33  type Target struct {
    34  	kptfilev1.Git
    35  	Destination string
    36  }
    37  
    38  func GitParseArgs(ctx context.Context, args []string) (Target, error) {
    39  	g := Target{}
    40  	if args[0] == "-" {
    41  		return g, nil
    42  	}
    43  
    44  	// Simple parsing if contains .git{$|/)
    45  	if HasGitSuffix(args[0]) {
    46  		return targetFromPkgURL(ctx, args[0], args[1])
    47  	}
    48  
    49  	// GitHub parsing if contains github.com
    50  	if strings.Contains(args[0], "github.com") {
    51  		ghPkgURL, err := pkgURLFromGHURL(ctx, args[0], getRepoBranches)
    52  		if err != nil {
    53  			return g, err
    54  		}
    55  		return targetFromPkgURL(ctx, ghPkgURL, args[1])
    56  	}
    57  
    58  	uri, version, err := getURIAndVersion(args[0])
    59  	if err != nil {
    60  		return g, err
    61  	}
    62  	repo, remoteDir, err := getRepoAndPkg(uri)
    63  	if err != nil {
    64  		return g, err
    65  	}
    66  	if version == "" {
    67  		gur, err := gitutil.NewGitUpstreamRepo(ctx, repo)
    68  		if err != nil {
    69  			return g, err
    70  		}
    71  		defaultRef, err := gur.GetDefaultBranch(ctx)
    72  		if err != nil {
    73  			return g, err
    74  		}
    75  		version = defaultRef
    76  	}
    77  
    78  	destination, err := getDest(args[1], repo, remoteDir)
    79  	if err != nil {
    80  		return g, err
    81  	}
    82  	g.Ref = version
    83  	g.Directory = path.Clean(remoteDir)
    84  	g.Repo = repo
    85  	g.Destination = filepath.Clean(destination)
    86  	return g, nil
    87  }
    88  
    89  // targetFromPkgURL parses a pkg url and destination into kptfile git info and local destination Target
    90  func targetFromPkgURL(ctx context.Context, pkgURL string, dest string) (Target, error) {
    91  	g := Target{}
    92  	repo, dir, ref, err := URL(pkgURL)
    93  	if err != nil {
    94  		return g, err
    95  	}
    96  	if dir == "" {
    97  		dir = "/"
    98  	}
    99  	if ref == "" {
   100  		gur, err := gitutil.NewGitUpstreamRepo(ctx, repo)
   101  		if err != nil {
   102  			return g, err
   103  		}
   104  		defaultRef, err := gur.GetDefaultBranch(ctx)
   105  		if err != nil {
   106  			return g, err
   107  		}
   108  		ref = defaultRef
   109  	}
   110  	destination, err := getDest(dest, repo, dir)
   111  	if err != nil {
   112  		return g, err
   113  	}
   114  	g.Ref = ref
   115  	g.Directory = path.Clean(dir)
   116  	g.Repo = repo
   117  	g.Destination = filepath.Clean(destination)
   118  	return g, nil
   119  }
   120  
   121  // URL parses a pkg url (must contain ".git") and returns the repo, directory, and version
   122  func URL(pkgURL string) (repo string, dir string, ref string, err error) {
   123  	parts := regexp.MustCompile(gitSuffixRegexp).Split(pkgURL, 2)
   124  	index := strings.Index(pkgURL, parts[0])
   125  	repo = strings.Join([]string{pkgURL[:index], parts[0]}, "")
   126  	switch {
   127  	case len(parts) == 1 || parts[1] == "":
   128  		// do nothing
   129  	case strings.Contains(parts[1], "@"):
   130  		parts := strings.Split(parts[1], "@")
   131  		ref = strings.TrimSuffix(parts[1], "/")
   132  		dir = string(filepath.Separator) + parts[0]
   133  	default:
   134  		dir = string(filepath.Separator) + parts[1]
   135  	}
   136  	return repo, dir, ref, nil
   137  }
   138  
   139  // pkgURLFromGHURL converts a GitHub URL into a well formed pkg url
   140  // by adding a .git suffix after repo URI and version info if available
   141  func pkgURLFromGHURL(ctx context.Context, v string, findRepoBranches func(context.Context, string) ([]string, error)) (string, error) {
   142  	v = strings.TrimSuffix(v, "/")
   143  	// url should have scheme and host separated by ://
   144  	parts := strings.SplitN(v, "://", 2)
   145  	if len(parts) != 2 {
   146  		return "", errors.Errorf("invalid GitHub url: %s", v)
   147  	}
   148  	// host should be github.com
   149  	if !strings.HasPrefix(parts[1], "github.com") {
   150  		return "", errors.Errorf("invalid GitHub url: %s", v)
   151  	}
   152  
   153  	ghRepoParts := strings.Split(parts[1], "/")
   154  	// expect at least github.com/owner/repo
   155  	if len(ghRepoParts) < 3 {
   156  		return "", errors.Errorf("invalid GitHub pkg url: %s", v)
   157  	}
   158  	// url of form github.com/owner/repo
   159  	if len(ghRepoParts) == 3 {
   160  		repoWithPath := path.Join(ghRepoParts...)
   161  		// return scheme://github.com/owner/repo.git
   162  		return parts[0] + "://" + path.Join(repoWithPath) + ".git", nil
   163  	}
   164  
   165  	// url of form github.com/owner/repo/tree/ref/<path>
   166  	if ghRepoParts[3] == "tree" && len(ghRepoParts) > 4 {
   167  		repo := parts[0] + "://" + path.Join(ghRepoParts[:3]...)
   168  		version := ghRepoParts[4]
   169  		dir := path.Join(ghRepoParts[5:]...)
   170  		// For an input like github.com/owner/repo/tree/feature/foo-feat where feature/foo-feat is the branch name
   171  		// we will extract version as feature which is invalid.
   172  		// To identify potential mismatch, we find all branches in the upstream repo
   173  		// and check for potential matches, returning an error if any matched.
   174  		branches, err := findRepoBranches(ctx, repo)
   175  		if err != nil {
   176  			return "", err
   177  		}
   178  		if isAmbiguousBranch(version, branches) {
   179  			return "", errors.Errorf("ambiguous repo/dir@version specify '.git' in argument: %s", v)
   180  		}
   181  
   182  		if dir != "" {
   183  			// return scheme://github.com/owner/repo.git/path@ref
   184  			return fmt.Sprintf("%s.git/%s@%s", repo, dir, version), nil
   185  		}
   186  		// return scheme://github.com/owner/repo.git@ref
   187  		return fmt.Sprintf("%s.git@%s", repo, version), nil
   188  	}
   189  	// if no tree, version info is unavailable in url
   190  	// url of form github.com/owner/repo/<path>
   191  	repo := fmt.Sprintf("%s://%s", parts[0], path.Join(ghRepoParts[:3]...))
   192  	dir := path.Join(ghRepoParts[3:]...)
   193  	// return scheme://github.com/owner/repo.git/path
   194  	return repo + path.Join(".git", dir), nil
   195  }
   196  
   197  // getRepoBranches returns a slice of branches in upstream repo
   198  func getRepoBranches(ctx context.Context, repo string) ([]string, error) {
   199  	gur, err := gitutil.NewGitUpstreamRepo(ctx, repo)
   200  	if err != nil {
   201  		return nil, err
   202  	}
   203  	branches := make([]string, 0, len(gur.Heads))
   204  	for head := range gur.Heads {
   205  		branches = append(branches, head)
   206  	}
   207  	return branches, nil
   208  }
   209  
   210  // isAmbiguousBranch checks if a given branch name is similar to other branch names.
   211  // If a branch with an appended slash matches other branches, then it is ambiguous.
   212  func isAmbiguousBranch(branch string, branches []string) bool {
   213  	branch += "/"
   214  	for _, b := range branches {
   215  		if strings.Contains(b, branch) {
   216  			return true
   217  		}
   218  	}
   219  	return false
   220  }
   221  
   222  // getURIAndVersion parses the repo+pkgURI and the version from v
   223  func getURIAndVersion(v string) (string, string, error) {
   224  	if strings.Count(v, "://") > 1 {
   225  		return "", "", errors.Errorf("ambiguous repo/dir@version specify '.git' in argument")
   226  	}
   227  	if strings.Count(v, "@") > 2 {
   228  		return "", "", errors.Errorf("ambiguous repo/dir@version specify '.git' in argument")
   229  	}
   230  	pkgURI := strings.SplitN(v, "@", 2)
   231  	if len(pkgURI) == 1 {
   232  		return pkgURI[0], "", nil
   233  	}
   234  	return pkgURI[0], pkgURI[1], nil
   235  }
   236  
   237  // getRepoAndPkg parses the repository uri and the package subdirectory from v
   238  func getRepoAndPkg(v string) (string, string, error) {
   239  	parts := strings.SplitN(v, "://", 2)
   240  	if len(parts) != 2 {
   241  		return "", "", errors.Errorf("ambiguous repo/dir@version specify '.git' in argument")
   242  	}
   243  
   244  	if strings.Count(v, ".git/") != 1 && !strings.HasSuffix(v, ".git") {
   245  		return "", "", errors.Errorf("ambiguous repo/dir@version specify '.git' in argument")
   246  	}
   247  
   248  	if strings.HasSuffix(v, ".git") || strings.HasSuffix(v, ".git/") {
   249  		v = strings.TrimSuffix(v, "/")
   250  		v = strings.TrimSuffix(v, ".git")
   251  		return v, "/", nil
   252  	}
   253  
   254  	repoAndPkg := strings.SplitN(v, ".git/", 2)
   255  	return repoAndPkg[0], repoAndPkg[1], nil
   256  }
   257  
   258  func getDest(v, repo, subdir string) (string, error) {
   259  	v = filepath.Clean(v)
   260  
   261  	f, err := os.Stat(v)
   262  	if os.IsNotExist(err) {
   263  		parent := filepath.Dir(v)
   264  		if _, err := os.Stat(parent); os.IsNotExist(err) {
   265  			// error -- fetch to directory where parent does not exist
   266  			return "", errors.Errorf("parent directory %q does not exist", parent)
   267  		}
   268  		// fetch to a specific directory -- don't default the name
   269  		return v, nil
   270  	}
   271  
   272  	if !f.IsDir() {
   273  		return "", errors.Errorf("LOCAL_PKG_DEST must be a directory")
   274  	}
   275  
   276  	// LOCATION EXISTS
   277  	// default the location to a new subdirectory matching the pkg URI base
   278  	repo = strings.TrimSuffix(repo, "/")
   279  	repo = strings.TrimSuffix(repo, ".git")
   280  	v = filepath.Join(v, path.Base(path.Join(path.Clean(repo), path.Clean(subdir))))
   281  
   282  	// make sure the destination directory does not yet exist yet
   283  	if _, err := os.Stat(v); !os.IsNotExist(err) {
   284  		return "", errors.Errorf("destination directory %q already exists", v)
   285  	}
   286  	return v, nil
   287  }
   288  
   289  // HasGitSuffix returns true if the provided pkgURL is a git repo containing the ".git" suffix
   290  func HasGitSuffix(pkgURL string) bool {
   291  	matched, err := regexp.Match(gitSuffixRegexp, []byte(pkgURL))
   292  	return matched && err == nil
   293  }