internal/mirror/mirror.go

142 lines · 3879 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	"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}