github.com/argoproj/argo-cd/v3@v3.2.1/util/git/client.go (about) 1 package git 2 3 import ( 4 "bufio" 5 "crypto/tls" 6 "encoding/json" 7 "errors" 8 "fmt" 9 "math" 10 "net/http" 11 "net/mail" 12 "net/url" 13 "os" 14 "os/exec" 15 "path/filepath" 16 "regexp" 17 "sort" 18 "strconv" 19 "strings" 20 "syscall" 21 "time" 22 "unicode/utf8" 23 24 "github.com/bmatcuk/doublestar/v4" 25 "github.com/go-git/go-git/v5" 26 "github.com/go-git/go-git/v5/config" 27 "github.com/go-git/go-git/v5/plumbing" 28 "github.com/go-git/go-git/v5/plumbing/transport" 29 githttp "github.com/go-git/go-git/v5/plumbing/transport/http" 30 "github.com/go-git/go-git/v5/storage/memory" 31 "github.com/google/uuid" 32 log "github.com/sirupsen/logrus" 33 "golang.org/x/crypto/ssh" 34 "golang.org/x/crypto/ssh/knownhosts" 35 apierrors "k8s.io/apimachinery/pkg/api/errors" 36 utilnet "k8s.io/apimachinery/pkg/util/net" 37 38 "github.com/argoproj/argo-cd/v3/common" 39 certutil "github.com/argoproj/argo-cd/v3/util/cert" 40 "github.com/argoproj/argo-cd/v3/util/env" 41 executil "github.com/argoproj/argo-cd/v3/util/exec" 42 "github.com/argoproj/argo-cd/v3/util/proxy" 43 "github.com/argoproj/argo-cd/v3/util/versions" 44 ) 45 46 var ErrInvalidRepoURL = errors.New("repo URL is invalid") 47 48 // builtinGitConfig configuration contains statements that are needed 49 // for correct ArgoCD operation. These settings will override any 50 // user-provided configuration of same options. 51 var builtinGitConfig = map[string]string{ 52 "maintenance.autoDetach": "false", 53 "gc.autoDetach": "false", 54 } 55 56 // BuiltinGitConfigEnv contains builtin git configuration in the 57 // format acceptable by Git. 58 var BuiltinGitConfigEnv []string 59 60 // CommitMetadata contains metadata about a commit that is related in some way to another commit. 61 type CommitMetadata struct { 62 // Author is the author of the commit. 63 // Comes from the Argocd-reference-commit-author trailer. 64 Author mail.Address 65 // Date is the date of the commit, formatted as by `git show -s --format=%aI`. 66 // May be an empty string if the date is unknown. 67 // Comes from the Argocd-reference-commit-date trailer. 68 Date string 69 // Subject is the commit message subject, i.e. `git show -s --format=%s`. 70 // Comes from the Argocd-reference-commit-subject trailer. 71 Subject string 72 // Body is the commit message body, excluding the subject, i.e. `git show -s --format=%b`. 73 // Comes from the Argocd-reference-commit-body trailer. 74 Body string 75 // SHA is the commit hash. 76 // Comes from the Argocd-reference-commit-sha trailer. 77 SHA string 78 // RepoURL is the URL of the repository where the commit is located. 79 // Comes from the Argocd-reference-commit-repourl trailer. 80 // This value is not validated beyond confirming that it's a URL, and it should not be used to construct UI links 81 // unless it is properly validated and/or sanitized first. 82 RepoURL string 83 } 84 85 // RevisionReference contains a reference to a some information that is related in some way to another commit. For now, 86 // it supports only references to a commit. In the future, it may support other types of references. 87 type RevisionReference struct { 88 // Commit contains metadata about the commit that is related in some way to another commit. 89 Commit *CommitMetadata 90 } 91 92 type RevisionMetadata struct { 93 // Author is the author of the commit. Corresponds to the output of `git log -n 1 --pretty='format:%an <%ae>'`. 94 Author string 95 // Date is the date of the commit. Corresponds to the output of `git log -n 1 --pretty='format:%ad'`. 96 Date time.Time 97 Tags []string 98 // Message is the commit message. 99 Message string 100 // References contains metadata about information that is related in some way to this commit. This data comes from 101 // git commit trailers starting with "Argocd-reference-". We currently only support a single reference to a commit, 102 // but we return an array to allow for future expansion. 103 References []RevisionReference 104 } 105 106 // this should match reposerver/repository/repository.proto/RefsList 107 type Refs struct { 108 Branches []string 109 Tags []string 110 // heads and remotes are also refs, but are not needed at this time. 111 } 112 113 type gitRefCache interface { 114 SetGitReferences(repo string, references []*plumbing.Reference) error 115 GetOrLockGitReferences(repo string, lockId string, references *[]*plumbing.Reference) (string, error) 116 UnlockGitReferences(repo string, lockId string) error 117 } 118 119 // Client is a generic git client interface 120 type Client interface { 121 Root() string 122 Init() error 123 Fetch(revision string) error 124 Submodule() error 125 Checkout(revision string, submoduleEnabled bool) (string, error) 126 LsRefs() (*Refs, error) 127 LsRemote(revision string) (string, error) 128 LsFiles(path string, enableNewGitFileGlobbing bool) ([]string, error) 129 LsLargeFiles() ([]string, error) 130 CommitSHA() (string, error) 131 RevisionMetadata(revision string) (*RevisionMetadata, error) 132 VerifyCommitSignature(string) (string, error) 133 IsAnnotatedTag(string) bool 134 ChangedFiles(revision string, targetRevision string) ([]string, error) 135 IsRevisionPresent(revision string) bool 136 // SetAuthor sets the author name and email in the git configuration. 137 SetAuthor(name, email string) (string, error) 138 // CheckoutOrOrphan checks out the branch. If the branch does not exist, it creates an orphan branch. 139 CheckoutOrOrphan(branch string, submoduleEnabled bool) (string, error) 140 // CheckoutOrNew checks out the given branch. If the branch does not exist, it creates an empty branch based on 141 // the base branch. 142 CheckoutOrNew(branch, base string, submoduleEnabled bool) (string, error) 143 // RemoveContents removes all files from the given paths in the git repository. 144 RemoveContents(paths []string) (string, error) 145 // CommitAndPush commits and pushes changes to the target branch. 146 CommitAndPush(branch, message string) (string, error) 147 } 148 149 type EventHandlers struct { 150 OnLsRemote func(repo string) func() 151 OnFetch func(repo string) func() 152 OnPush func(repo string) func() 153 } 154 155 // nativeGitClient implements Client interface using git CLI 156 type nativeGitClient struct { 157 EventHandlers 158 159 // URL of the repository 160 repoURL string 161 // Root path of repository 162 root string 163 // Authenticator credentials for private repositories 164 creds Creds 165 // Whether to connect insecurely to repository, e.g. don't verify certificate 166 insecure bool 167 // Whether the repository is LFS enabled 168 enableLfs bool 169 // gitRefCache knows how to cache git refs 170 gitRefCache gitRefCache 171 // indicates if client allowed to load refs from cache 172 loadRefFromCache bool 173 // HTTP/HTTPS proxy used to access repository 174 proxy string 175 // list of targets that shouldn't use the proxy, applies only if the proxy is set 176 noProxy string 177 // git configuration environment variables 178 gitConfigEnv []string 179 } 180 181 type runOpts struct { 182 SkipErrorLogging bool 183 CaptureStderr bool 184 } 185 186 var ( 187 maxAttemptsCount = 1 188 maxRetryDuration time.Duration 189 retryDuration time.Duration 190 factor int64 191 ) 192 193 func init() { 194 if countStr := os.Getenv(common.EnvGitAttemptsCount); countStr != "" { 195 cnt, err := strconv.Atoi(countStr) 196 if err != nil { 197 panic(fmt.Sprintf("Invalid value in %s env variable: %v", common.EnvGitAttemptsCount, err)) 198 } 199 maxAttemptsCount = int(math.Max(float64(cnt), 1)) 200 } 201 202 maxRetryDuration = env.ParseDurationFromEnv(common.EnvGitRetryMaxDuration, common.DefaultGitRetryMaxDuration, 0, math.MaxInt64) 203 retryDuration = env.ParseDurationFromEnv(common.EnvGitRetryDuration, common.DefaultGitRetryDuration, 0, math.MaxInt64) 204 factor = env.ParseInt64FromEnv(common.EnvGitRetryFactor, common.DefaultGitRetryFactor, 0, math.MaxInt64) 205 206 BuiltinGitConfigEnv = append(BuiltinGitConfigEnv, fmt.Sprintf("GIT_CONFIG_COUNT=%d", len(builtinGitConfig))) 207 idx := 0 208 for k, v := range builtinGitConfig { 209 BuiltinGitConfigEnv = append(BuiltinGitConfigEnv, fmt.Sprintf("GIT_CONFIG_KEY_%d=%s", idx, k)) 210 BuiltinGitConfigEnv = append(BuiltinGitConfigEnv, fmt.Sprintf("GIT_CONFIG_VALUE_%d=%s", idx, v)) 211 idx++ 212 } 213 } 214 215 type ClientOpts func(c *nativeGitClient) 216 217 // WithCache sets git revisions cacher as well as specifies if client should tries to use cached resolved revision 218 func WithCache(cache gitRefCache, loadRefFromCache bool) ClientOpts { 219 return func(c *nativeGitClient) { 220 c.gitRefCache = cache 221 c.loadRefFromCache = loadRefFromCache 222 } 223 } 224 225 func WithBuiltinGitConfig(enable bool) ClientOpts { 226 return func(c *nativeGitClient) { 227 if enable { 228 c.gitConfigEnv = BuiltinGitConfigEnv 229 } else { 230 c.gitConfigEnv = nil 231 } 232 } 233 } 234 235 // WithEventHandlers sets the git client event handlers 236 func WithEventHandlers(handlers EventHandlers) ClientOpts { 237 return func(c *nativeGitClient) { 238 c.EventHandlers = handlers 239 } 240 } 241 242 func NewClient(rawRepoURL string, creds Creds, insecure bool, enableLfs bool, proxy string, noProxy string, opts ...ClientOpts) (Client, error) { 243 r := regexp.MustCompile(`([/:])`) 244 normalizedGitURL := NormalizeGitURL(rawRepoURL) 245 if normalizedGitURL == "" { 246 return nil, fmt.Errorf("repository %q cannot be initialized: %w", rawRepoURL, ErrInvalidRepoURL) 247 } 248 root := filepath.Join(os.TempDir(), r.ReplaceAllString(normalizedGitURL, "_")) 249 if root == os.TempDir() { 250 return nil, fmt.Errorf("repository %q cannot be initialized, because its root would be system temp at %s", rawRepoURL, root) 251 } 252 return NewClientExt(rawRepoURL, root, creds, insecure, enableLfs, proxy, noProxy, opts...) 253 } 254 255 func NewClientExt(rawRepoURL string, root string, creds Creds, insecure bool, enableLfs bool, proxy string, noProxy string, opts ...ClientOpts) (Client, error) { 256 client := &nativeGitClient{ 257 repoURL: rawRepoURL, 258 root: root, 259 creds: creds, 260 insecure: insecure, 261 enableLfs: enableLfs, 262 proxy: proxy, 263 noProxy: noProxy, 264 gitConfigEnv: BuiltinGitConfigEnv, 265 } 266 for i := range opts { 267 opts[i](client) 268 } 269 return client, nil 270 } 271 272 var gitClientTimeout = env.ParseDurationFromEnv("ARGOCD_GIT_REQUEST_TIMEOUT", 15*time.Second, 0, math.MaxInt64) 273 274 // Returns a HTTP client object suitable for go-git to use using the following 275 // pattern: 276 // - If insecure is true, always returns a client with certificate verification 277 // turned off. 278 // - If one or more custom certificates are stored for the repository, returns 279 // a client with those certificates in the list of root CAs used to verify 280 // the server's certificate. 281 // - Otherwise (and on non-fatal errors), a default HTTP client is returned. 282 func GetRepoHTTPClient(repoURL string, insecure bool, creds Creds, proxyURL string, noProxy string) *http.Client { 283 // Default HTTP client 284 customHTTPClient := &http.Client{ 285 // 15 second timeout by default 286 Timeout: gitClientTimeout, 287 // don't follow redirect 288 CheckRedirect: func(_ *http.Request, _ []*http.Request) error { 289 return http.ErrUseLastResponse 290 }, 291 } 292 293 proxyFunc := proxy.GetCallback(proxyURL, noProxy) 294 295 // Callback function to return any configured client certificate 296 // We never return err, but an empty cert instead. 297 clientCertFunc := func(_ *tls.CertificateRequestInfo) (*tls.Certificate, error) { 298 var err error 299 cert := tls.Certificate{} 300 301 // If we aren't called with GenericHTTPSCreds, then we just return an empty cert 302 httpsCreds, ok := creds.(GenericHTTPSCreds) 303 if !ok { 304 return &cert, nil 305 } 306 307 // If the creds contain client certificate data, we return a TLS.Certificate 308 // populated with the cert and its key. 309 if httpsCreds.HasClientCert() { 310 cert, err = tls.X509KeyPair([]byte(httpsCreds.GetClientCertData()), []byte(httpsCreds.GetClientCertKey())) 311 if err != nil { 312 log.Errorf("Could not load Client Certificate: %v", err) 313 return &cert, nil 314 } 315 } 316 317 return &cert, nil 318 } 319 transport := &http.Transport{ 320 Proxy: proxyFunc, 321 TLSClientConfig: &tls.Config{ 322 GetClientCertificate: clientCertFunc, 323 }, 324 DisableKeepAlives: true, 325 } 326 customHTTPClient.Transport = transport 327 if insecure { 328 transport.TLSClientConfig.InsecureSkipVerify = true 329 return customHTTPClient 330 } 331 parsedURL, err := url.Parse(repoURL) 332 if err != nil { 333 return customHTTPClient 334 } 335 serverCertificatePem, err := certutil.GetCertificateForConnect(parsedURL.Host) 336 if err != nil { 337 return customHTTPClient 338 } 339 if len(serverCertificatePem) > 0 { 340 certPool := certutil.GetCertPoolFromPEMData(serverCertificatePem) 341 transport.TLSClientConfig.RootCAs = certPool 342 } 343 return customHTTPClient 344 } 345 346 func newAuth(repoURL string, creds Creds) (transport.AuthMethod, error) { 347 switch creds := creds.(type) { 348 case SSHCreds: 349 var sshUser string 350 if isSSH, user := IsSSHURL(repoURL); isSSH { 351 sshUser = user 352 } 353 signer, err := ssh.ParsePrivateKey([]byte(creds.sshPrivateKey)) 354 if err != nil { 355 return nil, err 356 } 357 auth := &PublicKeysWithOptions{} 358 auth.User = sshUser 359 auth.Signer = signer 360 if creds.insecure { 361 auth.HostKeyCallback = ssh.InsecureIgnoreHostKey() 362 } else { 363 // Set up validation of SSH known hosts for using our ssh_known_hosts 364 // file. 365 auth.HostKeyCallback, err = knownhosts.New(certutil.GetSSHKnownHostsDataPath()) 366 if err != nil { 367 log.Errorf("Could not set-up SSH known hosts callback: %v", err) 368 } 369 } 370 return auth, nil 371 case HTTPSCreds: 372 if creds.bearerToken != "" { 373 return &githttp.TokenAuth{Token: creds.bearerToken}, nil 374 } 375 auth := githttp.BasicAuth{Username: creds.username, Password: creds.password} 376 if auth.Username == "" { 377 auth.Username = "x-access-token" 378 } 379 return &auth, nil 380 case GitHubAppCreds: 381 token, err := creds.getAccessToken() 382 if err != nil { 383 return nil, err 384 } 385 auth := githttp.BasicAuth{Username: "x-access-token", Password: token} 386 return &auth, nil 387 case GoogleCloudCreds: 388 username, err := creds.getUsername() 389 if err != nil { 390 return nil, fmt.Errorf("failed to get username from creds: %w", err) 391 } 392 token, err := creds.getAccessToken() 393 if err != nil { 394 return nil, fmt.Errorf("failed to get access token from creds: %w", err) 395 } 396 397 auth := githttp.BasicAuth{Username: username, Password: token} 398 return &auth, nil 399 case AzureWorkloadIdentityCreds: 400 token, err := creds.GetAzureDevOpsAccessToken() 401 if err != nil { 402 return nil, fmt.Errorf("failed to get access token from creds: %w", err) 403 } 404 405 auth := githttp.TokenAuth{Token: token} 406 return &auth, nil 407 } 408 409 return nil, nil 410 } 411 412 func (m *nativeGitClient) Root() string { 413 return m.root 414 } 415 416 // Init initializes a local git repository and sets the remote origin 417 func (m *nativeGitClient) Init() error { 418 _, err := git.PlainOpen(m.root) 419 if err == nil { 420 return nil 421 } 422 if !errors.Is(err, git.ErrRepositoryNotExists) { 423 return err 424 } 425 log.Infof("Initializing %s to %s", m.repoURL, m.root) 426 err = os.RemoveAll(m.root) 427 if err != nil { 428 return fmt.Errorf("unable to clean repo at %s: %w", m.root, err) 429 } 430 err = os.MkdirAll(m.root, 0o755) 431 if err != nil { 432 return err 433 } 434 repo, err := git.PlainInit(m.root, false) 435 if err != nil { 436 return err 437 } 438 _, err = repo.CreateRemote(&config.RemoteConfig{ 439 Name: git.DefaultRemoteName, 440 URLs: []string{m.repoURL}, 441 }) 442 return err 443 } 444 445 // IsLFSEnabled returns true if the repository is LFS enabled 446 func (m *nativeGitClient) IsLFSEnabled() bool { 447 return m.enableLfs 448 } 449 450 func (m *nativeGitClient) fetch(revision string) error { 451 var err error 452 if revision != "" { 453 err = m.runCredentialedCmd("fetch", "origin", revision, "--tags", "--force", "--prune") 454 } else { 455 err = m.runCredentialedCmd("fetch", "origin", "--tags", "--force", "--prune") 456 } 457 return err 458 } 459 460 // IsRevisionPresent checks to see if the given revision already exists locally. 461 func (m *nativeGitClient) IsRevisionPresent(revision string) bool { 462 if revision == "" { 463 return false 464 } 465 466 cmd := exec.Command("git", "cat-file", "-t", revision) 467 out, err := m.runCmdOutput(cmd, runOpts{SkipErrorLogging: true}) 468 if out == "commit" && err == nil { 469 return true 470 } 471 return false 472 } 473 474 // Fetch fetches latest updates from origin 475 func (m *nativeGitClient) Fetch(revision string) error { 476 if m.OnFetch != nil { 477 done := m.OnFetch(m.repoURL) 478 defer done() 479 } 480 481 err := m.fetch(revision) 482 483 // When we have LFS support enabled, check for large files and fetch them too. 484 if err == nil && m.IsLFSEnabled() { 485 largeFiles, err := m.LsLargeFiles() 486 if err == nil && len(largeFiles) > 0 { 487 err = m.runCredentialedCmd("lfs", "fetch", "--all") 488 if err != nil { 489 return err 490 } 491 } 492 } 493 494 return err 495 } 496 497 // LsFiles lists the local working tree, including only files that are under source control 498 func (m *nativeGitClient) LsFiles(path string, enableNewGitFileGlobbing bool) ([]string, error) { 499 if enableNewGitFileGlobbing { 500 // This is the new way with safer globbing 501 502 // evaluating the root path for symlinks 503 realRoot, err := filepath.EvalSymlinks(m.root) 504 if err != nil { 505 return nil, err 506 } 507 // searching for the pattern inside the root path 508 allFiles, err := doublestar.FilepathGlob(filepath.Join(realRoot, path)) 509 if err != nil { 510 return nil, err 511 } 512 var files []string 513 for _, file := range allFiles { 514 link, err := filepath.EvalSymlinks(file) 515 if err != nil { 516 return nil, err 517 } 518 absPath, err := filepath.Abs(link) 519 if err != nil { 520 return nil, err 521 } 522 523 if strings.HasPrefix(absPath, realRoot) { 524 // removing the repository root prefix from the file path 525 relativeFile, err := filepath.Rel(realRoot, file) 526 if err != nil { 527 return nil, err 528 } 529 files = append(files, relativeFile) 530 } else { 531 log.Warnf("Absolute path for %s is outside of repository, ignoring it", file) 532 } 533 } 534 return files, nil 535 } 536 // This is the old and default way 537 out, err := m.runCmd("ls-files", "--full-name", "-z", "--", path) 538 if err != nil { 539 return nil, err 540 } 541 // remove last element, which is blank regardless of whether we're using nullbyte or newline 542 ss := strings.Split(out, "\000") 543 return ss[:len(ss)-1], nil 544 } 545 546 // LsLargeFiles lists all files that have references to LFS storage 547 func (m *nativeGitClient) LsLargeFiles() ([]string, error) { 548 out, err := m.runCmd("lfs", "ls-files", "-n") 549 if err != nil { 550 return nil, err 551 } 552 ss := strings.Split(out, "\n") 553 return ss, nil 554 } 555 556 // Submodule embed other repositories into this repository 557 func (m *nativeGitClient) Submodule() error { 558 if err := m.runCredentialedCmd("submodule", "sync", "--recursive"); err != nil { 559 return err 560 } 561 return m.runCredentialedCmd("submodule", "update", "--init", "--recursive") 562 } 563 564 // Checkout checks out the specified revision 565 func (m *nativeGitClient) Checkout(revision string, submoduleEnabled bool) (string, error) { 566 if revision == "" || revision == "HEAD" { 567 revision = "origin/HEAD" 568 } 569 if out, err := m.runCmd("checkout", "--force", revision); err != nil { 570 return out, fmt.Errorf("failed to checkout %s: %w", revision, err) 571 } 572 // We must populate LFS content by using lfs checkout, if we have at least 573 // one LFS reference in the current revision. 574 if m.IsLFSEnabled() { 575 largeFiles, err := m.LsLargeFiles() 576 if err != nil { 577 return "", fmt.Errorf("failed to list LFS files: %w", err) 578 } 579 if len(largeFiles) > 0 { 580 if out, err := m.runCmd("lfs", "checkout"); err != nil { 581 return out, fmt.Errorf("failed to checkout LFS files: %w", err) 582 } 583 } 584 } 585 if _, err := os.Stat(m.root + "/.gitmodules"); !os.IsNotExist(err) { 586 if submoduleEnabled { 587 if err := m.Submodule(); err != nil { 588 return "", fmt.Errorf("failed to update submodules: %w", err) 589 } 590 } 591 } 592 // NOTE 593 // The double “f” in the arguments is not a typo: the first “f” tells 594 // `git clean` to delete untracked files and directories, and the second “f” 595 // tells it to clean untracked nested Git repositories (for example a 596 // submodule which has since been removed). 597 if out, err := m.runCmd("clean", "-ffdx"); err != nil { 598 return out, fmt.Errorf("failed to clean: %w", err) 599 } 600 return "", nil 601 } 602 603 func (m *nativeGitClient) getRefs() ([]*plumbing.Reference, error) { 604 myLockUUID, err := uuid.NewRandom() 605 myLockId := "" 606 if err != nil { 607 log.Debug("Error generating git references cache lock id: ", err) 608 } else { 609 myLockId = myLockUUID.String() 610 } 611 // Prevent an additional get call to cache if we know our state isn't stale 612 needsUnlock := true 613 if m.gitRefCache != nil && m.loadRefFromCache { 614 var res []*plumbing.Reference 615 foundLockId, err := m.gitRefCache.GetOrLockGitReferences(m.repoURL, myLockId, &res) 616 isLockOwner := myLockId == foundLockId 617 if !isLockOwner && err == nil { 618 // Valid value already in cache 619 return res, nil 620 } else if !isLockOwner && err != nil { 621 // Error getting value from cache 622 log.Debugf("Error getting git references from cache: %v", err) 623 return nil, err 624 } 625 // Defer a soft reset of the cache lock, if the value is set this call will be ignored 626 defer func() { 627 if needsUnlock { 628 err := m.gitRefCache.UnlockGitReferences(m.repoURL, myLockId) 629 if err != nil { 630 log.Debugf("Error unlocking git references from cache: %v", err) 631 } 632 } 633 }() 634 } 635 636 if m.OnLsRemote != nil { 637 done := m.OnLsRemote(m.repoURL) 638 defer done() 639 } 640 641 repo, err := git.Init(memory.NewStorage(), nil) 642 if err != nil { 643 return nil, err 644 } 645 remote, err := repo.CreateRemote(&config.RemoteConfig{ 646 Name: git.DefaultRemoteName, 647 URLs: []string{m.repoURL}, 648 }) 649 if err != nil { 650 return nil, err 651 } 652 auth, err := newAuth(m.repoURL, m.creds) 653 if err != nil { 654 return nil, err 655 } 656 res, err := listRemote(remote, &git.ListOptions{Auth: auth}, m.insecure, m.creds, m.proxy, m.noProxy) 657 if err == nil && m.gitRefCache != nil { 658 if err := m.gitRefCache.SetGitReferences(m.repoURL, res); err != nil { 659 log.Warnf("Failed to store git references to cache: %v", err) 660 } else { 661 // Since we successfully overwrote the lock with valid data, we don't need to unlock 662 needsUnlock = false 663 } 664 return res, nil 665 } 666 return res, err 667 } 668 669 func (m *nativeGitClient) LsRefs() (*Refs, error) { 670 refs, err := m.getRefs() 671 if err != nil { 672 return nil, err 673 } 674 675 sortedRefs := &Refs{ 676 Branches: []string{}, 677 Tags: []string{}, 678 } 679 680 for _, revision := range refs { 681 if revision.Name().IsBranch() { 682 sortedRefs.Branches = append(sortedRefs.Branches, revision.Name().Short()) 683 } else if revision.Name().IsTag() { 684 sortedRefs.Tags = append(sortedRefs.Tags, revision.Name().Short()) 685 } 686 } 687 688 log.Debugf("LsRefs resolved %d branches and %d tags on repository", len(sortedRefs.Branches), len(sortedRefs.Tags)) 689 690 // Would prefer to sort by last modified date but that info does not appear to be available without resolving each ref 691 sort.Strings(sortedRefs.Branches) 692 sort.Strings(sortedRefs.Tags) 693 694 return sortedRefs, nil 695 } 696 697 // LsRemote resolves the commit SHA of a specific branch, tag (with semantic versioning or not), 698 // or HEAD. If the supplied revision does not resolve, and "looks" like a 7+ hexadecimal commit SHA, 699 // it will return the revision string. Otherwise, it returns an error indicating that the revision could 700 // not be resolved. This method runs with in-memory storage and is safe to run concurrently, 701 // or to be run without a git repository locally cloned. 702 func (m *nativeGitClient) LsRemote(revision string) (res string, err error) { 703 for attempt := 0; attempt < maxAttemptsCount; attempt++ { 704 res, err = m.lsRemote(revision) 705 if err == nil { 706 return 707 } else if apierrors.IsInternalError(err) || apierrors.IsTimeout(err) || apierrors.IsServerTimeout(err) || 708 apierrors.IsTooManyRequests(err) || utilnet.IsProbableEOF(err) || utilnet.IsConnectionReset(err) { 709 // Formula: timeToWait = duration * factor^retry_number 710 // Note that timeToWait should equal to duration for the first retry attempt. 711 // When timeToWait is more than maxDuration retry should be performed at maxDuration. 712 timeToWait := float64(retryDuration) * (math.Pow(float64(factor), float64(attempt))) 713 if maxRetryDuration > 0 { 714 timeToWait = math.Min(float64(maxRetryDuration), timeToWait) 715 } 716 time.Sleep(time.Duration(timeToWait)) 717 } 718 } 719 return 720 } 721 722 func getGitTags(refs []*plumbing.Reference) []string { 723 var tags []string 724 for _, ref := range refs { 725 if ref.Name().IsTag() { 726 tags = append(tags, ref.Name().Short()) 727 } 728 } 729 return tags 730 } 731 732 func (m *nativeGitClient) lsRemote(revision string) (string, error) { 733 if IsCommitSHA(revision) { 734 return revision, nil 735 } 736 737 refs, err := m.getRefs() 738 if err != nil { 739 return "", fmt.Errorf("failed to list refs: %w", err) 740 } 741 742 if revision == "" { 743 revision = "HEAD" 744 } 745 746 maxV, err := versions.MaxVersion(revision, getGitTags(refs)) 747 if err == nil { 748 revision = maxV 749 } 750 751 // refToHash keeps a maps of remote refs to their hash 752 // (e.g. refs/heads/master -> a67038ae2e9cb9b9b16423702f98b41e36601001) 753 refToHash := make(map[string]string) 754 755 // refToResolve remembers ref name of the supplied revision if we determine the revision is a 756 // symbolic reference (like HEAD), in which case we will resolve it from the refToHash map 757 refToResolve := "" 758 759 for _, ref := range refs { 760 refName := ref.Name().String() 761 hash := ref.Hash().String() 762 if ref.Type() == plumbing.HashReference { 763 refToHash[refName] = hash 764 } 765 // log.Debugf("%s\t%s", hash, refName) 766 if ref.Name().Short() == revision || refName == revision { 767 if ref.Type() == plumbing.HashReference { 768 log.Debugf("revision '%s' resolved to '%s'", revision, hash) 769 return hash, nil 770 } 771 if ref.Type() == plumbing.SymbolicReference { 772 refToResolve = ref.Target().String() 773 } 774 } 775 } 776 777 if refToResolve != "" { 778 // If refToResolve is non-empty, we are resolving symbolic reference (e.g. HEAD). 779 // It should exist in our refToHash map 780 if hash, ok := refToHash[refToResolve]; ok { 781 log.Debugf("symbolic reference '%s' (%s) resolved to '%s'", revision, refToResolve, hash) 782 return hash, nil 783 } 784 } 785 786 // We support the ability to use a truncated commit-SHA (e.g. first 7 characters of a SHA) 787 if IsTruncatedCommitSHA(revision) { 788 log.Debugf("revision '%s' assumed to be commit sha", revision) 789 return revision, nil 790 } 791 792 // If we get here, revision string had non hexadecimal characters (indicating its a branch, tag, 793 // or symbolic ref) and we were unable to resolve it to a commit SHA. 794 return "", fmt.Errorf("unable to resolve '%s' to a commit SHA", revision) 795 } 796 797 // CommitSHA returns current commit sha from `git rev-parse HEAD` 798 func (m *nativeGitClient) CommitSHA() (string, error) { 799 out, err := m.runCmd("rev-parse", "HEAD") 800 if err != nil { 801 return "", err 802 } 803 return strings.TrimSpace(out), nil 804 } 805 806 // RevisionMetadata returns the meta-data for the commit 807 func (m *nativeGitClient) RevisionMetadata(revision string) (*RevisionMetadata, error) { 808 out, err := m.runCmd("show", "-s", "--format=%an <%ae>%n%at%n%B", revision) 809 if err != nil { 810 return nil, err 811 } 812 segments := strings.SplitN(out, "\n", 3) 813 if len(segments) != 3 { 814 return nil, fmt.Errorf("expected 3 segments, got %v", segments) 815 } 816 author := segments[0] 817 authorDateUnixTimestamp, _ := strconv.ParseInt(segments[1], 10, 64) 818 message := strings.TrimSpace(segments[2]) 819 820 cmd := exec.Command("git", "interpret-trailers", "--parse") 821 cmd.Stdin = strings.NewReader(message) 822 out, err = m.runCmdOutput(cmd, runOpts{}) 823 if err != nil { 824 return nil, fmt.Errorf("failed to interpret trailers for revision %q in repo %q: %w", revision, m.repoURL, err) 825 } 826 relatedCommits, _ := GetReferences(log.WithFields(log.Fields{"repo": m.repoURL, "revision": revision}), out) 827 828 out, err = m.runCmd("tag", "--points-at", revision) 829 if err != nil { 830 return nil, err 831 } 832 tags := strings.Fields(out) 833 834 return &RevisionMetadata{ 835 Author: author, 836 Date: time.Unix(authorDateUnixTimestamp, 0), 837 Tags: tags, 838 Message: message, 839 References: relatedCommits, 840 }, nil 841 } 842 843 func truncate(str string) string { 844 if utf8.RuneCountInString(str) > 100 { 845 return string([]rune(str)[0:97]) + "..." 846 } 847 return str 848 } 849 850 var shaRegex = regexp.MustCompile(`^[0-9a-f]{5,40}$`) 851 852 // GetReferences extracts related commit metadata from the commit message trailers. If referenced commit 853 // metadata is present, we return a slice containing a single metadata object. If no related commit metadata is found, 854 // we return a nil slice. 855 // 856 // If a trailer fails validation, we log an error and skip that trailer. We truncate the trailer values to 100 857 // characters to avoid excessively long log messages. 858 // 859 // We also return the commit message body with all valid Argocd-reference-commit-* trailers removed. 860 func GetReferences(logCtx *log.Entry, commitMessageBody string) ([]RevisionReference, string) { 861 unrelatedLines := strings.Builder{} 862 var relatedCommit CommitMetadata 863 scanner := bufio.NewScanner(strings.NewReader(commitMessageBody)) 864 for scanner.Scan() { 865 line := scanner.Text() 866 updated := updateCommitMetadata(logCtx, &relatedCommit, line) 867 if !updated { 868 unrelatedLines.WriteString(line + "\n") 869 } 870 } 871 var relatedCommits []RevisionReference 872 if relatedCommit != (CommitMetadata{}) { 873 relatedCommits = append(relatedCommits, RevisionReference{ 874 Commit: &relatedCommit, 875 }) 876 } 877 return relatedCommits, unrelatedLines.String() 878 } 879 880 // updateCommitMetadata checks if the line is a valid Argocd-reference-commit-* trailer. If so, it updates 881 // the relatedCommit object and returns true. If the line is not a valid trailer, it returns false. 882 func updateCommitMetadata(logCtx *log.Entry, relatedCommit *CommitMetadata, line string) bool { 883 if !strings.HasPrefix(line, "Argocd-reference-commit-") { 884 return false 885 } 886 parts := strings.SplitN(line, ": ", 2) 887 if len(parts) != 2 { 888 return false 889 } 890 trailerKey := parts[0] 891 trailerValue := parts[1] 892 switch trailerKey { 893 case "Argocd-reference-commit-repourl": 894 _, err := url.Parse(trailerValue) 895 if err != nil { 896 logCtx.Errorf("failed to parse repo URL %q: %v", truncate(trailerValue), err) 897 return false 898 } 899 relatedCommit.RepoURL = trailerValue 900 case "Argocd-reference-commit-author": 901 address, err := mail.ParseAddress(trailerValue) 902 if err != nil || address == nil { 903 logCtx.Errorf("failed to parse author email %q: %v", truncate(trailerValue), err) 904 return false 905 } 906 relatedCommit.Author = *address 907 case "Argocd-reference-commit-date": 908 // Validate that it's the correct date format. 909 t, err := time.Parse(time.RFC3339, trailerValue) 910 if err != nil { 911 logCtx.Errorf("failed to parse date %q with RFC3339 format: %v", truncate(trailerValue), err) 912 return false 913 } 914 relatedCommit.Date = t.Format(time.RFC3339) 915 case "Argocd-reference-commit-subject": 916 relatedCommit.Subject = trailerValue 917 case "Argocd-reference-commit-body": 918 body := "" 919 err := json.Unmarshal([]byte(trailerValue), &body) 920 if err != nil { 921 logCtx.Errorf("failed to parse body %q as JSON: %v", truncate(trailerValue), err) 922 return false 923 } 924 relatedCommit.Body = body 925 case "Argocd-reference-commit-sha": 926 if !shaRegex.MatchString(trailerValue) { 927 logCtx.Errorf("invalid commit SHA %q in trailer %s: must be a lowercase hex string 5-40 characters long", truncate(trailerValue), trailerKey) 928 return false 929 } 930 relatedCommit.SHA = trailerValue 931 default: 932 return false 933 } 934 return true 935 } 936 937 // VerifyCommitSignature Runs verify-commit on a given revision and returns the output 938 func (m *nativeGitClient) VerifyCommitSignature(revision string) (string, error) { 939 out, err := m.runGnuPGWrapper("git-verify-wrapper.sh", revision) 940 if err != nil { 941 log.Errorf("error verifying commit signature: %v", err) 942 return "", errors.New("permission denied") 943 } 944 return out, nil 945 } 946 947 // IsAnnotatedTag returns true if the revision points to an annotated tag 948 func (m *nativeGitClient) IsAnnotatedTag(revision string) bool { 949 cmd := exec.Command("git", "describe", "--exact-match", revision) 950 out, err := m.runCmdOutput(cmd, runOpts{SkipErrorLogging: true}) 951 if out != "" && err == nil { 952 return true 953 } 954 return false 955 } 956 957 // ChangedFiles returns a list of files changed between two revisions 958 func (m *nativeGitClient) ChangedFiles(revision string, targetRevision string) ([]string, error) { 959 if revision == targetRevision { 960 return []string{}, nil 961 } 962 963 if !IsCommitSHA(revision) || !IsCommitSHA(targetRevision) { 964 return []string{}, errors.New("invalid revision provided, must be SHA") 965 } 966 967 out, err := m.runCmd("diff", "--name-only", fmt.Sprintf("%s..%s", revision, targetRevision)) 968 if err != nil { 969 return nil, fmt.Errorf("failed to diff %s..%s: %w", revision, targetRevision, err) 970 } 971 972 if out == "" { 973 return []string{}, nil 974 } 975 976 files := strings.Split(out, "\n") 977 return files, nil 978 } 979 980 // config runs a git config command. 981 func (m *nativeGitClient) config(args ...string) (string, error) { 982 args = append([]string{"config"}, args...) 983 out, err := m.runCmd(args...) 984 if err != nil { 985 return out, fmt.Errorf("failed to run git config: %w", err) 986 } 987 return out, nil 988 } 989 990 // SetAuthor sets the author name and email in the git configuration. 991 func (m *nativeGitClient) SetAuthor(name, email string) (string, error) { 992 if name != "" { 993 out, err := m.config("--local", "user.name", name) 994 if err != nil { 995 return out, err 996 } 997 } 998 if email != "" { 999 out, err := m.config("--local", "user.email", email) 1000 if err != nil { 1001 return out, err 1002 } 1003 } 1004 return "", nil 1005 } 1006 1007 // CheckoutOrOrphan checks out the branch. If the branch does not exist, it creates an orphan branch. 1008 func (m *nativeGitClient) CheckoutOrOrphan(branch string, submoduleEnabled bool) (string, error) { 1009 out, err := m.Checkout(branch, submoduleEnabled) 1010 if err != nil { 1011 // If the branch doesn't exist, create it as an orphan branch. 1012 if !strings.Contains(err.Error(), "did not match any file(s) known to git") { 1013 return out, fmt.Errorf("failed to checkout branch: %w", err) 1014 } 1015 out, err = m.runCmd("switch", "--orphan", branch) 1016 if err != nil { 1017 return out, fmt.Errorf("failed to create orphan branch: %w", err) 1018 } 1019 1020 // Make an empty initial commit. 1021 out, err = m.runCmd("commit", "--allow-empty", "-m", "Initial commit") 1022 if err != nil { 1023 return out, fmt.Errorf("failed to commit initial commit: %w", err) 1024 } 1025 1026 // Push the commit. 1027 err = m.runCredentialedCmd("push", "origin", branch) 1028 if err != nil { 1029 return "", fmt.Errorf("failed to push to branch: %w", err) 1030 } 1031 } 1032 return "", nil 1033 } 1034 1035 // CheckoutOrNew checks out the given branch. If the branch does not exist, it creates an empty branch based on 1036 // the base branch. 1037 func (m *nativeGitClient) CheckoutOrNew(branch, base string, submoduleEnabled bool) (string, error) { 1038 out, err := m.Checkout(branch, submoduleEnabled) 1039 if err != nil { 1040 if !strings.Contains(err.Error(), "did not match any file(s) known to git") { 1041 return out, fmt.Errorf("failed to checkout branch: %w", err) 1042 } 1043 // If the branch does not exist, create any empty branch based on the sync branch 1044 // First, checkout the sync branch. 1045 out, err = m.Checkout(base, submoduleEnabled) 1046 if err != nil { 1047 return out, fmt.Errorf("failed to checkout sync branch: %w", err) 1048 } 1049 1050 out, err = m.runCmd("checkout", "-b", branch) 1051 if err != nil { 1052 return out, fmt.Errorf("failed to create branch: %w", err) 1053 } 1054 } 1055 return "", nil 1056 } 1057 1058 // RemoveContents removes all files from the path of git repository. 1059 func (m *nativeGitClient) RemoveContents(paths []string) (string, error) { 1060 if len(paths) == 0 { 1061 return "", nil 1062 } 1063 args := append([]string{"rm", "-r", "--ignore-unmatch", "--"}, paths...) 1064 out, err := m.runCmd(args...) 1065 if err != nil { 1066 return out, fmt.Errorf("failed to clear paths %v: %w", paths, err) 1067 } 1068 return "", nil 1069 } 1070 1071 // CommitAndPush commits and pushes changes to the target branch. 1072 func (m *nativeGitClient) CommitAndPush(branch, message string) (string, error) { 1073 out, err := m.runCmd("add", ".") 1074 if err != nil { 1075 return out, fmt.Errorf("failed to add files: %w", err) 1076 } 1077 1078 out, err = m.runCmd("commit", "-m", message) 1079 if err != nil { 1080 if strings.Contains(out, "nothing to commit, working tree clean") { 1081 return out, nil 1082 } 1083 return out, fmt.Errorf("failed to commit: %w", err) 1084 } 1085 1086 if m.OnPush != nil { 1087 done := m.OnPush(m.repoURL) 1088 defer done() 1089 } 1090 1091 err = m.runCredentialedCmd("push", "origin", branch) 1092 if err != nil { 1093 return "", fmt.Errorf("failed to push: %w", err) 1094 } 1095 1096 return "", nil 1097 } 1098 1099 // runWrapper runs a custom command with all the semantics of running the Git client 1100 func (m *nativeGitClient) runGnuPGWrapper(wrapper string, args ...string) (string, error) { 1101 cmd := exec.Command(wrapper, args...) 1102 cmd.Env = append(cmd.Env, "GNUPGHOME="+common.GetGnuPGHomePath(), "LANG=C") 1103 return m.runCmdOutput(cmd, runOpts{}) 1104 } 1105 1106 // runCmd is a convenience function to run a command in a given directory and return its output 1107 func (m *nativeGitClient) runCmd(args ...string) (string, error) { 1108 cmd := exec.Command("git", args...) 1109 return m.runCmdOutput(cmd, runOpts{}) 1110 } 1111 1112 // runCredentialedCmd is a convenience function to run a git command with username/password credentials 1113 func (m *nativeGitClient) runCredentialedCmd(args ...string) error { 1114 closer, environ, err := m.creds.Environ() 1115 if err != nil { 1116 return err 1117 } 1118 defer func() { _ = closer.Close() }() 1119 1120 // If a basic auth header is explicitly set, tell Git to send it to the 1121 // server to force use of basic auth instead of negotiating the auth scheme 1122 for _, e := range environ { 1123 if strings.HasPrefix(e, forceBasicAuthHeaderEnv+"=") { 1124 args = append([]string{"--config-env", "http.extraHeader=" + forceBasicAuthHeaderEnv}, args...) 1125 } else if strings.HasPrefix(e, bearerAuthHeaderEnv+"=") { 1126 args = append([]string{"--config-env", "http.extraHeader=" + bearerAuthHeaderEnv}, args...) 1127 } 1128 } 1129 1130 cmd := exec.Command("git", args...) 1131 cmd.Env = append(cmd.Env, environ...) 1132 _, err = m.runCmdOutput(cmd, runOpts{}) 1133 return err 1134 } 1135 1136 func (m *nativeGitClient) runCmdOutput(cmd *exec.Cmd, ropts runOpts) (string, error) { 1137 cmd.Dir = m.root 1138 cmd.Env = append(os.Environ(), cmd.Env...) 1139 // Set $HOME to nowhere, so we can execute Git regardless of any external 1140 // authentication keys (e.g. in ~/.ssh) -- this is especially important for 1141 // running tests on local machines and/or CircleCI. 1142 cmd.Env = append(cmd.Env, "HOME=/dev/null") 1143 // Skip LFS for most Git operations except when explicitly requested 1144 cmd.Env = append(cmd.Env, "GIT_LFS_SKIP_SMUDGE=1") 1145 // Disable Git terminal prompts in case we're running with a tty 1146 cmd.Env = append(cmd.Env, "GIT_TERMINAL_PROMPT=false") 1147 // Add Git configuration options that are essential for ArgoCD operation 1148 cmd.Env = append(cmd.Env, m.gitConfigEnv...) 1149 1150 // For HTTPS repositories, we need to consider insecure repositories as well 1151 // as custom CA bundles from the cert database. 1152 if IsHTTPSURL(m.repoURL) { 1153 if m.insecure { 1154 cmd.Env = append(cmd.Env, "GIT_SSL_NO_VERIFY=true") 1155 } else { 1156 parsedURL, err := url.Parse(m.repoURL) 1157 // We don't fail if we cannot parse the URL, but log a warning in that 1158 // case. And we execute the command in a verbatim way. 1159 if err != nil { 1160 log.Warnf("runCmdOutput: Could not parse repo URL '%s'", m.repoURL) 1161 } else { 1162 caPath, err := certutil.GetCertBundlePathForRepository(parsedURL.Host) 1163 if err == nil && caPath != "" { 1164 cmd.Env = append(cmd.Env, "GIT_SSL_CAINFO="+caPath) 1165 } 1166 } 1167 } 1168 } 1169 cmd.Env = proxy.UpsertEnv(cmd, m.proxy, m.noProxy) 1170 opts := executil.ExecRunOpts{ 1171 TimeoutBehavior: executil.TimeoutBehavior{ 1172 Signal: syscall.SIGTERM, 1173 ShouldWait: true, 1174 }, 1175 SkipErrorLogging: ropts.SkipErrorLogging, 1176 CaptureStderr: ropts.CaptureStderr, 1177 } 1178 return executil.RunWithExecRunOpts(cmd, opts) 1179 }