github.com/cycloidio/terraform@v1.1.10-0.20220513142504-76d5c768dc63/getmodules/git_getter.go (about) 1 package getmodules 2 3 import ( 4 "bytes" 5 "context" 6 "encoding/base64" 7 "fmt" 8 "io/ioutil" 9 "net/url" 10 "os" 11 "os/exec" 12 "path/filepath" 13 "regexp" 14 "runtime" 15 "strconv" 16 "strings" 17 "syscall" 18 19 getter "github.com/hashicorp/go-getter" 20 urlhelper "github.com/hashicorp/go-getter/helper/url" 21 safetemp "github.com/hashicorp/go-safetemp" 22 version "github.com/hashicorp/go-version" 23 ) 24 25 // getter is our base getter; it regroups 26 // fields all getters have in common. 27 type getterCommon struct { 28 client *getter.Client 29 } 30 31 func (g *getterCommon) SetClient(c *getter.Client) { g.client = c } 32 33 // Context tries to returns the Contex from the getter's 34 // client. otherwise context.Background() is returned. 35 func (g *getterCommon) Context() context.Context { 36 if g == nil || g.client == nil { 37 return context.Background() 38 } 39 return g.client.Ctx 40 } 41 42 // gitGetter is a temporary fork of getter.GitGetter to allow us to tactically 43 // fix https://github.com/cycloidio/terraform/issues/30119 only within 44 // Terraform. 45 // 46 // This should be only a brief workaround to help us decouple work on the 47 // Terraform CLI v1.1.1 release so that we can get it done without having to 48 // coordinate with every other go-getter caller first. However, this fork 49 // should be healed promptly after v1.1.1 by upstreaming something like this 50 // fix into upstream go-getter, so that other go-getter callers can also 51 // benefit from it. 52 type gitGetter struct { 53 getterCommon 54 } 55 56 var defaultBranchRegexp = regexp.MustCompile(`\s->\sorigin/(.*)`) 57 var lsRemoteSymRefRegexp = regexp.MustCompile(`ref: refs/heads/([^\s]+).*`) 58 59 func (g *gitGetter) ClientMode(_ *url.URL) (getter.ClientMode, error) { 60 return getter.ClientModeDir, nil 61 } 62 63 func (g *gitGetter) Get(dst string, u *url.URL) error { 64 ctx := g.Context() 65 if _, err := exec.LookPath("git"); err != nil { 66 return fmt.Errorf("git must be available and on the PATH") 67 } 68 69 // The port number must be parseable as an integer. If not, the user 70 // was probably trying to use a scp-style address, in which case the 71 // ssh:// prefix must be removed to indicate that. 72 // 73 // This is not necessary in versions of Go which have patched 74 // CVE-2019-14809 (e.g. Go 1.12.8+) 75 if portStr := u.Port(); portStr != "" { 76 if _, err := strconv.ParseUint(portStr, 10, 16); err != nil { 77 return fmt.Errorf("invalid port number %q; if using the \"scp-like\" git address scheme where a colon introduces the path instead, remove the ssh:// portion and use just the git:: prefix", portStr) 78 } 79 } 80 81 // Extract some query parameters we use 82 var ref, sshKey string 83 depth := 0 // 0 means "not set" 84 q := u.Query() 85 if len(q) > 0 { 86 ref = q.Get("ref") 87 q.Del("ref") 88 89 sshKey = q.Get("sshkey") 90 q.Del("sshkey") 91 92 if n, err := strconv.Atoi(q.Get("depth")); err == nil { 93 depth = n 94 } 95 q.Del("depth") 96 97 // Copy the URL 98 var newU url.URL = *u 99 u = &newU 100 u.RawQuery = q.Encode() 101 } 102 103 var sshKeyFile string 104 if sshKey != "" { 105 // Check that the git version is sufficiently new. 106 if err := checkGitVersion("2.3"); err != nil { 107 return fmt.Errorf("Error using ssh key: %v", err) 108 } 109 110 // We have an SSH key - decode it. 111 raw, err := base64.StdEncoding.DecodeString(sshKey) 112 if err != nil { 113 return err 114 } 115 116 // Create a temp file for the key and ensure it is removed. 117 fh, err := ioutil.TempFile("", "go-getter") 118 if err != nil { 119 return err 120 } 121 sshKeyFile = fh.Name() 122 defer os.Remove(sshKeyFile) 123 124 // Set the permissions prior to writing the key material. 125 if err := os.Chmod(sshKeyFile, 0600); err != nil { 126 return err 127 } 128 129 // Write the raw key into the temp file. 130 _, err = fh.Write(raw) 131 fh.Close() 132 if err != nil { 133 return err 134 } 135 } 136 137 // Clone or update the repository 138 _, err := os.Stat(dst) 139 if err != nil && !os.IsNotExist(err) { 140 return err 141 } 142 if err == nil { 143 err = g.update(ctx, dst, sshKeyFile, ref, depth) 144 } else { 145 err = g.clone(ctx, dst, sshKeyFile, u, ref, depth) 146 } 147 if err != nil { 148 return err 149 } 150 151 // Next: check out the proper tag/branch if it is specified, and checkout 152 if ref != "" { 153 if err := g.checkout(dst, ref); err != nil { 154 return err 155 } 156 } 157 158 // Lastly, download any/all submodules. 159 return g.fetchSubmodules(ctx, dst, sshKeyFile, depth) 160 } 161 162 // GetFile for Git doesn't support updating at this time. It will download 163 // the file every time. 164 func (g *gitGetter) GetFile(dst string, u *url.URL) error { 165 td, tdcloser, err := safetemp.Dir("", "getter") 166 if err != nil { 167 return err 168 } 169 defer tdcloser.Close() 170 171 // Get the filename, and strip the filename from the URL so we can 172 // just get the repository directly. 173 filename := filepath.Base(u.Path) 174 u.Path = filepath.Dir(u.Path) 175 176 // Get the full repository 177 if err := g.Get(td, u); err != nil { 178 return err 179 } 180 181 // Copy the single file 182 u, err = urlhelper.Parse(fmtFileURL(filepath.Join(td, filename))) 183 if err != nil { 184 return err 185 } 186 187 fg := &getter.FileGetter{Copy: true} 188 return fg.GetFile(dst, u) 189 } 190 191 func (g *gitGetter) checkout(dst string, ref string) error { 192 cmd := exec.Command("git", "checkout", ref) 193 cmd.Dir = dst 194 return getRunCommand(cmd) 195 } 196 197 // gitCommitIDRegex is a pattern intended to match strings that seem 198 // "likely to be" git commit IDs, rather than named refs. This cannot be 199 // an exact decision because it's valid to name a branch or tag after a series 200 // of hexadecimal digits too. 201 // 202 // We require at least 7 digits here because that's the smallest size git 203 // itself will typically generate, and so it'll reduce the risk of false 204 // positives on short branch names that happen to also be "hex words". 205 var gitCommitIDRegex = regexp.MustCompile("^[0-9a-fA-F]{7,40}$") 206 207 func (g *gitGetter) clone(ctx context.Context, dst, sshKeyFile string, u *url.URL, ref string, depth int) error { 208 args := []string{"clone"} 209 210 autoBranch := false 211 if ref == "" { 212 ref = findRemoteDefaultBranch(u) 213 autoBranch = true 214 } 215 if depth > 0 { 216 args = append(args, "--depth", strconv.Itoa(depth)) 217 args = append(args, "--branch", ref) 218 } 219 args = append(args, u.String(), dst) 220 221 cmd := exec.CommandContext(ctx, "git", args...) 222 setupGitEnv(cmd, sshKeyFile) 223 err := getRunCommand(cmd) 224 if err != nil { 225 if depth > 0 && !autoBranch { 226 // If we're creating a shallow clone then the given ref must be 227 // a named ref (branch or tag) rather than a commit directly. 228 // We can't accurately recognize the resulting error here without 229 // hard-coding assumptions about git's human-readable output, but 230 // we can at least try a heuristic. 231 if gitCommitIDRegex.MatchString(ref) { 232 return fmt.Errorf("%w (note that setting 'depth' requires 'ref' to be a branch or tag name)", err) 233 } 234 } 235 return err 236 } 237 238 if depth < 1 && !autoBranch { 239 // If we didn't add --depth and --branch above then we will now be 240 // on the remote repository's default branch, rather than the selected 241 // ref, so we'll need to fix that before we return. 242 return g.checkout(dst, ref) 243 } 244 return nil 245 } 246 247 func (g *gitGetter) update(ctx context.Context, dst, sshKeyFile, ref string, depth int) error { 248 // Determine if we're a branch. If we're NOT a branch, then we just 249 // switch to master prior to checking out 250 cmd := exec.CommandContext(ctx, "git", "show-ref", "-q", "--verify", "refs/heads/"+ref) 251 cmd.Dir = dst 252 253 if getRunCommand(cmd) != nil { 254 // Not a branch, switch to default branch. This will also catch 255 // non-existent branches, in which case we want to switch to default 256 // and then checkout the proper branch later. 257 ref = findDefaultBranch(dst) 258 } 259 260 // We have to be on a branch to pull 261 if err := g.checkout(dst, ref); err != nil { 262 return err 263 } 264 265 if depth > 0 { 266 cmd = exec.Command("git", "pull", "--depth", strconv.Itoa(depth), "--ff-only") 267 } else { 268 cmd = exec.Command("git", "pull", "--ff-only") 269 } 270 271 cmd.Dir = dst 272 setupGitEnv(cmd, sshKeyFile) 273 return getRunCommand(cmd) 274 } 275 276 // fetchSubmodules downloads any configured submodules recursively. 277 func (g *gitGetter) fetchSubmodules(ctx context.Context, dst, sshKeyFile string, depth int) error { 278 args := []string{"submodule", "update", "--init", "--recursive"} 279 if depth > 0 { 280 args = append(args, "--depth", strconv.Itoa(depth)) 281 } 282 cmd := exec.CommandContext(ctx, "git", args...) 283 cmd.Dir = dst 284 setupGitEnv(cmd, sshKeyFile) 285 return getRunCommand(cmd) 286 } 287 288 // findDefaultBranch checks the repo's origin remote for its default branch 289 // (generally "master"). "master" is returned if an origin default branch 290 // can't be determined. 291 func findDefaultBranch(dst string) string { 292 var stdoutbuf bytes.Buffer 293 cmd := exec.Command("git", "branch", "-r", "--points-at", "refs/remotes/origin/HEAD") 294 cmd.Dir = dst 295 cmd.Stdout = &stdoutbuf 296 err := cmd.Run() 297 matches := defaultBranchRegexp.FindStringSubmatch(stdoutbuf.String()) 298 if err != nil || matches == nil { 299 return "master" 300 } 301 return matches[len(matches)-1] 302 } 303 304 // findRemoteDefaultBranch checks the remote repo's HEAD symref to return the remote repo's 305 // default branch. "master" is returned if no HEAD symref exists. 306 func findRemoteDefaultBranch(u *url.URL) string { 307 var stdoutbuf bytes.Buffer 308 cmd := exec.Command("git", "ls-remote", "--symref", u.String(), "HEAD") 309 cmd.Stdout = &stdoutbuf 310 err := cmd.Run() 311 matches := lsRemoteSymRefRegexp.FindStringSubmatch(stdoutbuf.String()) 312 if err != nil || matches == nil { 313 return "master" 314 } 315 return matches[len(matches)-1] 316 } 317 318 // setupGitEnv sets up the environment for the given command. This is used to 319 // pass configuration data to git and ssh and enables advanced cloning methods. 320 func setupGitEnv(cmd *exec.Cmd, sshKeyFile string) { 321 const gitSSHCommand = "GIT_SSH_COMMAND=" 322 var sshCmd []string 323 324 // If we have an existing GIT_SSH_COMMAND, we need to append our options. 325 // We will also remove our old entry to make sure the behavior is the same 326 // with versions of Go < 1.9. 327 env := os.Environ() 328 for i, v := range env { 329 if strings.HasPrefix(v, gitSSHCommand) && len(v) > len(gitSSHCommand) { 330 sshCmd = []string{v} 331 332 env[i], env[len(env)-1] = env[len(env)-1], env[i] 333 env = env[:len(env)-1] 334 break 335 } 336 } 337 338 if len(sshCmd) == 0 { 339 sshCmd = []string{gitSSHCommand + "ssh"} 340 } 341 342 if sshKeyFile != "" { 343 // We have an SSH key temp file configured, tell ssh about this. 344 if runtime.GOOS == "windows" { 345 sshKeyFile = strings.Replace(sshKeyFile, `\`, `/`, -1) 346 } 347 sshCmd = append(sshCmd, "-i", sshKeyFile) 348 } 349 350 env = append(env, strings.Join(sshCmd, " ")) 351 cmd.Env = env 352 } 353 354 // checkGitVersion is used to check the version of git installed on the system 355 // against a known minimum version. Returns an error if the installed version 356 // is older than the given minimum. 357 func checkGitVersion(min string) error { 358 want, err := version.NewVersion(min) 359 if err != nil { 360 return err 361 } 362 363 out, err := exec.Command("git", "version").Output() 364 if err != nil { 365 return err 366 } 367 368 fields := strings.Fields(string(out)) 369 if len(fields) < 3 { 370 return fmt.Errorf("Unexpected 'git version' output: %q", string(out)) 371 } 372 v := fields[2] 373 if runtime.GOOS == "windows" && strings.Contains(v, ".windows.") { 374 // on windows, git version will return for example: 375 // git version 2.20.1.windows.1 376 // Which does not follow the semantic versionning specs 377 // https://semver.org. We remove that part in order for 378 // go-version to not error. 379 v = v[:strings.Index(v, ".windows.")] 380 } 381 382 have, err := version.NewVersion(v) 383 if err != nil { 384 return err 385 } 386 387 if have.LessThan(want) { 388 return fmt.Errorf("Required git version = %s, have %s", want, have) 389 } 390 391 return nil 392 } 393 394 // getRunCommand is a helper that will run a command and capture the output 395 // in the case an error happens. 396 func getRunCommand(cmd *exec.Cmd) error { 397 var buf bytes.Buffer 398 cmd.Stdout = &buf 399 cmd.Stderr = &buf 400 err := cmd.Run() 401 if err == nil { 402 return nil 403 } 404 if exiterr, ok := err.(*exec.ExitError); ok { 405 // The program has exited with an exit code != 0 406 if status, ok := exiterr.Sys().(syscall.WaitStatus); ok { 407 return fmt.Errorf( 408 "%s exited with %d: %s", 409 cmd.Path, 410 status.ExitStatus(), 411 buf.String()) 412 } 413 } 414 415 return fmt.Errorf("error running %s: %s", cmd.Path, buf.String()) 416 }