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 }