forked from
tangled.org/core
Monorepo for Tangled
1package knotmirror
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "net/url"
8 "os"
9 "os/exec"
10 "path/filepath"
11 "regexp"
12 "strings"
13
14 "github.com/go-git/go-git/v5"
15 gitconfig "github.com/go-git/go-git/v5/config"
16 "github.com/go-git/go-git/v5/plumbing/transport"
17 "tangled.org/core/knotmirror/models"
18)
19
20type GitMirrorManager interface {
21 Exist(repo *models.Repo) (bool, error)
22 // RemoteSetUrl updates git repository 'origin' remote
23 RemoteSetUrl(ctx context.Context, repo *models.Repo) error
24 // Clone clones the repository as a mirror
25 Clone(ctx context.Context, repo *models.Repo) error
26 // Fetch fetches the repository
27 Fetch(ctx context.Context, repo *models.Repo) error
28 // Sync mirrors the repository. It will clone the repository if repository doesn't exist.
29 Sync(ctx context.Context, repo *models.Repo) error
30}
31
32type CliGitMirrorManager struct {
33 repoBasePath string
34 knotUseSSL bool
35}
36
37func NewCliGitMirrorManager(repoBasePath string, knotUseSSL bool) *CliGitMirrorManager {
38 return &CliGitMirrorManager{
39 repoBasePath,
40 knotUseSSL,
41 }
42}
43
44var _ GitMirrorManager = new(CliGitMirrorManager)
45
46func (c *CliGitMirrorManager) makeRepoPath(repo *models.Repo) string {
47 return filepath.Join(c.repoBasePath, repo.Did.String(), repo.Rkey.String())
48}
49
50func (c *CliGitMirrorManager) Exist(repo *models.Repo) (bool, error) {
51 return isDir(c.makeRepoPath(repo))
52}
53
54func (c *CliGitMirrorManager) RemoteSetUrl(ctx context.Context, repo *models.Repo) error {
55 path := c.makeRepoPath(repo)
56 url, err := makeRepoRemoteUrl(repo.KnotDomain, repo.DidSlashRepo(), c.knotUseSSL)
57 if err != nil {
58 return fmt.Errorf("constructing repo remote url: %w", err)
59 }
60 cmd := exec.CommandContext(ctx, "git", "-C", path, "remote", "set-url", "origin", url)
61 if out, err := cmd.CombinedOutput(); err != nil {
62 if ctx.Err() != nil {
63 return ctx.Err()
64 }
65 msg := string(out)
66 return fmt.Errorf("running 'git remote set-url origin %s': %w\n%s", url, err, msg)
67 }
68 return nil
69}
70
71func (c *CliGitMirrorManager) Clone(ctx context.Context, repo *models.Repo) error {
72 path := c.makeRepoPath(repo)
73 url, err := makeRepoRemoteUrl(repo.KnotDomain, repo.DidSlashRepo(), c.knotUseSSL)
74 if err != nil {
75 return fmt.Errorf("constructing repo remote url: %w", err)
76 }
77 return c.clone(ctx, path, url)
78}
79
80func (c *CliGitMirrorManager) clone(ctx context.Context, path, url string) error {
81 cmd := exec.CommandContext(ctx, "git", "clone", "--mirror", url, path)
82 if out, err := cmd.CombinedOutput(); err != nil {
83 if ctx.Err() != nil {
84 return ctx.Err()
85 }
86 msg := string(out)
87 if classification := classifyCliError(msg); classification != nil {
88 return classification
89 }
90 return fmt.Errorf("running 'git clone --mirror %s': %w\n%s", url, err, msg)
91 }
92 return nil
93}
94
95func (c *CliGitMirrorManager) Fetch(ctx context.Context, repo *models.Repo) error {
96 path := c.makeRepoPath(repo)
97 return c.fetch(ctx, path)
98}
99
100func (c *CliGitMirrorManager) fetch(ctx context.Context, path string) error {
101 // TODO: use `repo.Knot` instead of depending on origin
102 cmd := exec.CommandContext(ctx, "git", "-C", path, "fetch", "--prune", "origin")
103 if out, err := cmd.CombinedOutput(); err != nil {
104 if ctx.Err() != nil {
105 return ctx.Err()
106 }
107 return fmt.Errorf("running 'git fetch': %w\n%s", err, string(out))
108 }
109 return nil
110}
111
112func (c *CliGitMirrorManager) Sync(ctx context.Context, repo *models.Repo) error {
113 path := c.makeRepoPath(repo)
114 url, err := makeRepoRemoteUrl(repo.KnotDomain, repo.DidSlashRepo(), c.knotUseSSL)
115 if err != nil {
116 return fmt.Errorf("constructing repo remote url: %w", err)
117 }
118
119 exist, err := isDir(path)
120 if err != nil {
121 return fmt.Errorf("checking repo path: %w", err)
122 }
123 if !exist {
124 if err := c.clone(ctx, path, url); err != nil {
125 return fmt.Errorf("cloning repo: %w", err)
126 }
127 } else {
128 if err := c.fetch(ctx, path); err != nil {
129 return fmt.Errorf("fetching repo: %w", err)
130 }
131 }
132 return nil
133}
134
135var (
136 ErrDNSFailure = errors.New("git: knot: dns failure (could not resolve host)")
137 ErrCertExpired = errors.New("git: knot: certificate has expired")
138 ErrCertMismatch = errors.New("git: knot: certificate hostname mismatch")
139 ErrTLSHandshake = errors.New("git: knot: tls handshake failure")
140 ErrHTTPStatus = errors.New("git: knot: request url returned error")
141 ErrUnreachable = errors.New("git: knot: could not connect to server")
142 ErrRepoNotFound = errors.New("git: repo: repository not found")
143)
144
145var (
146 reDNSFailure = regexp.MustCompile(`Could not resolve host:`)
147 reCertExpired = regexp.MustCompile(`SSL certificate OpenSSL verify result: certificate has expired`)
148 reCertMismatch = regexp.MustCompile(`SSL: no alternative certificate subject name matches target hostname`)
149 reTLSHandshake = regexp.MustCompile(`TLS connect error: (.*)`)
150 reHTTPStatus = regexp.MustCompile(`The requested URL returned error: (\d\d\d)`)
151 reUnreachable = regexp.MustCompile(`Could not connect to server`)
152 reRepoNotFound = regexp.MustCompile(`repository '.*?' not found`)
153)
154
155// classifyCliError classifies git cli error message. It will return nil for unknown error messages
156func classifyCliError(stderr string) error {
157 msg := strings.TrimSpace(stderr)
158 if m := reTLSHandshake.FindStringSubmatch(msg); len(m) > 1 {
159 return fmt.Errorf("%w: %s", ErrTLSHandshake, m[1])
160 }
161 if m := reHTTPStatus.FindStringSubmatch(msg); len(m) > 1 {
162 return fmt.Errorf("%w: %s", ErrHTTPStatus, m[1])
163 }
164 switch {
165 case reDNSFailure.MatchString(msg):
166 return ErrDNSFailure
167 case reCertExpired.MatchString(msg):
168 return ErrCertExpired
169 case reCertMismatch.MatchString(msg):
170 return ErrCertMismatch
171 case reUnreachable.MatchString(msg):
172 return ErrUnreachable
173 case reRepoNotFound.MatchString(msg):
174 return ErrRepoNotFound
175 }
176 return nil
177}
178
179type GoGitMirrorManager struct {
180 repoBasePath string
181 knotUseSSL bool
182}
183
184func NewGoGitMirrorClient(repoBasePath string, knotUseSSL bool) *GoGitMirrorManager {
185 return &GoGitMirrorManager{
186 repoBasePath,
187 knotUseSSL,
188 }
189}
190
191var _ GitMirrorManager = new(GoGitMirrorManager)
192
193func (c *GoGitMirrorManager) makeRepoPath(repo *models.Repo) string {
194 return filepath.Join(c.repoBasePath, repo.Did.String(), repo.Rkey.String())
195}
196
197func (c *GoGitMirrorManager) Exist(repo *models.Repo) (bool, error) {
198 return isDir(c.makeRepoPath(repo))
199}
200
201func (c *GoGitMirrorManager) RemoteSetUrl(ctx context.Context, repo *models.Repo) error {
202 panic("unimplemented")
203}
204
205func (c *GoGitMirrorManager) Clone(ctx context.Context, repo *models.Repo) error {
206 path := c.makeRepoPath(repo)
207 url, err := makeRepoRemoteUrl(repo.KnotDomain, repo.DidSlashRepo(), c.knotUseSSL)
208 if err != nil {
209 return fmt.Errorf("constructing repo remote url: %w", err)
210 }
211 return c.clone(ctx, path, url)
212}
213
214func (c *GoGitMirrorManager) clone(ctx context.Context, path, url string) error {
215 _, err := git.PlainCloneContext(ctx, path, true, &git.CloneOptions{
216 URL: url,
217 Mirror: true,
218 })
219 if err != nil && !errors.Is(err, transport.ErrEmptyRemoteRepository) {
220 return fmt.Errorf("cloning repo: %w", err)
221 }
222 return nil
223}
224
225func (c *GoGitMirrorManager) Fetch(ctx context.Context, repo *models.Repo) error {
226 path := c.makeRepoPath(repo)
227 url, err := makeRepoRemoteUrl(repo.KnotDomain, repo.DidSlashRepo(), c.knotUseSSL)
228 if err != nil {
229 return fmt.Errorf("constructing repo remote url: %w", err)
230 }
231
232 return c.fetch(ctx, path, url)
233}
234
235func (c *GoGitMirrorManager) fetch(ctx context.Context, path, url string) error {
236 gr, err := git.PlainOpen(path)
237 if err != nil {
238 return fmt.Errorf("opening local repo: %w", err)
239 }
240 if err := gr.FetchContext(ctx, &git.FetchOptions{
241 RemoteURL: url,
242 RefSpecs: []gitconfig.RefSpec{gitconfig.RefSpec("+refs/*:refs/*")},
243 Force: true,
244 Prune: true,
245 }); err != nil {
246 return fmt.Errorf("fetching reppo: %w", err)
247 }
248 return nil
249}
250
251func (c *GoGitMirrorManager) Sync(ctx context.Context, repo *models.Repo) error {
252 path := c.makeRepoPath(repo)
253 url, err := makeRepoRemoteUrl(repo.KnotDomain, repo.DidSlashRepo(), c.knotUseSSL)
254 if err != nil {
255 return fmt.Errorf("constructing repo remote url: %w", err)
256 }
257
258 exist, err := isDir(path)
259 if err != nil {
260 return fmt.Errorf("checking repo path: %w", err)
261 }
262 if !exist {
263 if err := c.clone(ctx, path, url); err != nil {
264 return fmt.Errorf("cloning repo: %w", err)
265 }
266 } else {
267 if err := c.fetch(ctx, path, url); err != nil {
268 return fmt.Errorf("fetching repo: %w", err)
269 }
270 }
271 return nil
272}
273
274func makeRepoRemoteUrl(knot, didSlashRepo string, knotUseSSL bool) (string, error) {
275 if !strings.Contains(knot, "://") {
276 if knotUseSSL {
277 knot = "https://" + knot
278 } else {
279 knot = "http://" + knot
280 }
281 }
282
283 u, err := url.Parse(knot)
284 if err != nil {
285 return "", err
286 }
287
288 if u.Scheme != "http" && u.Scheme != "https" {
289 return "", fmt.Errorf("unsupported scheme: %s", u.Scheme)
290 }
291
292 u = u.JoinPath(didSlashRepo)
293 return u.String(), nil
294}
295
296func isDir(path string) (bool, error) {
297 info, err := os.Stat(path)
298 if err == nil && info.IsDir() {
299 return true, nil
300 }
301 if os.IsNotExist(err) {
302 return false, nil
303 }
304 return false, err
305}