diff --git a/pkg/git/client.go b/pkg/git/client.go index 3482b0a2d4..1a88dabf41 100644 --- a/pkg/git/client.go +++ b/pkg/git/client.go @@ -33,6 +33,14 @@ const ( defaultEmail = "pipecd.dev@gmail.com" ) +// basicAuthHeader builds the value of an HTTP "Authorization" header +// for basic authentication with the given username and password. +func basicAuthHeader(username, password string) string { + token := fmt.Sprintf("%s:%s", username, password) + encodedToken := base64.StdEncoding.EncodeToString([]byte(token)) + return fmt.Sprintf("Authorization: Basic %s", encodedToken) +} + // Client is a git client for cloning/fetching git repo. // It keeps a local cache for faster future cloning. type Client interface { @@ -148,9 +156,7 @@ func (c *client) Clone(ctx context.Context, repoID, remote, branch, destination _, err, _ := c.repoSingleFlights.Do(repoID, func() (interface{}, error) { authArgs := []string{} if c.username != "" && c.password != "" { - token := fmt.Sprintf("%s:%s", c.username, c.password) - encodedToken := base64.StdEncoding.EncodeToString([]byte(token)) - header := fmt.Sprintf("Authorization: Basic %s", encodedToken) + header := basicAuthHeader(c.username, c.password) authArgs = append(authArgs, "-c", fmt.Sprintf("http.extraHeader=%s", header)) } @@ -241,6 +247,14 @@ func (c *client) Clone(ctx context.Context, repoID, remote, branch, destination return nil, fmt.Errorf("failed to set user: %v", err) } } + // Persist the basic auth header into the checked-out repo's git config so that + // subsequent git operations (e.g. pull, fetch, push) run directly against the + // remote continue to be authenticated, not just this initial clone. + if c.username != "" && c.password != "" { + if err := r.setHTTPAuthHeader(ctx, basicAuthHeader(c.username, c.password)); err != nil { + return nil, fmt.Errorf("failed to set up http auth: %v", err) + } + } logger.Info("setting gc.autoDetach", zap.Bool("gc.autoDetach", c.gcAutoDetach)) if err := r.setGCAutoDetach(ctx, c.gcAutoDetach); err != nil { diff --git a/pkg/git/client_test.go b/pkg/git/client_test.go index 036c85d047..28f9a3ad54 100644 --- a/pkg/git/client_test.go +++ b/pkg/git/client_test.go @@ -20,6 +20,7 @@ import ( "os" "os/exec" "path/filepath" + "strings" "testing" "time" @@ -92,6 +93,43 @@ func TestClone(t *testing.T) { assert.Equal(t, "Added note.txt", commits12[0].Message) } +func TestClonePersistsHTTPAuthHeader(t *testing.T) { + faker, err := newFaker() + require.NoError(t, err) + defer faker.clean() + + c, err := NewClient(WithUserName("git-user"), WithPassword("git-pass")) + require.NoError(t, err) + require.NotNil(t, c) + defer c.Clean() + + err = faker.makeRepo("test-clone-org", "repo-auth") + require.NoError(t, err) + + ctx := context.Background() + destPath, err := os.MkdirTemp("", "repo-auth-dest") + require.NoError(t, err) + + repo, err := c.Clone(ctx, "repo-auth", filepath.Join(faker.dir, "test-clone-org/repo-auth"), "", destPath) + require.NoError(t, err) + require.NotNil(t, repo) + defer func() { + assert.NoError(t, repo.Clean()) + }() + + // Simulates what happens after the initial clone: a plain `git pull` run + // directly against the checked-out repo, as done by e.g. the event watcher. + // This must be able to find the credentials without any extra args, otherwise + // it fails with "could not read Username" for an HTTP(S) remote requiring auth. + cmd := exec.CommandContext(ctx, c.(*client).gitPath, "config", "--get", "http.extraHeader") + cmd.Dir = destPath + out, err := cmd.CombinedOutput() + require.NoError(t, err, string(out)) + + wantHeader := basicAuthHeader("git-user", "git-pass") + assert.Equal(t, wantHeader, strings.TrimSuffix(string(out), "\n")) +} + type faker struct { dir string gitPath string diff --git a/pkg/git/repo.go b/pkg/git/repo.go index 396501b1e6..4530fdf632 100644 --- a/pkg/git/repo.go +++ b/pkg/git/repo.go @@ -70,6 +70,11 @@ type repo struct { username string email string + // httpAuthHeader is the HTTP "Authorization" header value used to authenticate + // with the remote when it's accessed over HTTP(S). It's empty when username/password + // based authentication isn't configured. It's set in the `setHTTPAuthHeader` method. + httpAuthHeader string + dir string gitPath string remote string @@ -187,6 +192,14 @@ func (r *repo) CopyToModify(dest string) (Repo, error) { } } + // the cloned repo doesn't inherit custom config from the source one, + // so we need to set the http auth header again if it was configured. + if r.httpAuthHeader != "" { + if err := cloned.setHTTPAuthHeader(context.Background(), r.httpAuthHeader); err != nil { + return nil, fmt.Errorf("failed to set up http auth: %v", err) + } + } + // because we did a local cloning so set the remote url of origin if err := cloned.setRemote(context.Background(), r.remote); err != nil { return nil, err @@ -441,6 +454,18 @@ func (r *repo) setUser(ctx context.Context, username, email string) error { return nil } +// setHTTPAuthHeader persists the given HTTP "Authorization" header value into the +// repo's git config, so that it's automatically included by git in every future +// git command run against this repo directory, not just the command it was +// originally issued with via a one-off `-c` flag. +func (r *repo) setHTTPAuthHeader(ctx context.Context, header string) error { + if out, err := r.runGitCommand(ctx, "config", "http.extraHeader", header); err != nil { + return formatCommandError(err, out) + } + r.httpAuthHeader = header + return nil +} + func (r *repo) setRemote(ctx context.Context, remote string) error { out, err := r.runGitCommand(ctx, "remote", "set-url", "origin", remote) if err != nil { diff --git a/pkg/git/repo_test.go b/pkg/git/repo_test.go index d36cb0c3f9..b9445be404 100644 --- a/pkg/git/repo_test.go +++ b/pkg/git/repo_test.go @@ -243,6 +243,49 @@ func Test_setGCAutoDetach(t *testing.T) { assert.Equal(t, false, got) } +func Test_setHTTPAuthHeader(t *testing.T) { + getHTTPExtraHeader := func(ctx context.Context, repo *repo) (string, error) { + cmd := exec.CommandContext(ctx, repo.gitPath, "config", "--get", "http.extraHeader") + cmd.Dir = repo.dir + out, err := cmd.CombinedOutput() + if err != nil { + return "", err + } + return strings.TrimSuffix(string(out), "\n"), nil + } + + faker, err := newFaker() + require.NoError(t, err) + defer faker.clean() + + var ( + org = "test-repo-org" + repoName = "repo-set-http-auth-header" + ctx = context.Background() + ) + + err = faker.makeRepo(org, repoName) + require.NoError(t, err) + + r := &repo{ + dir: faker.repoDir(org, repoName), + gitPath: faker.gitPath, + } + + // Before being set, the repo has no http.extraHeader configured. + _, err = getHTTPExtraHeader(ctx, r) + require.Error(t, err) + + header := "Authorization: Basic dXNlcjpwYXNz" + err = r.setHTTPAuthHeader(ctx, header) + require.NoError(t, err) + assert.Equal(t, header, r.httpAuthHeader) + + got, err := getHTTPExtraHeader(ctx, r) + require.NoError(t, err) + assert.Equal(t, header, got) +} + func TestCopy(t *testing.T) { faker, err := newFaker() require.NoError(t, err) @@ -293,6 +336,10 @@ func TestCopyToModify(t *testing.T) { remote: faker.repoDir(org, repoName), // use the same directory as remote, it's not a real remote. it's strange but it's ok for testing. } + header := "Authorization: Basic dXNlcjpwYXNz" + err = r.setHTTPAuthHeader(ctx, header) + require.NoError(t, err) + commits, err := r.ListCommits(ctx, "") require.NoError(t, err) assert.Equal(t, 1, len(commits)) @@ -301,6 +348,15 @@ func TestCopyToModify(t *testing.T) { newRepo, err := r.CopyToModify(tmpDir) require.NoError(t, err) + // The http auth header must be propagated to the cloned repo too, + // since it's not carried over by a plain `git clone`. + assert.Equal(t, header, newRepo.(*repo).httpAuthHeader) + cmd := exec.CommandContext(ctx, r.gitPath, "config", "--get", "http.extraHeader") + cmd.Dir = tmpDir + out, err := cmd.CombinedOutput() + require.NoError(t, err) + assert.Equal(t, header, strings.TrimSuffix(string(out), "\n")) + // we can copy the repo to another directory multiple times tmpDir2 := filepath.Join(faker.dir, "tmp-repo2") newRepo2, err := r.CopyToModify(tmpDir2)