internal/mirror/mirror.go

139 lines · 3799 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		}
 88	}
 89}
 90
 91func (w *Worker) sync(m store.Mirror) error {
 92	repo, err := w.St.RepoByID(m.RepoID)
 93	if err != nil {
 94		return err
 95	}
 96	dir := control.RepoDir(w.Cfg.Server.Root, repo.OwnerName, repo.Name)
 97
 98	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
 99	defer cancel()
100	// The URL was checked when saved, but DNS can answer differently
101	// now. Check what it resolves to at sync time, then let git connect
102	// to exactly those addresses.
103	remote, err := gitpin.Resolve(ctx, w.Lookup, m.URL, w.Cfg.Webhooks.AllowLocal)
104	if err != nil {
105		return err
106	}
107
108	env := gitpin.Env(w.Cfg.Server.Root)
109	if m.Token != "" {
110		askpass := filepath.Join(w.Cfg.Server.Root, "mirror-askpass.sh")
111		if err := os.WriteFile(askpass, []byte(askpassScript), 0o700); err != nil {
112			return err
113		}
114		user := m.Username
115		if user == "" {
116			user = "x-access-token"
117		}
118		env = append(env,
119			"GIT_ASKPASS="+askpass,
120			"GITBAY_MIRROR_USER="+user,
121			"GITBAY_MIRROR_TOKEN="+m.Token)
122	}
123
124	args := append(remote.Args(), "-C", dir)
125	if m.Direction == "push" {
126		// Branches and tags only: internal refs (merge-requests) stay home.
127		args = append(args, "push", "--prune", m.URL,
128			"+refs/heads/*:refs/heads/*", "+refs/tags/*:refs/tags/*")
129	} else {
130		args = append(args, "fetch", "--prune", m.URL,
131			"+refs/heads/*:refs/heads/*", "+refs/tags/*:refs/tags/*")
132	}
133	cmd := exec.CommandContext(ctx, toolpath.Look("git"), args...)
134	cmd.Env = env
135	if out, err := cmd.CombinedOutput(); err != nil {
136		return fmt.Errorf("git %s: %v: %.300s", m.Direction, err, out)
137	}
138	return nil
139}