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}