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  }