internal/mirror/mirror.go

d122636fc915dc95728bc74a3990da1630aa36d3
gitbay/internal/mirror/mirror.go history · blame · raw

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}