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