diff options
| author | jannfis <jann@mistrust.net> | 2024-05-27 19:38:30 +0000 |
|---|---|---|
| committer | jannfis <jann@mistrust.net> | 2024-05-27 19:40:33 +0000 |
| commit | 67f3d349f9fb60bb232cf6f0734a090bc48f5a4e (patch) | |
| tree | f8b3731f82b98982d4f26ee598635d7932efed19 /ext/git/client.go | |
| parent | eb1d8d30bb22cb82928df64fd5028fa978f8cb5b (diff) | |
chore(deps): Pull in Git client changes from Argo CD v2.11.2
Signed-off-by: jannfis <jann@mistrust.net>
Diffstat (limited to 'ext/git/client.go')
| -rw-r--r-- | ext/git/client.go | 328 |
1 files changed, 252 insertions, 76 deletions
diff --git a/ext/git/client.go b/ext/git/client.go index 11a9a75..ac9dc10 100644 --- a/ext/git/client.go +++ b/ext/git/client.go @@ -13,24 +13,33 @@ import ( "sort" "strconv" "strings" + "syscall" "time" + argoexec "github.com/argoproj/pkg/exec" + "github.com/bmatcuk/doublestar/v4" "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/config" "github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing/transport" githttp "github.com/go-git/go-git/v5/plumbing/transport/http" "github.com/go-git/go-git/v5/storage/memory" + "github.com/google/uuid" log "github.com/sirupsen/logrus" "golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh/knownhosts" + apierrors "k8s.io/apimachinery/pkg/api/errors" + utilnet "k8s.io/apimachinery/pkg/util/net" "github.com/argoproj/argo-cd/v2/common" certutil "github.com/argoproj/argo-cd/v2/util/cert" + "github.com/argoproj/argo-cd/v2/util/env" executil "github.com/argoproj/argo-cd/v2/util/exec" "github.com/argoproj/argo-cd/v2/util/proxy" ) +var ErrInvalidRepoURL = fmt.Errorf("repo URL is invalid") + type RevisionMetadata struct { Author string Date time.Time @@ -47,7 +56,8 @@ type Refs struct { type gitRefCache interface { SetGitReferences(repo string, references []*plumbing.Reference) error - GetGitReferences(repo string, references *[]*plumbing.Reference) error + GetOrLockGitReferences(repo string, lockId string, references *[]*plumbing.Reference) (string, error) + UnlockGitReferences(repo string, lockId string) error } // Client is a generic git client interface @@ -55,14 +65,17 @@ type Client interface { Root() string Init() error Fetch(revision string) error - Checkout(revision string) error + Submodule() error + Checkout(revision string, submoduleEnabled bool) error LsRefs() (*Refs, error) LsRemote(revision string) (string, error) - LsFiles(path string) ([]string, error) + LsFiles(path string, enableNewGitFileGlobbing bool) ([]string, error) LsLargeFiles() ([]string, error) CommitSHA() (string, error) RevisionMetadata(revision string) (*RevisionMetadata, error) VerifyCommitSignature(string) (string, error) + IsAnnotatedTag(string) bool + ChangedFiles(revision string, targetRevision string) ([]string, error) Commit(pathSpec string, opts *CommitOptions) error Branch(sourceBranch string, targetBranch string) error Push(remote string, branch string, force bool) error @@ -98,8 +111,16 @@ type nativeGitClient struct { proxy string } +type runOpts struct { + SkipErrorLogging bool + CaptureStderr bool +} + var ( maxAttemptsCount = 1 + maxRetryDuration time.Duration + retryDuration time.Duration + factor int64 ) func init() { @@ -110,6 +131,11 @@ func init() { maxAttemptsCount = int(math.Max(float64(cnt), 1)) } } + + maxRetryDuration = env.ParseDurationFromEnv(common.EnvGitRetryMaxDuration, common.DefaultGitRetryMaxDuration, 0, math.MaxInt64) + retryDuration = env.ParseDurationFromEnv(common.EnvGitRetryDuration, common.DefaultGitRetryDuration, 0, math.MaxInt64) + factor = env.ParseInt64FromEnv(common.EnvGitRetryFactor, common.DefaultGitRetryFactor, 0, math.MaxInt64) + } type ClientOpts func(c *nativeGitClient) @@ -131,9 +157,13 @@ func WithEventHandlers(handlers EventHandlers) ClientOpts { func NewClient(rawRepoURL string, creds Creds, insecure bool, enableLfs bool, proxy string, opts ...ClientOpts) (Client, error) { r := regexp.MustCompile("(/|:)") - root := filepath.Join(os.TempDir(), r.ReplaceAllString(NormalizeGitURL(rawRepoURL), "_")) + normalizedGitURL := NormalizeGitURL(rawRepoURL) + if normalizedGitURL == "" { + return nil, fmt.Errorf("repository %q cannot be initialized: %w", rawRepoURL, ErrInvalidRepoURL) + } + root := filepath.Join(os.TempDir(), r.ReplaceAllString(normalizedGitURL, "_")) if root == os.TempDir() { - return nil, fmt.Errorf("Repository '%s' cannot be initialized, because its root would be system temp at %s", rawRepoURL, root) + return nil, fmt.Errorf("repository %q cannot be initialized, because its root would be system temp at %s", rawRepoURL, root) } return NewClientExt(rawRepoURL, root, creds, insecure, enableLfs, proxy, opts...) } @@ -153,6 +183,10 @@ func NewClientExt(rawRepoURL string, root string, creds Creds, insecure bool, en return client, nil } +var ( + gitClientTimeout = env.ParseDurationFromEnv("ARGOCD_GIT_REQUEST_TIMEOUT", 15*time.Second, 0, math.MaxInt64) +) + // Returns a HTTP client object suitable for go-git to use using the following // pattern: // - If insecure is true, always returns a client with certificate verification @@ -164,8 +198,8 @@ func NewClientExt(rawRepoURL string, root string, creds Creds, insecure bool, en func GetRepoHTTPClient(repoURL string, insecure bool, creds Creds, proxyURL string) *http.Client { // Default HTTP client var customHTTPClient = &http.Client{ - // 15 second timeout - Timeout: 15 * time.Second, + // 15 second timeout by default + Timeout: gitClientTimeout, // don't follow redirect CheckRedirect: func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse @@ -198,46 +232,30 @@ func GetRepoHTTPClient(repoURL string, insecure bool, creds Creds, proxyURL stri return &cert, nil } - + transport := &http.Transport{ + Proxy: proxyFunc, + TLSClientConfig: &tls.Config{ + GetClientCertificate: clientCertFunc, + }, + DisableKeepAlives: true, + } + customHTTPClient.Transport = transport if insecure { - customHTTPClient.Transport = &http.Transport{ - Proxy: proxyFunc, - TLSClientConfig: &tls.Config{ - InsecureSkipVerify: true, - GetClientCertificate: clientCertFunc, - }, - DisableKeepAlives: true, - } - } else { - parsedURL, err := url.Parse(repoURL) - if err != nil { - return customHTTPClient - } - serverCertificatePem, err := certutil.GetCertificateForConnect(parsedURL.Host) - if err != nil { - return customHTTPClient - } else if len(serverCertificatePem) > 0 { - certPool := certutil.GetCertPoolFromPEMData(serverCertificatePem) - customHTTPClient.Transport = &http.Transport{ - Proxy: proxyFunc, - TLSClientConfig: &tls.Config{ - RootCAs: certPool, - GetClientCertificate: clientCertFunc, - }, - DisableKeepAlives: true, - } - } else { - // else no custom certificate stored. - customHTTPClient.Transport = &http.Transport{ - Proxy: proxyFunc, - TLSClientConfig: &tls.Config{ - GetClientCertificate: clientCertFunc, - }, - DisableKeepAlives: true, - } - } + transport.TLSClientConfig.InsecureSkipVerify = true + return customHTTPClient + } + parsedURL, err := url.Parse(repoURL) + if err != nil { + return customHTTPClient + } + serverCertificatePem, err := certutil.GetCertificateForConnect(parsedURL.Host) + if err != nil { + return customHTTPClient + } + if len(serverCertificatePem) > 0 { + certPool := certutil.GetCertPoolFromPEMData(serverCertificatePem) + transport.TLSClientConfig.RootCAs = certPool } - return customHTTPClient } @@ -268,6 +286,9 @@ func newAuth(repoURL string, creds Creds) (transport.AuthMethod, error) { return auth, nil case HTTPSCreds: auth := githttp.BasicAuth{Username: creds.username, Password: creds.password} + if auth.Username == "" { + auth.Username = "x-access-token" + } return &auth, nil case GitHubAppCreds: token, err := creds.getAccessToken() @@ -276,6 +297,18 @@ func newAuth(repoURL string, creds Creds) (transport.AuthMethod, error) { } auth := githttp.BasicAuth{Username: "x-access-token", Password: token} return &auth, nil + case GoogleCloudCreds: + username, err := creds.getUsername() + if err != nil { + return nil, fmt.Errorf("failed to get username from creds: %w", err) + } + token, err := creds.getAccessToken() + if err != nil { + return nil, fmt.Errorf("failed to get access token from creds: %w", err) + } + + auth := githttp.BasicAuth{Username: username, Password: token} + return &auth, nil } return nil, nil } @@ -294,7 +327,7 @@ func (m *nativeGitClient) Init() error { return err } log.Infof("Initializing %s to %s", m.repoURL, m.root) - _, err = executil.Run(exec.Command("rm", "-rf", m.root)) + err = os.RemoveAll(m.root) if err != nil { return fmt.Errorf("unable to clean repo at %s: %v", m.root, err) } @@ -318,6 +351,16 @@ func (m *nativeGitClient) IsLFSEnabled() bool { return m.enableLfs } +func (m *nativeGitClient) fetch(revision string) error { + var err error + if revision != "" { + err = m.runCredentialedCmd("fetch", "origin", revision, "--tags", "--force", "--prune") + } else { + err = m.runCredentialedCmd("fetch", "origin", "--tags", "--force", "--prune") + } + return err +} + // Fetch fetches latest updates from origin func (m *nativeGitClient) Fetch(revision string) error { if m.OnFetch != nil { @@ -325,34 +368,61 @@ func (m *nativeGitClient) Fetch(revision string) error { defer done() } - var err error - if revision != "" { - err = m.runCredentialedCmd("git", "fetch", "origin", revision, "--tags", "--force") - } else { - err = m.runCredentialedCmd("git", "fetch", "origin", "--tags", "--force") - } + err := m.fetch(revision) + // When we have LFS support enabled, check for large files and fetch them too. if err == nil && m.IsLFSEnabled() { largeFiles, err := m.LsLargeFiles() if err == nil && len(largeFiles) > 0 { - err = m.runCredentialedCmd("git", "lfs", "fetch", "--all") + err = m.runCredentialedCmd("lfs", "fetch", "--all") if err != nil { return err } } } + return err } // LsFiles lists the local working tree, including only files that are under source control -func (m *nativeGitClient) LsFiles(path string) ([]string, error) { - out, err := m.runCmd("ls-files", "--full-name", "-z", "--", path) - if err != nil { - return nil, err +func (m *nativeGitClient) LsFiles(path string, enableNewGitFileGlobbing bool) ([]string, error) { + if enableNewGitFileGlobbing { + // This is the new way with safer globbing + err := os.Chdir(m.root) + if err != nil { + return nil, err + } + all_files, err := doublestar.FilepathGlob(path) + if err != nil { + return nil, err + } + var files []string + for _, file := range all_files { + link, err := filepath.EvalSymlinks(file) + if err != nil { + return nil, err + } + absPath, err := filepath.Abs(link) + if err != nil { + return nil, err + } + if strings.HasPrefix(absPath, m.root) { + files = append(files, file) + } else { + log.Warnf("Absolute path for %s is outside of repository, removing it", file) + } + } + return files, nil + } else { + // This is the old and default way + out, err := m.runCmd("ls-files", "--full-name", "-z", "--", path) + if err != nil { + return nil, err + } + // remove last element, which is blank regardless of whether we're using nullbyte or newline + ss := strings.Split(out, "\000") + return ss[:len(ss)-1], nil } - // remove last element, which is blank regardless of whether we're using nullbyte or newline - ss := strings.Split(out, "\000") - return ss[:len(ss)-1], nil } // LsLargeFiles lists all files that have references to LFS storage @@ -365,8 +435,19 @@ func (m *nativeGitClient) LsLargeFiles() ([]string, error) { return ss, nil } +// Submodule embed other repositories into this repository +func (m *nativeGitClient) Submodule() error { + if err := m.runCredentialedCmd("submodule", "sync", "--recursive"); err != nil { + return err + } + if err := m.runCredentialedCmd("submodule", "update", "--init", "--recursive"); err != nil { + return err + } + return nil +} + // Checkout checkout specified revision -func (m *nativeGitClient) Checkout(revision string) error { +func (m *nativeGitClient) Checkout(revision string, submoduleEnabled bool) error { if revision == "" || revision == "HEAD" { revision = "origin/HEAD" } @@ -387,24 +468,54 @@ func (m *nativeGitClient) Checkout(revision string) error { } } if _, err := os.Stat(m.root + "/.gitmodules"); !os.IsNotExist(err) { - if submoduleEnabled := os.Getenv(common.EnvGitSubmoduleEnabled); submoduleEnabled != "false" { - if err := m.runCredentialedCmd("git", "submodule", "update", "--init", "--recursive"); err != nil { + if submoduleEnabled { + if err := m.Submodule(); err != nil { return err } } } - if _, err := m.runCmd("clean", "-fdx"); err != nil { + // NOTE + // The double “f” in the arguments is not a typo: the first “f” tells + // `git clean` to delete untracked files and directories, and the second “f” + // tells it to clean untractked nested Git repositories (for example a + // submodule which has since been removed). + if _, err := m.runCmd("clean", "-ffdx"); err != nil { return err } return nil } func (m *nativeGitClient) getRefs() ([]*plumbing.Reference, error) { + myLockUUID, err := uuid.NewRandom() + myLockId := "" + if err != nil { + log.Debug("Error generating git references cache lock id: ", err) + } else { + myLockId = myLockUUID.String() + } + // Prevent an additional get call to cache if we know our state isn't stale + needsUnlock := true if m.gitRefCache != nil && m.loadRefFromCache { var res []*plumbing.Reference - if m.gitRefCache.GetGitReferences(m.repoURL, &res) == nil { + foundLockId, err := m.gitRefCache.GetOrLockGitReferences(m.repoURL, myLockId, &res) + isLockOwner := myLockId == foundLockId + if !isLockOwner && err == nil { + // Valid value already in cache return res, nil + } else if !isLockOwner && err != nil { + // Error getting value from cache + log.Debugf("Error getting git references from cache: %v", err) + return nil, err } + // Defer a soft reset of the cache lock, if the value is set this call will be ignored + defer func() { + if needsUnlock { + err := m.gitRefCache.UnlockGitReferences(m.repoURL, myLockId) + if err != nil { + log.Debugf("Error unlocking git references from cache: %v", err) + } + } + }() } if m.OnLsRemote != nil { @@ -431,6 +542,9 @@ func (m *nativeGitClient) getRefs() ([]*plumbing.Reference, error) { if err == nil && m.gitRefCache != nil { if err := m.gitRefCache.SetGitReferences(m.repoURL, res); err != nil { log.Warnf("Failed to store git references to cache: %v", err) + } else { + // Since we successfully overwrote the lock with valid data, we don't need to unlock + needsUnlock = false } return res, nil } @@ -473,8 +587,19 @@ func (m *nativeGitClient) LsRefs() (*Refs, error) { // repository locally cloned. func (m *nativeGitClient) LsRemote(revision string) (res string, err error) { for attempt := 0; attempt < maxAttemptsCount; attempt++ { - if res, err = m.lsRemote(revision); err == nil { + res, err = m.lsRemote(revision) + if err == nil { return + } else if apierrors.IsInternalError(err) || apierrors.IsTimeout(err) || apierrors.IsServerTimeout(err) || + apierrors.IsTooManyRequests(err) || utilnet.IsProbableEOF(err) || utilnet.IsConnectionReset(err) { + // Formula: timeToWait = duration * factor^retry_number + // Note that timeToWait should equal to duration for the first retry attempt. + // When timeToWait is more than maxDuration retry should be performed at maxDuration. + timeToWait := float64(retryDuration) * (math.Pow(float64(factor), float64(attempt))) + if maxRetryDuration > 0 { + timeToWait = math.Min(float64(maxRetryDuration), timeToWait) + } + time.Sleep(time.Duration(timeToWait)) } } return @@ -575,42 +700,87 @@ func (m *nativeGitClient) VerifyCommitSignature(revision string) (string, error) return out, nil } +// IsAnnotatedTag returns true if the revision points to an annotated tag +func (m *nativeGitClient) IsAnnotatedTag(revision string) bool { + cmd := exec.Command("git", "describe", "--exact-match", revision) + out, err := m.runCmdOutput(cmd, runOpts{SkipErrorLogging: true}) + if out != "" && err == nil { + return true + } else { + return false + } +} + +// returns the meta-data for the commit +func (m *nativeGitClient) ChangedFiles(revision string, targetRevision string) ([]string, error) { + if revision == targetRevision { + return []string{}, nil + } + + if !IsCommitSHA(revision) || !IsCommitSHA(targetRevision) { + return []string{}, fmt.Errorf("invalid revision provided, must be SHA") + } + + out, err := m.runCmd("diff", "--name-only", fmt.Sprintf("%s..%s", revision, targetRevision)) + if err != nil { + return nil, fmt.Errorf("failed to diff %s..%s: %w", revision, targetRevision, err) + } + + if out == "" { + return []string{}, nil + } + + files := strings.Split(out, "\n") + return files, nil +} + // runWrapper runs a custom command with all the semantics of running the Git client func (m *nativeGitClient) runGnuPGWrapper(wrapper string, args ...string) (string, error) { cmd := exec.Command(wrapper, args...) cmd.Env = append(cmd.Env, fmt.Sprintf("GNUPGHOME=%s", common.GetGnuPGHomePath()), "LANG=C") - return m.runCmdOutput(cmd) + return m.runCmdOutput(cmd, runOpts{}) } // runCmd is a convenience function to run a command in a given directory and return its output func (m *nativeGitClient) runCmd(args ...string) (string, error) { cmd := exec.Command("git", args...) - return m.runCmdOutput(cmd) + return m.runCmdOutput(cmd, runOpts{}) } // runCredentialedCmd is a convenience function to run a git command with username/password credentials // nolint:unparam -func (m *nativeGitClient) runCredentialedCmd(command string, args ...string) error { - cmd := exec.Command(command, args...) +func (m *nativeGitClient) runCredentialedCmd(args ...string) error { closer, environ, err := m.creds.Environ() if err != nil { return err } defer func() { _ = closer.Close() }() + + // If a basic auth header is explicitly set, tell Git to send it to the + // server to force use of basic auth instead of negotiating the auth scheme + for _, e := range environ { + if strings.HasPrefix(e, fmt.Sprintf("%s=", forceBasicAuthHeaderEnv)) { + args = append([]string{"--config-env", fmt.Sprintf("http.extraHeader=%s", forceBasicAuthHeaderEnv)}, args...) + } + } + + cmd := exec.Command("git", args...) cmd.Env = append(cmd.Env, environ...) - _, err = m.runCmdOutput(cmd) + _, err = m.runCmdOutput(cmd, runOpts{}) return err } -func (m *nativeGitClient) runCmdOutput(cmd *exec.Cmd) (string, error) { +func (m *nativeGitClient) runCmdOutput(cmd *exec.Cmd, ropts runOpts) (string, error) { cmd.Dir = m.root - cmd.Env = append(cmd.Env, os.Environ()...) + cmd.Env = append(os.Environ(), cmd.Env...) // Set $HOME to nowhere, so we can be execute Git regardless of any external // authentication keys (e.g. in ~/.ssh) -- this is especially important for // running tests on local machines and/or CircleCI. cmd.Env = append(cmd.Env, "HOME=/dev/null") // Skip LFS for most Git operations except when explicitly requested cmd.Env = append(cmd.Env, "GIT_LFS_SKIP_SMUDGE=1") + // Disable Git terminal prompts in case we're running with a tty + cmd.Env = append(cmd.Env, "GIT_TERMINAL_PROMPT=false") // For HTTPS repositories, we need to consider insecure repositories as well // as custom CA bundles from the cert database. @@ -631,8 +801,14 @@ func (m *nativeGitClient) runCmdOutput(cmd *exec.Cmd) (string, error) { } } } - cmd.Env = proxy.UpsertEnv(cmd, m.proxy) - - return executil.Run(cmd) + opts := executil.ExecRunOpts{ + TimeoutBehavior: argoexec.TimeoutBehavior{ + Signal: syscall.SIGTERM, + ShouldWait: true, + }, + SkipErrorLogging: ropts.SkipErrorLogging, + CaptureStderr: ropts.CaptureStderr, + } + return executil.RunWithExecRunOpts(cmd, opts) } |
