github.com/argoproj/argo-cd@v1.8.7/util/git/client.go (about)

     1  package git
     2  
     3  import (
     4  	"crypto/tls"
     5  	"fmt"
     6  	"math"
     7  	"net/http"
     8  	"net/url"
     9  	"os"
    10  	"os/exec"
    11  	"path/filepath"
    12  	"sort"
    13  	"strconv"
    14  	"strings"
    15  	"time"
    16  
    17  	log "github.com/sirupsen/logrus"
    18  	"golang.org/x/crypto/ssh"
    19  	"golang.org/x/crypto/ssh/knownhosts"
    20  	"gopkg.in/src-d/go-git.v4"
    21  	"gopkg.in/src-d/go-git.v4/config"
    22  	"gopkg.in/src-d/go-git.v4/plumbing"
    23  	"gopkg.in/src-d/go-git.v4/plumbing/transport"
    24  	githttp "gopkg.in/src-d/go-git.v4/plumbing/transport/http"
    25  	ssh2 "gopkg.in/src-d/go-git.v4/plumbing/transport/ssh"
    26  	"gopkg.in/src-d/go-git.v4/storage/memory"
    27  
    28  	"github.com/argoproj/argo-cd/common"
    29  	certutil "github.com/argoproj/argo-cd/util/cert"
    30  	executil "github.com/argoproj/argo-cd/util/exec"
    31  )
    32  
    33  type RevisionMetadata struct {
    34  	Author  string
    35  	Date    time.Time
    36  	Tags    []string
    37  	Message string
    38  }
    39  
    40  // this should match reposerver/repository/repository.proto/RefsList
    41  type Refs struct {
    42  	Branches []string
    43  	Tags     []string
    44  	// heads and remotes are also refs, but are not needed at this time.
    45  }
    46  
    47  // Client is a generic git client interface
    48  type Client interface {
    49  	Root() string
    50  	Init() error
    51  	Fetch() error
    52  	Checkout(revision string) error
    53  	LsRefs() (*Refs, error)
    54  	LsRemote(revision string) (string, error)
    55  	LsFiles(path string) ([]string, error)
    56  	LsLargeFiles() ([]string, error)
    57  	CommitSHA() (string, error)
    58  	RevisionMetadata(revision string) (*RevisionMetadata, error)
    59  	VerifyCommitSignature(string) (string, error)
    60  }
    61  
    62  // nativeGitClient implements Client interface using git CLI
    63  type nativeGitClient struct {
    64  	// URL of the repository
    65  	repoURL string
    66  	// Root path of repository
    67  	root string
    68  	// Authenticator credentials for private repositories
    69  	creds Creds
    70  	// Whether to connect insecurely to repository, e.g. don't verify certificate
    71  	insecure bool
    72  	// Whether the repository is LFS enabled
    73  	enableLfs bool
    74  }
    75  
    76  var (
    77  	maxAttemptsCount = 1
    78  )
    79  
    80  func init() {
    81  	if countStr := os.Getenv(common.EnvGitAttemptsCount); countStr != "" {
    82  		if cnt, err := strconv.Atoi(countStr); err != nil {
    83  			panic(fmt.Sprintf("Invalid value in %s env variable: %v", common.EnvGitAttemptsCount, err))
    84  		} else {
    85  			maxAttemptsCount = int(math.Max(float64(cnt), 1))
    86  		}
    87  	}
    88  }
    89  
    90  func NewClient(rawRepoURL string, creds Creds, insecure bool, enableLfs bool) (Client, error) {
    91  	root := filepath.Join(os.TempDir(), strings.Replace(NormalizeGitURL(rawRepoURL), "/", "_", -1))
    92  	if root == os.TempDir() {
    93  		return nil, fmt.Errorf("Repository '%s' cannot be initialized, because its root would be system temp at %s", rawRepoURL, root)
    94  	}
    95  	return NewClientExt(rawRepoURL, root, creds, insecure, enableLfs)
    96  }
    97  
    98  func NewClientExt(rawRepoURL string, root string, creds Creds, insecure bool, enableLfs bool) (Client, error) {
    99  	client := nativeGitClient{
   100  		repoURL:   rawRepoURL,
   101  		root:      root,
   102  		creds:     creds,
   103  		insecure:  insecure,
   104  		enableLfs: enableLfs,
   105  	}
   106  	return &client, nil
   107  }
   108  
   109  // Returns a HTTP client object suitable for go-git to use using the following
   110  // pattern:
   111  // - If insecure is true, always returns a client with certificate verification
   112  //   turned off.
   113  // - If one or more custom certificates are stored for the repository, returns
   114  //   a client with those certificates in the list of root CAs used to verify
   115  //   the server's certificate.
   116  // - Otherwise (and on non-fatal errors), a default HTTP client is returned.
   117  func GetRepoHTTPClient(repoURL string, insecure bool, creds Creds) *http.Client {
   118  	// Default HTTP client
   119  	var customHTTPClient = &http.Client{
   120  		// 15 second timeout
   121  		Timeout: 15 * time.Second,
   122  		// don't follow redirect
   123  		CheckRedirect: func(req *http.Request, via []*http.Request) error {
   124  			return http.ErrUseLastResponse
   125  		},
   126  	}
   127  
   128  	// Callback function to return any configured client certificate
   129  	// We never return err, but an empty cert instead.
   130  	clientCertFunc := func(req *tls.CertificateRequestInfo) (*tls.Certificate, error) {
   131  		var err error
   132  		cert := tls.Certificate{}
   133  
   134  		// If we aren't called with HTTPSCreds, then we just return an empty cert
   135  		httpsCreds, ok := creds.(HTTPSCreds)
   136  		if !ok {
   137  			return &cert, nil
   138  		}
   139  
   140  		// If the creds contain client certificate data, we return a TLS.Certificate
   141  		// populated with the cert and its key.
   142  		if httpsCreds.clientCertData != "" && httpsCreds.clientCertKey != "" {
   143  			cert, err = tls.X509KeyPair([]byte(httpsCreds.clientCertData), []byte(httpsCreds.clientCertKey))
   144  			if err != nil {
   145  				log.Errorf("Could not load Client Certificate: %v", err)
   146  				return &cert, nil
   147  			}
   148  		}
   149  
   150  		return &cert, nil
   151  	}
   152  
   153  	if insecure {
   154  		customHTTPClient.Transport = &http.Transport{
   155  			Proxy: http.ProxyFromEnvironment,
   156  			TLSClientConfig: &tls.Config{
   157  				InsecureSkipVerify:   true,
   158  				GetClientCertificate: clientCertFunc,
   159  			},
   160  			DisableKeepAlives: true,
   161  		}
   162  	} else {
   163  		parsedURL, err := url.Parse(repoURL)
   164  		if err != nil {
   165  			return customHTTPClient
   166  		}
   167  		serverCertificatePem, err := certutil.GetCertificateForConnect(parsedURL.Host)
   168  		if err != nil {
   169  			return customHTTPClient
   170  		} else if len(serverCertificatePem) > 0 {
   171  			certPool := certutil.GetCertPoolFromPEMData(serverCertificatePem)
   172  			customHTTPClient.Transport = &http.Transport{
   173  				Proxy: http.ProxyFromEnvironment,
   174  				TLSClientConfig: &tls.Config{
   175  					RootCAs:              certPool,
   176  					GetClientCertificate: clientCertFunc,
   177  				},
   178  				DisableKeepAlives: true,
   179  			}
   180  		} else {
   181  			// else no custom certificate stored.
   182  			customHTTPClient.Transport = &http.Transport{
   183  				Proxy: http.ProxyFromEnvironment,
   184  				TLSClientConfig: &tls.Config{
   185  					GetClientCertificate: clientCertFunc,
   186  				},
   187  				DisableKeepAlives: true,
   188  			}
   189  		}
   190  	}
   191  
   192  	return customHTTPClient
   193  }
   194  
   195  func newAuth(repoURL string, creds Creds) (transport.AuthMethod, error) {
   196  	switch creds := creds.(type) {
   197  	case SSHCreds:
   198  		var sshUser string
   199  		if isSSH, user := IsSSHURL(repoURL); isSSH {
   200  			sshUser = user
   201  		}
   202  		signer, err := ssh.ParsePrivateKey([]byte(creds.sshPrivateKey))
   203  		if err != nil {
   204  			return nil, err
   205  		}
   206  		auth := &ssh2.PublicKeys{User: sshUser, Signer: signer}
   207  		if creds.insecure {
   208  			auth.HostKeyCallback = ssh.InsecureIgnoreHostKey()
   209  		} else {
   210  			// Set up validation of SSH known hosts for using our ssh_known_hosts
   211  			// file.
   212  			auth.HostKeyCallback, err = knownhosts.New(certutil.GetSSHKnownHostsDataPath())
   213  			if err != nil {
   214  				log.Errorf("Could not set-up SSH known hosts callback: %v", err)
   215  			}
   216  		}
   217  		return auth, nil
   218  	case HTTPSCreds:
   219  		auth := githttp.BasicAuth{Username: creds.username, Password: creds.password}
   220  		return &auth, nil
   221  	}
   222  	return nil, nil
   223  }
   224  
   225  func (m *nativeGitClient) Root() string {
   226  	return m.root
   227  }
   228  
   229  // Init initializes a local git repository and sets the remote origin
   230  func (m *nativeGitClient) Init() error {
   231  	_, err := git.PlainOpen(m.root)
   232  	if err == nil {
   233  		return nil
   234  	}
   235  	if err != git.ErrRepositoryNotExists {
   236  		return err
   237  	}
   238  	log.Infof("Initializing %s to %s", m.repoURL, m.root)
   239  	_, err = executil.Run(exec.Command("rm", "-rf", m.root))
   240  	if err != nil {
   241  		return fmt.Errorf("unable to clean repo at %s: %v", m.root, err)
   242  	}
   243  	err = os.MkdirAll(m.root, 0755)
   244  	if err != nil {
   245  		return err
   246  	}
   247  	repo, err := git.PlainInit(m.root, false)
   248  	if err != nil {
   249  		return err
   250  	}
   251  	_, err = repo.CreateRemote(&config.RemoteConfig{
   252  		Name: git.DefaultRemoteName,
   253  		URLs: []string{m.repoURL},
   254  	})
   255  	return err
   256  }
   257  
   258  // Returns true if the repository is LFS enabled
   259  func (m *nativeGitClient) IsLFSEnabled() bool {
   260  	return m.enableLfs
   261  }
   262  
   263  // Fetch fetches latest updates from origin
   264  func (m *nativeGitClient) Fetch() error {
   265  	err := m.runCredentialedCmd("git", "fetch", "origin", "--tags", "--force")
   266  	// When we have LFS support enabled, check for large files and fetch them too.
   267  	if err == nil && m.IsLFSEnabled() {
   268  		largeFiles, err := m.LsLargeFiles()
   269  		if err == nil && len(largeFiles) > 0 {
   270  			err = m.runCredentialedCmd("git", "lfs", "fetch", "--all")
   271  			if err != nil {
   272  				return err
   273  			}
   274  		}
   275  	}
   276  	return err
   277  }
   278  
   279  // LsFiles lists the local working tree, including only files that are under source control
   280  func (m *nativeGitClient) LsFiles(path string) ([]string, error) {
   281  	out, err := m.runCmd("ls-files", "--full-name", "-z", "--", path)
   282  	if err != nil {
   283  		return nil, err
   284  	}
   285  	// remove last element, which is blank regardless of whether we're using nullbyte or newline
   286  	ss := strings.Split(out, "\000")
   287  	return ss[:len(ss)-1], nil
   288  }
   289  
   290  // LsLargeFiles lists all files that have references to LFS storage
   291  func (m *nativeGitClient) LsLargeFiles() ([]string, error) {
   292  	out, err := m.runCmd("lfs", "ls-files", "-n")
   293  	if err != nil {
   294  		return nil, err
   295  	}
   296  	ss := strings.Split(out, "\n")
   297  	return ss, nil
   298  }
   299  
   300  // Checkout checkout specified git sha
   301  func (m *nativeGitClient) Checkout(revision string) error {
   302  	if revision == "" || revision == "HEAD" {
   303  		revision = "origin/HEAD"
   304  	}
   305  	if _, err := m.runCmd("checkout", "--force", revision); err != nil {
   306  		return err
   307  	}
   308  	// We must populate LFS content by using lfs checkout, if we have at least
   309  	// one LFS reference in the current revision.
   310  	if m.IsLFSEnabled() {
   311  		if largeFiles, err := m.LsLargeFiles(); err == nil {
   312  			if len(largeFiles) > 0 {
   313  				if _, err := m.runCmd("lfs", "checkout"); err != nil {
   314  					return err
   315  				}
   316  			}
   317  		} else {
   318  			return err
   319  		}
   320  	}
   321  	if _, err := os.Stat(m.root + "/.gitmodules"); !os.IsNotExist(err) {
   322  		if submoduleEnabled := os.Getenv(common.EnvGitSubmoduleEnabled); submoduleEnabled != "false" {
   323  			if err := m.runCredentialedCmd("git", "submodule", "update", "--init", "--recursive"); err != nil {
   324  				return err
   325  			}
   326  		}
   327  	}
   328  	if _, err := m.runCmd("clean", "-fdx"); err != nil {
   329  		return err
   330  	}
   331  	return nil
   332  }
   333  
   334  func (m *nativeGitClient) getRefs() ([]*plumbing.Reference, error) {
   335  	repo, err := git.Init(memory.NewStorage(), nil)
   336  	if err != nil {
   337  		return nil, err
   338  	}
   339  	remote, err := repo.CreateRemote(&config.RemoteConfig{
   340  		Name: git.DefaultRemoteName,
   341  		URLs: []string{m.repoURL},
   342  	})
   343  	if err != nil {
   344  		return nil, err
   345  	}
   346  	auth, err := newAuth(m.repoURL, m.creds)
   347  	if err != nil {
   348  		return nil, err
   349  	}
   350  	return listRemote(remote, &git.ListOptions{Auth: auth}, m.insecure, m.creds)
   351  }
   352  
   353  func (m *nativeGitClient) LsRefs() (*Refs, error) {
   354  	refs, err := m.getRefs()
   355  
   356  	if err != nil {
   357  		return nil, err
   358  	}
   359  
   360  	sortedRefs := &Refs{
   361  		Branches: []string{},
   362  		Tags:     []string{},
   363  	}
   364  
   365  	for _, revision := range refs {
   366  		if revision.Name().IsBranch() {
   367  			sortedRefs.Branches = append(sortedRefs.Branches, revision.Name().Short())
   368  		} else if revision.Name().IsTag() {
   369  			sortedRefs.Tags = append(sortedRefs.Tags, revision.Name().Short())
   370  		}
   371  	}
   372  
   373  	log.Debugf("LsRefs resolved %d branches and %d tags on repository", len(sortedRefs.Branches), len(sortedRefs.Tags))
   374  
   375  	// Would prefer to sort by last modified date but that info does not appear to be available without resolving each ref
   376  	sort.Strings(sortedRefs.Branches)
   377  	sort.Strings(sortedRefs.Tags)
   378  
   379  	return sortedRefs, nil
   380  }
   381  
   382  // LsRemote resolves the commit SHA of a specific branch, tag, or HEAD. If the supplied revision
   383  // does not resolve, and "looks" like a 7+ hexadecimal commit SHA, it return the revision string.
   384  // Otherwise, it returns an error indicating that the revision could not be resolved. This method
   385  // runs with in-memory storage and is safe to run concurrently, or to be run without a git
   386  // repository locally cloned.
   387  func (m *nativeGitClient) LsRemote(revision string) (res string, err error) {
   388  	for attempt := 0; attempt < maxAttemptsCount; attempt++ {
   389  		if res, err = m.lsRemote(revision); err == nil {
   390  			return
   391  		}
   392  	}
   393  	return
   394  }
   395  
   396  func (m *nativeGitClient) lsRemote(revision string) (string, error) {
   397  	if IsCommitSHA(revision) {
   398  		return revision, nil
   399  	}
   400  
   401  	refs, err := m.getRefs()
   402  
   403  	if err != nil {
   404  		return "", err
   405  	}
   406  	if revision == "" {
   407  		revision = "HEAD"
   408  	}
   409  	// refToHash keeps a maps of remote refs to their hash
   410  	// (e.g. refs/heads/master -> a67038ae2e9cb9b9b16423702f98b41e36601001)
   411  	refToHash := make(map[string]string)
   412  	// refToResolve remembers ref name of the supplied revision if we determine the revision is a
   413  	// symbolic reference (like HEAD), in which case we will resolve it from the refToHash map
   414  	refToResolve := ""
   415  	for _, ref := range refs {
   416  		refName := ref.Name().String()
   417  		if refName != "HEAD" && !strings.HasPrefix(refName, "refs/heads/") && !strings.HasPrefix(refName, "refs/tags/") {
   418  			// ignore things like 'refs/pull/' 'refs/reviewable'
   419  			continue
   420  		}
   421  		hash := ref.Hash().String()
   422  		if ref.Type() == plumbing.HashReference {
   423  			refToHash[refName] = hash
   424  		}
   425  		//log.Debugf("%s\t%s", hash, refName)
   426  		if ref.Name().Short() == revision {
   427  			if ref.Type() == plumbing.HashReference {
   428  				log.Debugf("revision '%s' resolved to '%s'", revision, hash)
   429  				return hash, nil
   430  			}
   431  			if ref.Type() == plumbing.SymbolicReference {
   432  				refToResolve = ref.Target().String()
   433  			}
   434  		}
   435  	}
   436  	if refToResolve != "" {
   437  		// If refToResolve is non-empty, we are resolving symbolic reference (e.g. HEAD).
   438  		// It should exist in our refToHash map
   439  		if hash, ok := refToHash[refToResolve]; ok {
   440  			log.Debugf("symbolic reference '%s' (%s) resolved to '%s'", revision, refToResolve, hash)
   441  			return hash, nil
   442  		}
   443  	}
   444  	// We support the ability to use a truncated commit-SHA (e.g. first 7 characters of a SHA)
   445  	if IsTruncatedCommitSHA(revision) {
   446  		log.Debugf("revision '%s' assumed to be commit sha", revision)
   447  		return revision, nil
   448  	}
   449  	// If we get here, revision string had non hexadecimal characters (indicating its a branch, tag,
   450  	// or symbolic ref) and we were unable to resolve it to a commit SHA.
   451  	return "", fmt.Errorf("Unable to resolve '%s' to a commit SHA", revision)
   452  }
   453  
   454  // CommitSHA returns current commit sha from `git rev-parse HEAD`
   455  func (m *nativeGitClient) CommitSHA() (string, error) {
   456  	out, err := m.runCmd("rev-parse", "HEAD")
   457  	if err != nil {
   458  		return "", err
   459  	}
   460  	return strings.TrimSpace(out), nil
   461  }
   462  
   463  // returns the meta-data for the commit
   464  func (m *nativeGitClient) RevisionMetadata(revision string) (*RevisionMetadata, error) {
   465  	out, err := m.runCmd("show", "-s", "--format=%an <%ae>|%at|%B", revision)
   466  	if err != nil {
   467  		return nil, err
   468  	}
   469  	segments := strings.SplitN(out, "|", 3)
   470  	if len(segments) != 3 {
   471  		return nil, fmt.Errorf("expected 3 segments, got %v", segments)
   472  	}
   473  	author := segments[0]
   474  	authorDateUnixTimestamp, _ := strconv.ParseInt(segments[1], 10, 64)
   475  	message := strings.TrimSpace(segments[2])
   476  
   477  	out, err = m.runCmd("tag", "--points-at", revision)
   478  	if err != nil {
   479  		return nil, err
   480  	}
   481  	tags := strings.Fields(out)
   482  
   483  	return &RevisionMetadata{author, time.Unix(authorDateUnixTimestamp, 0), tags, message}, nil
   484  }
   485  
   486  // VerifyCommitSignature Runs verify-commit on a given revision and returns the output
   487  func (m *nativeGitClient) VerifyCommitSignature(revision string) (string, error) {
   488  	out, err := m.runGnuPGWrapper("git-verify-wrapper.sh", revision)
   489  	if err != nil {
   490  		return "", err
   491  	}
   492  	return out, nil
   493  }
   494  
   495  // runWrapper runs a custom command with all the semantics of running the Git client
   496  func (m *nativeGitClient) runGnuPGWrapper(wrapper string, args ...string) (string, error) {
   497  	cmd := exec.Command(wrapper, args...)
   498  	cmd.Env = append(cmd.Env, fmt.Sprintf("GNUPGHOME=%s", common.GetGnuPGHomePath()))
   499  	return m.runCmdOutput(cmd)
   500  }
   501  
   502  // runCmd is a convenience function to run a command in a given directory and return its output
   503  func (m *nativeGitClient) runCmd(args ...string) (string, error) {
   504  	cmd := exec.Command("git", args...)
   505  	return m.runCmdOutput(cmd)
   506  }
   507  
   508  // runCredentialedCmd is a convenience function to run a git command with username/password credentials
   509  func (m *nativeGitClient) runCredentialedCmd(command string, args ...string) error {
   510  	cmd := exec.Command(command, args...)
   511  	closer, environ, err := m.creds.Environ()
   512  	if err != nil {
   513  		return err
   514  	}
   515  	defer func() { _ = closer.Close() }()
   516  	cmd.Env = append(cmd.Env, environ...)
   517  	_, err = m.runCmdOutput(cmd)
   518  	return err
   519  }
   520  
   521  func (m *nativeGitClient) runCmdOutput(cmd *exec.Cmd) (string, error) {
   522  	cmd.Dir = m.root
   523  	cmd.Env = append(cmd.Env, os.Environ()...)
   524  	// Set $HOME to nowhere, so we can be execute Git regardless of any external
   525  	// authentication keys (e.g. in ~/.ssh) -- this is especially important for
   526  	// running tests on local machines and/or CircleCI.
   527  	cmd.Env = append(cmd.Env, "HOME=/dev/null")
   528  	// Skip LFS for most Git operations except when explicitly requested
   529  	cmd.Env = append(cmd.Env, "GIT_LFS_SKIP_SMUDGE=1")
   530  
   531  	// For HTTPS repositories, we need to consider insecure repositories as well
   532  	// as custom CA bundles from the cert database.
   533  	if IsHTTPSURL(m.repoURL) {
   534  		if m.insecure {
   535  			cmd.Env = append(cmd.Env, "GIT_SSL_NO_VERIFY=true")
   536  		} else {
   537  			parsedURL, err := url.Parse(m.repoURL)
   538  			// We don't fail if we cannot parse the URL, but log a warning in that
   539  			// case. And we execute the command in a verbatim way.
   540  			if err != nil {
   541  				log.Warnf("runCmdOutput: Could not parse repo URL '%s'", m.repoURL)
   542  			} else {
   543  				caPath, err := certutil.GetCertBundlePathForRepository(parsedURL.Host)
   544  				if err == nil && caPath != "" {
   545  					cmd.Env = append(cmd.Env, fmt.Sprintf("GIT_SSL_CAINFO=%s", caPath))
   546  				}
   547  			}
   548  		}
   549  	}
   550  	return executil.Run(cmd)
   551  }