1
0
mirror of https://github.com/goreleaser/goreleaser.git synced 2025-01-10 03:47:03 +02:00
goreleaser/internal/client/git.go
German Lashevich ec7106fdea
feat(git): retry git clone on retriable error (#4725)
This PR adds retry logic to the process of cloning a git repository.
Currently, it retries only if the output of the git clone command
contains the string `Connection reset`.
Probably, there are more cases where retry is reasonable, but I'm not
sure what they are.

The number of retries is hardcoded to 10 with increasing delay between
retries — in the same way as it is done in #4265, which served me as an
example.

The initial use case is described in #4724.
2024-03-26 23:41:25 -03:00

240 lines
6.4 KiB
Go

package client
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/caarlos0/log"
"github.com/charmbracelet/x/exp/ordered"
"github.com/goreleaser/goreleaser/internal/git"
"github.com/goreleaser/goreleaser/internal/pipe"
"github.com/goreleaser/goreleaser/internal/tmpl"
"github.com/goreleaser/goreleaser/pkg/config"
"github.com/goreleaser/goreleaser/pkg/context"
"golang.org/x/crypto/ssh"
)
var gil sync.Mutex
// DefaulGitSSHCommand used for git over SSH.
const DefaulGitSSHCommand = `ssh -i "{{ .KeyPath }}" -o StrictHostKeyChecking=accept-new -F /dev/null`
type gitClient struct {
branch string
}
// NewGitUploadClient
func NewGitUploadClient(branch string) FilesCreator {
return &gitClient{
branch: branch,
}
}
// CreateFiles implements FilesCreator.
func (g *gitClient) CreateFiles(
ctx *context.Context,
commitAuthor config.CommitAuthor,
repo Repo,
message string,
files []RepoFile,
) (err error) {
gil.Lock()
defer gil.Unlock()
url, err := tmpl.New(ctx).Apply(repo.GitURL)
if err != nil {
return fmt.Errorf("git: failed to template git url: %w", err)
}
if url == "" {
return pipe.Skip("url is empty")
}
repo.Name = ordered.First(repo.Name, nameFromURL(url))
key, err := tmpl.New(ctx).Apply(repo.PrivateKey)
if err != nil {
return fmt.Errorf("git: failed to template private key: %w", err)
}
key, err = keyPath(key)
if err != nil {
return err
}
sshcmd, err := tmpl.New(ctx).WithExtraFields(tmpl.Fields{
"KeyPath": key,
}).Apply(ordered.First(repo.GitSSHCommand, DefaulGitSSHCommand))
if err != nil {
return fmt.Errorf("git: failed to template ssh command: %w", err)
}
parent := filepath.Join(ctx.Config.Dist, "git")
name := repo.Name + "-" + g.branch
cwd := filepath.Join(parent, name)
env := []string{fmt.Sprintf("GIT_SSH_COMMAND=%s", sshcmd)}
if _, err := os.Stat(cwd); errors.Is(err, os.ErrNotExist) {
log.Infof("cloning %s %s", name, cwd)
if err := os.MkdirAll(parent, 0o755); err != nil {
return fmt.Errorf("git: failed to create parent: %w", err)
}
if err := cloneRepoWithRetries(ctx, parent, url, name, env); err != nil {
return err
}
if err := runGitCmds(ctx, cwd, env, [][]string{
{"config", "--local", "user.name", commitAuthor.Name},
{"config", "--local", "user.email", commitAuthor.Email},
{"config", "--local", "commit.gpgSign", "false"},
{"config", "--local", "init.defaultBranch", ordered.First(g.branch, "master")},
}); err != nil {
return fmt.Errorf("git: failed to setup local repository: %w", err)
}
if g.branch != "" {
if err := runGitCmds(ctx, cwd, env, [][]string{
{"checkout", g.branch},
}); err != nil {
if err := runGitCmds(ctx, cwd, env, [][]string{
{"checkout", "-b", g.branch},
}); err != nil {
return fmt.Errorf("git: could not checkout branch %s: %w", g.branch, err)
}
}
}
}
for _, file := range files {
location := filepath.Join(cwd, file.Path)
log.WithField("path", location).Info("writing")
if err := os.MkdirAll(filepath.Dir(location), 0o755); err != nil {
return fmt.Errorf("failed to create parent dirs for %s: %w", file.Path, err)
}
if err := os.WriteFile(location, file.Content, 0o644); err != nil {
return fmt.Errorf("failed to write %s: %w", file.Path, err)
}
log.
WithField("repository", url).
WithField("name", repo.Name).
WithField("file", file.Path).
Info("pushing")
}
if err := runGitCmds(ctx, cwd, env, [][]string{
{"add", "-A", "."},
{"commit", "-m", message},
{"push", "origin", "HEAD"},
}); err != nil {
return fmt.Errorf("git: failed to push %q (%q): %w", repo.Name, url, err)
}
return nil
}
// CreateFile implements FileCreator.
func (g *gitClient) CreateFile(ctx *context.Context, commitAuthor config.CommitAuthor, repo Repo, content []byte, path string, message string) error {
return g.CreateFiles(ctx, commitAuthor, repo, message, []RepoFile{{
Path: path,
Content: content,
}})
}
func keyPath(key string) (string, error) {
if key == "" {
return "", pipe.Skip("private_key is empty")
}
path := key
_, err := ssh.ParsePrivateKey([]byte(key))
if isPasswordError(err) {
return "", fmt.Errorf("git: key is password-protected")
}
if err == nil {
// if it can be parsed as a valid private key, we write it to a
// temp file and use that path on GIT_SSH_COMMAND.
f, err := os.CreateTemp("", "id_*")
if err != nil {
return "", fmt.Errorf("git: failed to store private key: %w", err)
}
defer f.Close()
// the key needs to EOF at an empty line, seems like github actions
// is somehow removing them.
if !strings.HasSuffix(key, "\n") {
key += "\n"
}
if _, err := io.WriteString(f, key); err != nil {
return "", fmt.Errorf("git: failed to store private key: %w", err)
}
if err := f.Close(); err != nil {
return "", fmt.Errorf("git: failed to store private key: %w", err)
}
path = f.Name()
}
if _, err := os.Stat(path); err != nil {
return "", fmt.Errorf("git: could not stat private_key: %w", err)
}
// in any case, ensure the key has the correct permissions.
if err := os.Chmod(path, 0o600); err != nil {
return "", fmt.Errorf("git: failed to ensure private_key permissions: %w", err)
}
return path, nil
}
func isPasswordError(err error) bool {
var kerr *ssh.PassphraseMissingError
return errors.As(err, &kerr)
}
func cloneRepoWithRetries(ctx *context.Context, parent, url, name string, env []string) error {
var try int
for try < 10 {
try++
err := runGitCmds(ctx, parent, env, [][]string{{"clone", url, name}})
if err == nil {
return nil
}
if isRetriableCloneError(err) {
log.WithField("try", try).
WithField("image", name).
WithError(err).
Warnf("failed to push image, will retry")
time.Sleep(time.Duration(try*10) * time.Second)
continue
}
return fmt.Errorf("failed to clone local repository: %w", err)
}
return fmt.Errorf("failed to push %s after %d tries", name, try)
}
func isRetriableCloneError(err error) bool {
return strings.Contains(err.Error(), "Connection reset")
}
func runGitCmds(ctx *context.Context, cwd string, env []string, cmds [][]string) error {
for _, cmd := range cmds {
args := append([]string{"-C", cwd}, cmd...)
if _, err := git.Clean(git.RunWithEnv(ctx, env, args...)); err != nil {
return fmt.Errorf("%q failed: %w", strings.Join(cmd, " "), err)
}
}
return nil
}
func nameFromURL(url string) string {
return strings.TrimSuffix(url[strings.LastIndex(url, "/")+1:], ".git")
}