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