internal/mirror/mirror.go
142 lines · 3879 bytes
6 symbols in this file
1// Package mirror synchronizes repositories with foreign remotes: push
2// mirrors propagate local refs outward after each receive, pull mirrors
3// keep a local copy fresh from an upstream. Sync runs in a background
4// worker, never in the push path; outcomes are recorded per mirror so
5// `repo mirror list` shows failure states like webhook deliveries do.
6package mirror
7
8import (
9 "context"
10 "fmt"
11 "log/slog"
12 "net"
13 "os"
14 "os/exec"
15 "path/filepath"
16 "time"
17
18 "gitbay.org/gitbay/internal/config"
19 "gitbay.org/gitbay/internal/control"
20 "gitbay.org/gitbay/internal/gitpin"
21 "gitbay.org/gitbay/internal/store"
22 "gitbay.org/gitbay/internal/toolpath"
23)
24
25const askpassScript = `#!/bin/sh
26case "$1" in
27 Username*) echo "${GITBAY_MIRROR_USER}" ;;
28 *) echo "${GITBAY_MIRROR_TOKEN}" ;;
29esac
30`
31
32type Worker struct {
33 St *store.Store
34 Cfg config.Config
35 Tick time.Duration
36 // Lookup resolves a mirror's host immediately before each sync.
37 Lookup func(ctx context.Context, host string) ([]net.IP, error)
38 // gitErr is set when the server's git cannot pin addresses; no
39 // mirror syncs while it is.
40 gitErr error
41}
42
43func New(st *store.Store, cfg config.Config) *Worker {
44 tick := 10 * time.Second
45 if v := os.Getenv("GITBAY_MIRROR_TICK"); v != "" {
46 if d, err := time.ParseDuration(v); err == nil {
47 tick = d
48 }
49 }
50 return &Worker{St: st, Cfg: cfg, Tick: tick, Lookup: gitpin.LookupIP}
51}
52
53func (w *Worker) Run(ctx context.Context) {
54 if err := gitpin.CheckGit(ctx); err != nil {
55 w.gitErr = fmt.Errorf("mirrors disabled: %w", err)
56 slog.Error("mirror: not syncing", "err", w.gitErr)
57 }
58 t := time.NewTicker(w.Tick)
59 defer t.Stop()
60 for {
61 select {
62 case <-ctx.Done():
63 return
64 case <-t.C:
65 w.sweep()
66 }
67 }
68}
69
70func (w *Worker) sweep() {
71 interval := w.Cfg.Mirrors.PullIntervalMinutes * 60
72 due, err := w.St.DueMirrors(interval)
73 if err != nil {
74 slog.Error("mirror: listing due", "err", err)
75 return
76 }
77 for _, m := range due {
78 if w.gitErr != nil {
79 w.St.SetMirrorResult(m.ID, w.gitErr.Error())
80 continue
81 }
82 if err := w.sync(m); err != nil {
83 slog.Warn("mirror sync failed", "mirror", m.ID, "url", m.URL, "err", err)
84 w.St.SetMirrorResult(m.ID, err.Error())
85 } else {
86 w.St.SetMirrorResult(m.ID, "")
87 if m.Direction == "pull" {
88 w.St.RequestSymbolIndex(m.RepoID, false)
89 }
90 }
91 }
92}
93
94func (w *Worker) sync(m store.Mirror) error {
95 repo, err := w.St.RepoByID(m.RepoID)
96 if err != nil {
97 return err
98 }
99 dir := control.RepoDir(w.Cfg.Server.Root, repo.OwnerName, repo.Name)
100
101 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
102 defer cancel()
103 // The URL was checked when saved, but DNS can answer differently
104 // now. Check what it resolves to at sync time, then let git connect
105 // to exactly those addresses.
106 remote, err := gitpin.Resolve(ctx, w.Lookup, m.URL, w.Cfg.Webhooks.AllowLocal)
107 if err != nil {
108 return err
109 }
110
111 env := gitpin.Env(w.Cfg.Server.Root)
112 if m.Token != "" {
113 askpass := filepath.Join(w.Cfg.Server.Root, "mirror-askpass.sh")
114 if err := os.WriteFile(askpass, []byte(askpassScript), 0o700); err != nil {
115 return err
116 }
117 user := m.Username
118 if user == "" {
119 user = "x-access-token"
120 }
121 env = append(env,
122 "GIT_ASKPASS="+askpass,
123 "GITBAY_MIRROR_USER="+user,
124 "GITBAY_MIRROR_TOKEN="+m.Token)
125 }
126
127 args := append(remote.Args(), "-C", dir)
128 if m.Direction == "push" {
129 // Branches and tags only: internal refs (merge-requests) stay home.
130 args = append(args, "push", "--prune", m.URL,
131 "+refs/heads/*:refs/heads/*", "+refs/tags/*:refs/tags/*")
132 } else {
133 args = append(args, "fetch", "--prune", m.URL,
134 "+refs/heads/*:refs/heads/*", "+refs/tags/*:refs/tags/*")
135 }
136 cmd := exec.CommandContext(ctx, toolpath.Look("git"), args...)
137 cmd.Env = env
138 if out, err := cmd.CombinedOutput(); err != nil {
139 return fmt.Errorf("git %s: %v: %.300s", m.Direction, err, out)
140 }
141 return nil
142}