package replica import ( "context" "errors" "fmt" "net" "os" "strconv" "strings" "time" "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/object" "github.com/go-git/go-git/v5/plumbing/transport" "github.com/go-git/go-git/v5/plumbing/transport/client" "github.com/go-git/go-git/v5/plumbing/transport/server" gitssh "github.com/go-git/go-git/v5/plumbing/transport/ssh" "github.com/go-git/go-git/v5/storage/memory" "golang.org/x/crypto/ssh" ) const ( Branch = plumbing.ReferenceName("refs/heads/main") DefaultSSHTimeout = time.Minute DefaultFetchIdleTimeout = time.Minute DefaultFetchTotalTimeout = 30 * time.Minute remoteName = "replica" ) var ( ErrRemoteMoved = errors.New("replica: remote branch is not at the expected commit") ErrNonFastForward = errors.New("replica: push is not a fast-forward of the remote branch") ErrRemoteRejected = errors.New("replica: remote refused the update") ErrNotRoot = errors.New("replica: a push with no expected remote commit must push a root commit") ErrNoKnownHosts = errors.New("replica: an ssh remote needs a known_hosts file") ) func UseInProcessFileTransport() { client.InstallProtocol("file", server.DefaultServer) } func DisableSSHConfig() { gitssh.DefaultSSHConfig = nil } type SSH struct { *gitssh.PublicKeys Timeout time.Duration FetchIdleTimeout time.Duration FetchTotalTimeout time.Duration fetch string } func (a *SSH) ClientConfig() (*ssh.ClientConfig, error) { cfg, err := a.PublicKeys.ClientConfig() if err != nil { return nil, err } cfg.Timeout = a.Timeout return cfg, nil } func SSHAuth(remoteURL string, deployKey []byte, knownHostsFile string) (*SSH, error) { if knownHostsFile == "" { return nil, ErrNoKnownHosts } ep, err := transport.NewEndpoint(remoteURL) if err != nil { return nil, err } if ep.Protocol != "ssh" || ep.User == "" { return nil, fmt.Errorf("replica: %q is not an ssh URL with a user", remoteURL) } auth, err := gitssh.NewPublicKeys(ep.User, deployKey, "") if err != nil { return nil, ErrKeyFile } if _, err := os.Stat(knownHostsFile); err != nil { return nil, fmt.Errorf("%w: %s", ErrKnownHostsUnreadable, knownHostsFile) } db, err := gitssh.NewKnownHostsDb(knownHostsFile) if err != nil { return nil, fmt.Errorf("%w: %s", ErrKnownHostsUnreadable, knownHostsFile) } port := ep.Port if port == 0 { port = gitssh.DefaultPort } auth.HostKeyCallback = db.HostKeyCallback() auth.HostKeyAlgorithms = db.HostKeyAlgorithms(net.JoinHostPort(ep.Host, strconv.Itoa(port))) return &SSH{PublicKeys: auth, Timeout: DefaultSSHTimeout}, nil } func newRemote(s *git.Repository, url string) *git.Remote { return git.NewRemote(s.Storer, &config.RemoteConfig{Name: remoteName, URLs: []string{url}}) } func RemoteHead(ctx context.Context, url string, auth transport.AuthMethod, branch plumbing.ReferenceName) (plumbing.Hash, error) { r := git.NewRemote(memory.NewStorage(), &config.RemoteConfig{Name: remoteName, URLs: []string{url}}) refs, err := r.ListContext(ctx, &git.ListOptions{Auth: auth, ProxyOptions: proxyFor(auth)}) if errors.Is(err, transport.ErrEmptyRemoteRepository) { return plumbing.ZeroHash, nil } if err != nil { return plumbing.ZeroHash, err } for _, ref := range refs { if ref.Name() == branch && ref.Type() == plumbing.HashReference { return ref.Hash(), nil } } return plumbing.ZeroHash, nil } func Push(ctx context.Context, repo *git.Repository, url string, auth transport.AuthMethod, branch plumbing.ReferenceName, id, expected plumbing.Hash) error { opts := &git.PushOptions{ RemoteName: remoteName, RefSpecs: []config.RefSpec{config.RefSpec(id.String() + ":" + branch.String())}, Auth: auth, ProxyOptions: proxyFor(auth), } if expected.IsZero() { c, err := object.GetCommit(repo.Storer, id) if err != nil { return err } if c.NumParents() != 0 { return ErrNotRoot } } else { opts.RequireRemoteRefs = []config.RefSpec{config.RefSpec(expected.String() + ":" + branch.String())} } err := newRemote(repo, url).PushContext(ctx, opts) return classifyPush(err) } func classifyPush(err error) error { if err == nil || errors.Is(err, git.NoErrAlreadyUpToDate) { return nil } msg := err.Error() switch { case strings.HasPrefix(msg, "remote ref ") && strings.Contains(msg, " required to be "): return fmt.Errorf("%w: %v", ErrRemoteMoved, err) case strings.HasPrefix(msg, "non-fast-forward update: "): return fmt.Errorf("%w: %v", ErrNonFastForward, err) case strings.HasPrefix(msg, "command error on ") || strings.HasPrefix(msg, "unpack error: "): return fmt.Errorf("%w: %v", ErrRemoteRejected, err) } return err } func Fetch(ctx context.Context, repo *git.Repository, url string, auth transport.AuthMethod, branch plumbing.ReferenceName) (plumbing.Hash, error) { if a, ok := auth.(*SSH); ok && a.fetch == "" { var release func() auth, release = registerFetch(a, &fetchLimit{ctx: ctx, total: a.Timeout}) defer release() } dst := plumbing.ReferenceName("refs/remotes/" + remoteName + "/" + strings.TrimPrefix(branch.String(), "refs/heads/")) err := newRemote(repo, url).FetchContext(ctx, &git.FetchOptions{ RemoteName: remoteName, RefSpecs: []config.RefSpec{config.RefSpec("+" + branch.String() + ":" + dst.String())}, Auth: auth, Tags: git.NoTags, ProxyOptions: proxyFor(auth), }) if cause := context.Cause(ctx); cause != nil { return plumbing.ZeroHash, cause } var noMatch git.NoMatchingRefSpecError switch { case errors.Is(err, transport.ErrEmptyRemoteRepository), errors.As(err, &noMatch): return plumbing.ZeroHash, nil case err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate): return plumbing.ZeroHash, err } ref, err := repo.Storer.Reference(dst) if err != nil { return plumbing.ZeroHash, err } return ref.Hash(), nil }