Monorepo for Tangled
at master 305 lines 9.0 kB view raw
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}