Raw
  1package replica
  2
  3import (
  4	"context"
  5	"errors"
  6	"fmt"
  7	"net"
  8	"os"
  9	"strconv"
 10	"strings"
 11	"time"
 12
 13	"github.com/go-git/go-git/v5"
 14	"github.com/go-git/go-git/v5/config"
 15	"github.com/go-git/go-git/v5/plumbing"
 16	"github.com/go-git/go-git/v5/plumbing/object"
 17	"github.com/go-git/go-git/v5/plumbing/transport"
 18	"github.com/go-git/go-git/v5/plumbing/transport/client"
 19	"github.com/go-git/go-git/v5/plumbing/transport/server"
 20	gitssh "github.com/go-git/go-git/v5/plumbing/transport/ssh"
 21	"github.com/go-git/go-git/v5/storage/memory"
 22	"golang.org/x/crypto/ssh"
 23)
 24
 25const (
 26	Branch                   = plumbing.ReferenceName("refs/heads/main")
 27	DefaultSSHTimeout        = time.Minute
 28	DefaultFetchIdleTimeout  = time.Minute
 29	DefaultFetchTotalTimeout = 30 * time.Minute
 30	remoteName               = "replica"
 31)
 32
 33var (
 34	ErrRemoteMoved    = errors.New("replica: remote branch is not at the expected commit")
 35	ErrNonFastForward = errors.New("replica: push is not a fast-forward of the remote branch")
 36	ErrRemoteRejected = errors.New("replica: remote refused the update")
 37	ErrNotRoot        = errors.New("replica: a push with no expected remote commit must push a root commit")
 38	ErrNoKnownHosts   = errors.New("replica: an ssh remote needs a known_hosts file")
 39)
 40
 41func UseInProcessFileTransport() {
 42	client.InstallProtocol("file", server.DefaultServer)
 43}
 44
 45func DisableSSHConfig() {
 46	gitssh.DefaultSSHConfig = nil
 47}
 48
 49type SSH struct {
 50	*gitssh.PublicKeys
 51	Timeout           time.Duration
 52	FetchIdleTimeout  time.Duration
 53	FetchTotalTimeout time.Duration
 54	fetch             string
 55}
 56
 57func (a *SSH) ClientConfig() (*ssh.ClientConfig, error) {
 58	cfg, err := a.PublicKeys.ClientConfig()
 59	if err != nil {
 60		return nil, err
 61	}
 62	cfg.Timeout = a.Timeout
 63	return cfg, nil
 64}
 65
 66func SSHAuth(remoteURL string, deployKey []byte, knownHostsFile string) (*SSH, error) {
 67	if knownHostsFile == "" {
 68		return nil, ErrNoKnownHosts
 69	}
 70	ep, err := transport.NewEndpoint(remoteURL)
 71	if err != nil {
 72		return nil, err
 73	}
 74	if ep.Protocol != "ssh" || ep.User == "" {
 75		return nil, fmt.Errorf("replica: %q is not an ssh URL with a user", remoteURL)
 76	}
 77	auth, err := gitssh.NewPublicKeys(ep.User, deployKey, "")
 78	if err != nil {
 79		return nil, ErrKeyFile
 80	}
 81	if _, err := os.Stat(knownHostsFile); err != nil {
 82		return nil, fmt.Errorf("%w: %s", ErrKnownHostsUnreadable, knownHostsFile)
 83	}
 84	db, err := gitssh.NewKnownHostsDb(knownHostsFile)
 85	if err != nil {
 86		return nil, fmt.Errorf("%w: %s", ErrKnownHostsUnreadable, knownHostsFile)
 87	}
 88	port := ep.Port
 89	if port == 0 {
 90		port = gitssh.DefaultPort
 91	}
 92	auth.HostKeyCallback = db.HostKeyCallback()
 93	auth.HostKeyAlgorithms = db.HostKeyAlgorithms(net.JoinHostPort(ep.Host, strconv.Itoa(port)))
 94	return &SSH{PublicKeys: auth, Timeout: DefaultSSHTimeout}, nil
 95}
 96
 97func newRemote(s *git.Repository, url string) *git.Remote {
 98	return git.NewRemote(s.Storer, &config.RemoteConfig{Name: remoteName, URLs: []string{url}})
 99}
100
101func RemoteHead(ctx context.Context, url string, auth transport.AuthMethod, branch plumbing.ReferenceName) (plumbing.Hash, error) {
102	r := git.NewRemote(memory.NewStorage(), &config.RemoteConfig{Name: remoteName, URLs: []string{url}})
103	refs, err := r.ListContext(ctx, &git.ListOptions{Auth: auth, ProxyOptions: proxyFor(auth)})
104	if errors.Is(err, transport.ErrEmptyRemoteRepository) {
105		return plumbing.ZeroHash, nil
106	}
107	if err != nil {
108		return plumbing.ZeroHash, err
109	}
110	for _, ref := range refs {
111		if ref.Name() == branch && ref.Type() == plumbing.HashReference {
112			return ref.Hash(), nil
113		}
114	}
115	return plumbing.ZeroHash, nil
116}
117
118func Push(ctx context.Context, repo *git.Repository, url string, auth transport.AuthMethod, branch plumbing.ReferenceName, id, expected plumbing.Hash) error {
119	opts := &git.PushOptions{
120		RemoteName:   remoteName,
121		RefSpecs:     []config.RefSpec{config.RefSpec(id.String() + ":" + branch.String())},
122		Auth:         auth,
123		ProxyOptions: proxyFor(auth),
124	}
125	if expected.IsZero() {
126		c, err := object.GetCommit(repo.Storer, id)
127		if err != nil {
128			return err
129		}
130		if c.NumParents() != 0 {
131			return ErrNotRoot
132		}
133	} else {
134		opts.RequireRemoteRefs = []config.RefSpec{config.RefSpec(expected.String() + ":" + branch.String())}
135	}
136	err := newRemote(repo, url).PushContext(ctx, opts)
137	return classifyPush(err)
138}
139
140func classifyPush(err error) error {
141	if err == nil || errors.Is(err, git.NoErrAlreadyUpToDate) {
142		return nil
143	}
144	msg := err.Error()
145	switch {
146	case strings.HasPrefix(msg, "remote ref ") && strings.Contains(msg, " required to be "):
147		return fmt.Errorf("%w: %v", ErrRemoteMoved, err)
148	case strings.HasPrefix(msg, "non-fast-forward update: "):
149		return fmt.Errorf("%w: %v", ErrNonFastForward, err)
150	case strings.HasPrefix(msg, "command error on ") || strings.HasPrefix(msg, "unpack error: "):
151		return fmt.Errorf("%w: %v", ErrRemoteRejected, err)
152	}
153	return err
154}
155
156func Fetch(ctx context.Context, repo *git.Repository, url string, auth transport.AuthMethod, branch plumbing.ReferenceName) (plumbing.Hash, error) {
157	if a, ok := auth.(*SSH); ok && a.fetch == "" {
158		var release func()
159		auth, release = registerFetch(a, &fetchLimit{ctx: ctx, total: a.Timeout})
160		defer release()
161	}
162	dst := plumbing.ReferenceName("refs/remotes/" + remoteName + "/" + strings.TrimPrefix(branch.String(), "refs/heads/"))
163	err := newRemote(repo, url).FetchContext(ctx, &git.FetchOptions{
164		RemoteName:   remoteName,
165		RefSpecs:     []config.RefSpec{config.RefSpec("+" + branch.String() + ":" + dst.String())},
166		Auth:         auth,
167		Tags:         git.NoTags,
168		ProxyOptions: proxyFor(auth),
169	})
170	if cause := context.Cause(ctx); cause != nil {
171		return plumbing.ZeroHash, cause
172	}
173	var noMatch git.NoMatchingRefSpecError
174	switch {
175	case errors.Is(err, transport.ErrEmptyRemoteRepository), errors.As(err, &noMatch):
176		return plumbing.ZeroHash, nil
177	case err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate):
178		return plumbing.ZeroHash, err
179	}
180	ref, err := repo.Storer.Reference(dst)
181	if err != nil {
182		return plumbing.ZeroHash, err
183	}
184	return ref.Hash(), nil
185}