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 }