internal/mirror/mirror.go
115 lines · 2935 bytes
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 "os"
13 "os/exec"
14 "path/filepath"
15 "time"
16
17 "gitbay.org/gitbay/internal/config"
18 "gitbay.org/gitbay/internal/control"
19 "gitbay.org/gitbay/internal/store"
20)
21
22const askpassScript = `#!/bin/sh
23case "$1" in
24 Username*) echo "${GITBAY_MIRROR_USER}" ;;
25 *) echo "${GITBAY_MIRROR_TOKEN}" ;;
26esac
27`
28
29type Worker struct {
30 St *store.Store
31 Cfg config.Config
32 Tick time.Duration
33}
34
35func New(st *store.Store, cfg config.Config) *Worker {
36 tick := 10 * time.Second
37 if v := os.Getenv("GITBAY_MIRROR_TICK"); v != "" {
38 if d, err := time.ParseDuration(v); err == nil {
39 tick = d
40 }
41 }
42 return &Worker{St: st, Cfg: cfg, Tick: tick}
43}
44
45func (w *Worker) Run(ctx context.Context) {
46 t := time.NewTicker(w.Tick)
47 defer t.Stop()
48 for {
49 select {
50 case <-ctx.Done():
51 return
52 case <-t.C:
53 w.sweep()
54 }
55 }
56}
57
58func (w *Worker) sweep() {
59 interval := w.Cfg.Mirrors.PullIntervalMinutes * 60
60 due, err := w.St.DueMirrors(interval)
61 if err != nil {
62 slog.Error("mirror: listing due", "err", err)
63 return
64 }
65 for _, m := range due {
66 if err := w.sync(m); err != nil {
67 slog.Warn("mirror sync failed", "mirror", m.ID, "url", m.URL, "err", err)
68 w.St.SetMirrorResult(m.ID, err.Error())
69 } else {
70 w.St.SetMirrorResult(m.ID, "")
71 }
72 }
73}
74
75func (w *Worker) sync(m store.Mirror) error {
76 repo, err := w.St.RepoByID(m.RepoID)
77 if err != nil {
78 return err
79 }
80 dir := control.RepoDir(w.Cfg.Server.Root, repo.OwnerName, repo.Name)
81
82 env := []string{"GIT_TERMINAL_PROMPT=0", "HOME=" + w.Cfg.Server.Root}
83 if m.Token != "" {
84 askpass := filepath.Join(w.Cfg.Server.Root, "mirror-askpass.sh")
85 if err := os.WriteFile(askpass, []byte(askpassScript), 0o700); err != nil {
86 return err
87 }
88 user := m.Username
89 if user == "" {
90 user = "x-access-token"
91 }
92 env = append(env,
93 "GIT_ASKPASS="+askpass,
94 "GITBAY_MIRROR_USER="+user,
95 "GITBAY_MIRROR_TOKEN="+m.Token)
96 }
97
98 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
99 defer cancel()
100 var args []string
101 if m.Direction == "push" {
102 // Branches and tags only: internal refs (merge-requests) stay home.
103 args = []string{"-C", dir, "push", "--prune", m.URL,
104 "+refs/heads/*:refs/heads/*", "+refs/tags/*:refs/tags/*"}
105 } else {
106 args = []string{"-C", dir, "fetch", "--prune", m.URL,
107 "+refs/heads/*:refs/heads/*", "+refs/tags/*:refs/tags/*"}
108 }
109 cmd := exec.CommandContext(ctx, "git", args...)
110 cmd.Env = env
111 if out, err := cmd.CombinedOutput(); err != nil {
112 return fmt.Errorf("git %s: %v: %.300s", m.Direction, err, out)
113 }
114 return nil
115}