This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
// Package syncer performs a single src -> dst mirror cycle.
|
||||
//
|
||||
// The design is deliberately stateless: nothing is remembered between runs.
|
||||
// Each cycle asks both remotes what they currently hold and does only the work
|
||||
// needed to make them agree. That makes the bot self-healing — if someone
|
||||
// force-pushes the destination, or a push half-fails, or the container is
|
||||
// rebuilt from scratch, the next cycle simply notices and converges.
|
||||
package syncer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"syncbot/internal/config"
|
||||
"syncbot/internal/gitx"
|
||||
)
|
||||
|
||||
// Result summarises what a cycle did, for logging and metrics.
|
||||
type Result struct {
|
||||
Fetched bool // objects were pulled from src
|
||||
Pushed bool // refs were written to dst
|
||||
Refs int // number of managed refs after the cycle
|
||||
Changes []string // porcelain lines for the refs that moved
|
||||
Duration time.Duration
|
||||
}
|
||||
|
||||
// Syncer runs mirror cycles. It holds no per-repo state and is safe for
|
||||
// concurrent use.
|
||||
type Syncer struct {
|
||||
Log *slog.Logger
|
||||
Home string // isolated HOME for git/ssh; must exist and be writable
|
||||
}
|
||||
|
||||
// Sync brings job.Dst in line with job.Src.
|
||||
func (s *Syncer) Sync(ctx context.Context, job config.Job) (Result, error) {
|
||||
start := time.Now()
|
||||
var res Result
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, job.Timeout)
|
||||
defer cancel()
|
||||
|
||||
// Staged deploy-key copies live here and are shredded when we are done.
|
||||
tmp, err := os.MkdirTemp(s.Home, "ssh-")
|
||||
if err != nil {
|
||||
return res, fmt.Errorf("create temp dir: %w", err)
|
||||
}
|
||||
defer os.RemoveAll(tmp)
|
||||
|
||||
log := s.Log.With("repo", job.Name)
|
||||
secrets := []string{gitx.URLSecret(job.Src.URL), gitx.URLSecret(job.Dst.URL)}
|
||||
|
||||
srcOpts, err := s.options(job.Src, job.GitConfig, tmp, secrets, log)
|
||||
if err != nil {
|
||||
return res, fmt.Errorf("src: %w", err)
|
||||
}
|
||||
dstOpts, err := s.options(job.Dst, job.GitConfig, tmp, secrets, log)
|
||||
if err != nil {
|
||||
return res, fmt.Errorf("dst: %w", err)
|
||||
}
|
||||
|
||||
if err := gitx.EnsureMirror(ctx, srcOpts, job.Dir); err != nil {
|
||||
return res, err
|
||||
}
|
||||
|
||||
// 1. Ask src what it has. This is the cheap poll that runs every interval.
|
||||
srcRefs, err := gitx.LsRemote(ctx, srcOpts, job.Dir, job.Src.URL, job.Refs)
|
||||
if err != nil {
|
||||
return res, fmt.Errorf("read src %s: %w", gitx.RedactURL(job.Src.URL), err)
|
||||
}
|
||||
|
||||
localRefs, err := gitx.LocalRefs(ctx, srcOpts, job.Dir, job.Refs)
|
||||
if err != nil {
|
||||
return res, fmt.Errorf("read mirror: %w", err)
|
||||
}
|
||||
|
||||
// 2. Only transfer objects when the mirror is actually behind.
|
||||
if !srcRefs.Equal(localRefs) {
|
||||
log.Info("fetching", "src", gitx.RedactURL(job.Src.URL),
|
||||
"local_refs", len(localRefs), "src_refs", len(srcRefs))
|
||||
if err := gitx.Fetch(ctx, srcOpts, job.Dir, job.Src.URL, job.Refs); err != nil {
|
||||
return res, fmt.Errorf("fetch from %s: %w", gitx.RedactURL(job.Src.URL), err)
|
||||
}
|
||||
res.Fetched = true
|
||||
if localRefs, err = gitx.LocalRefs(ctx, srcOpts, job.Dir, job.Refs); err != nil {
|
||||
return res, fmt.Errorf("read mirror after fetch: %w", err)
|
||||
}
|
||||
}
|
||||
res.Refs = len(localRefs)
|
||||
|
||||
// 3. Ask dst what it has, so external drift is detected too.
|
||||
dstRefs, err := gitx.LsRemote(ctx, dstOpts, job.Dir, job.Dst.URL, job.Refs)
|
||||
if err != nil {
|
||||
return res, fmt.Errorf("read dst %s: %w", gitx.RedactURL(job.Dst.URL), err)
|
||||
}
|
||||
|
||||
if !needsPush(localRefs, dstRefs, job.Prune) {
|
||||
res.Duration = time.Since(start)
|
||||
log.Debug("already in sync", "refs", res.Refs, "dur", res.Duration.Round(time.Millisecond))
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// Safety net: an upstream that suddenly reports zero refs is far more
|
||||
// likely to be a broken URL or a revoked token than a genuine wipe, and
|
||||
// pushing that through with --prune would delete the destination.
|
||||
if len(localRefs) == 0 && len(dstRefs) > 0 && !job.AllowEmpty {
|
||||
return res, fmt.Errorf("refusing to mirror an empty source over %d ref(s) on dst; "+
|
||||
"set allow_empty = true if this is intended", len(dstRefs))
|
||||
}
|
||||
|
||||
log.Info("pushing", "dst", gitx.RedactURL(job.Dst.URL), "refs", res.Refs)
|
||||
changes, err := gitx.Push(ctx, dstOpts, job.Dir, job.Dst.URL, job.Refs, gitx.PushOptions{
|
||||
Prune: job.Prune,
|
||||
Force: job.Force,
|
||||
Atomic: job.Atomic,
|
||||
})
|
||||
if err != nil {
|
||||
return res, fmt.Errorf("push to %s: %w", gitx.RedactURL(job.Dst.URL), err)
|
||||
}
|
||||
res.Pushed = true
|
||||
res.Changes = changes
|
||||
res.Duration = time.Since(start)
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// options builds the git invocation environment for one endpoint.
|
||||
func (s *Syncer) options(e config.Endpoint, gitConfig []string, tmp string, secrets []string, log *slog.Logger) (gitx.Options, error) {
|
||||
o := gitx.Options{
|
||||
Log: log,
|
||||
Home: s.Home,
|
||||
GitConfig: gitConfig,
|
||||
Secrets: secrets,
|
||||
}
|
||||
sshCmd, err := gitx.PrepareSSH(gitx.SSH{
|
||||
KeyPath: e.SSHKey,
|
||||
KnownHosts: e.KnownHosts,
|
||||
StrictHostKey: e.StrictHostKey,
|
||||
}, s.Home, tmp)
|
||||
if err != nil {
|
||||
return o, err
|
||||
}
|
||||
o.SSHCmd = sshCmd
|
||||
return o, nil
|
||||
}
|
||||
|
||||
// needsPush reports whether dst differs from the mirror in any way we manage.
|
||||
func needsPush(local, dst gitx.Refs, prune bool) bool {
|
||||
for ref, obj := range local {
|
||||
if dst[ref] != obj {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if prune {
|
||||
for ref := range dst {
|
||||
if _, ok := local[ref]; !ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
package syncer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"syncbot/internal/config"
|
||||
"syncbot/internal/gitx"
|
||||
)
|
||||
|
||||
// harness wires up a real src repo, a real bare dst repo and a Syncer, so the
|
||||
// tests exercise the actual git plumbing rather than a mock of it.
|
||||
type harness struct {
|
||||
t *testing.T
|
||||
src string
|
||||
dst string
|
||||
job config.Job
|
||||
sync *Syncer
|
||||
}
|
||||
|
||||
func newHarness(t *testing.T) *harness {
|
||||
t.Helper()
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
t.Skip("git not installed")
|
||||
}
|
||||
|
||||
root := t.TempDir()
|
||||
src := filepath.Join(root, "src")
|
||||
dst := filepath.Join(root, "dst.git")
|
||||
home := filepath.Join(root, "home")
|
||||
for _, d := range []string{src, dst, home} {
|
||||
if err := os.MkdirAll(d, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
git(t, src, "init", "--quiet", "-b", "main")
|
||||
git(t, dst, "init", "--bare", "--quiet", "-b", "main")
|
||||
// A bare repo refuses to have the branch its HEAD points at deleted, which
|
||||
// would otherwise make the allow_empty case untestable. GitHub behaves the
|
||||
// same way for its default branch; see the README's troubleshooting notes.
|
||||
git(t, dst, "config", "receive.denyDeleteCurrent", "ignore")
|
||||
|
||||
h := &harness{
|
||||
t: t,
|
||||
src: src,
|
||||
dst: dst,
|
||||
job: config.Job{
|
||||
Name: "test",
|
||||
Src: config.Endpoint{URL: src},
|
||||
Dst: config.Endpoint{URL: dst},
|
||||
Dir: filepath.Join(root, "mirror.git"),
|
||||
Interval: time.Minute,
|
||||
Timeout: 2 * time.Minute,
|
||||
MaxBackoff: time.Minute,
|
||||
Refs: config.DefaultRefs,
|
||||
Prune: true,
|
||||
Force: true,
|
||||
},
|
||||
sync: &Syncer{Log: slog.New(slog.NewTextHandler(io.Discard, nil)), Home: home},
|
||||
}
|
||||
h.commit("first")
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *harness) commit(msg string) {
|
||||
h.t.Helper()
|
||||
path := filepath.Join(h.src, "file.txt")
|
||||
if err := os.WriteFile(path, []byte(msg+"\n"), 0o600); err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
git(h.t, h.src, "add", "-A")
|
||||
git(h.t, h.src, "commit", "--quiet", "-m", msg)
|
||||
}
|
||||
|
||||
func (h *harness) run() Result {
|
||||
h.t.Helper()
|
||||
res, err := h.sync.Sync(context.Background(), h.job)
|
||||
if err != nil {
|
||||
h.t.Fatalf("sync: %v", err)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func (h *harness) refs(dir string) gitx.Refs {
|
||||
h.t.Helper()
|
||||
out := git(h.t, dir, "for-each-ref", "--format=%(objectname)\t%(refname)")
|
||||
refs := gitx.Refs{}
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
if obj, ref, ok := strings.Cut(strings.TrimSpace(line), "\t"); ok {
|
||||
refs[ref] = obj
|
||||
}
|
||||
}
|
||||
return refs
|
||||
}
|
||||
|
||||
// assertMirrored checks that dst holds exactly what src holds.
|
||||
func (h *harness) assertMirrored() {
|
||||
h.t.Helper()
|
||||
src, dst := h.refs(h.src), h.refs(h.dst)
|
||||
if !src.Equal(dst) {
|
||||
h.t.Fatalf("dst does not mirror src\n src: %v\n dst: %v", src, dst)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirstSyncCopiesEverything(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
git(t, h.src, "tag", "-a", "v1.0.0", "-m", "release")
|
||||
git(t, h.src, "branch", "feature/x")
|
||||
|
||||
res := h.run()
|
||||
if !res.Fetched || !res.Pushed {
|
||||
t.Fatalf("want fetch and push on first sync, got %+v", res)
|
||||
}
|
||||
h.assertMirrored()
|
||||
|
||||
dst := h.refs(h.dst)
|
||||
for _, want := range []string{"refs/heads/main", "refs/heads/feature/x", "refs/tags/v1.0.0"} {
|
||||
if _, ok := dst[want]; !ok {
|
||||
t.Errorf("dst missing %s (has %v)", want, dst)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoChangesDoesNothing(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
h.run()
|
||||
|
||||
res := h.run()
|
||||
if res.Fetched {
|
||||
t.Error("fetched despite src being unchanged")
|
||||
}
|
||||
if res.Pushed {
|
||||
t.Error("pushed despite dst already being in sync")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCommitPropagates(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
h.run()
|
||||
|
||||
h.commit("second")
|
||||
res := h.run()
|
||||
if !res.Fetched || !res.Pushed {
|
||||
t.Fatalf("want fetch and push after a new commit, got %+v", res)
|
||||
}
|
||||
h.assertMirrored()
|
||||
}
|
||||
|
||||
func TestDeletedBranchIsPruned(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
git(t, h.src, "branch", "temp")
|
||||
h.run()
|
||||
if _, ok := h.refs(h.dst)["refs/heads/temp"]; !ok {
|
||||
t.Fatal("setup: dst should have refs/heads/temp")
|
||||
}
|
||||
|
||||
git(t, h.src, "branch", "-D", "temp")
|
||||
h.run()
|
||||
|
||||
if _, ok := h.refs(h.dst)["refs/heads/temp"]; ok {
|
||||
t.Error("refs/heads/temp still on dst after being deleted upstream")
|
||||
}
|
||||
h.assertMirrored()
|
||||
}
|
||||
|
||||
func TestForcePushAfterRewrite(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
h.commit("second")
|
||||
h.run()
|
||||
|
||||
// Rewrite history the way a rebase or an amended commit would.
|
||||
git(t, h.src, "reset", "--hard", "--quiet", "HEAD~1")
|
||||
h.commit("rewritten")
|
||||
res := h.run()
|
||||
|
||||
if !res.Pushed {
|
||||
t.Fatal("want a push after history was rewritten")
|
||||
}
|
||||
h.assertMirrored()
|
||||
}
|
||||
|
||||
// The bot keeps no state between runs, so damage done directly to dst must heal
|
||||
// on the next cycle even though src has not moved.
|
||||
func TestDestinationDriftIsRepaired(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
git(t, h.src, "branch", "keep")
|
||||
h.run()
|
||||
|
||||
git(t, h.dst, "update-ref", "-d", "refs/heads/keep")
|
||||
if _, ok := h.refs(h.dst)["refs/heads/keep"]; ok {
|
||||
t.Fatal("setup: refs/heads/keep should be gone from dst")
|
||||
}
|
||||
|
||||
res := h.run()
|
||||
if res.Fetched {
|
||||
t.Error("fetched even though src had not changed")
|
||||
}
|
||||
if !res.Pushed {
|
||||
t.Fatal("want a push to repair dst")
|
||||
}
|
||||
h.assertMirrored()
|
||||
}
|
||||
|
||||
func TestEmptySourceIsRefused(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
h.run()
|
||||
|
||||
// Simulate a source that answers but has nothing to offer — a revoked
|
||||
// token or a wrong URL looks exactly like this.
|
||||
empty := filepath.Join(t.TempDir(), "empty.git")
|
||||
git(t, t.TempDir(), "init", "--bare", "--quiet", empty)
|
||||
h.job.Src = config.Endpoint{URL: empty}
|
||||
|
||||
_, err := h.sync.Sync(context.Background(), h.job)
|
||||
if err == nil {
|
||||
t.Fatal("want an error when an empty source would wipe dst")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "allow_empty") {
|
||||
t.Errorf("error should point at allow_empty, got: %v", err)
|
||||
}
|
||||
if len(h.refs(h.dst)) == 0 {
|
||||
t.Error("dst was wiped despite the guard")
|
||||
}
|
||||
|
||||
// With the guard lifted the wipe goes through, as documented.
|
||||
h.job.AllowEmpty = true
|
||||
if _, err := h.sync.Sync(context.Background(), h.job); err != nil {
|
||||
t.Fatalf("sync with allow_empty: %v", err)
|
||||
}
|
||||
if n := len(h.refs(h.dst)); n != 0 {
|
||||
t.Errorf("dst should be empty with allow_empty = true, has %d refs", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefsFilterLimitsWhatIsMirrored(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
git(t, h.src, "tag", "v1")
|
||||
h.job.Refs = []string{"refs/heads/*"}
|
||||
|
||||
h.run()
|
||||
|
||||
dst := h.refs(h.dst)
|
||||
if _, ok := dst["refs/heads/main"]; !ok {
|
||||
t.Error("branches should be mirrored")
|
||||
}
|
||||
if _, ok := dst["refs/tags/v1"]; ok {
|
||||
t.Error("tags should not be mirrored when refs excludes them")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnreachableSourceReportsError(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
h.job.Src = config.Endpoint{URL: filepath.Join(t.TempDir(), "does-not-exist.git")}
|
||||
h.job.Timeout = 30 * time.Second
|
||||
|
||||
if _, err := h.sync.Sync(context.Background(), h.job); err == nil {
|
||||
t.Fatal("want an error for an unreachable source")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimeoutIsEnforced(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
h.job.Timeout = time.Nanosecond
|
||||
|
||||
_, err := h.sync.Sync(context.Background(), h.job)
|
||||
if err == nil {
|
||||
t.Fatal("want an error when the timeout expires")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeedsPush(t *testing.T) {
|
||||
local := gitx.Refs{"refs/heads/main": "aaa"}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
local gitx.Refs
|
||||
dst gitx.Refs
|
||||
prune bool
|
||||
want bool
|
||||
}{
|
||||
{"identical", local, gitx.Refs{"refs/heads/main": "aaa"}, true, false},
|
||||
{"moved", local, gitx.Refs{"refs/heads/main": "bbb"}, true, true},
|
||||
{"missing on dst", local, gitx.Refs{}, true, true},
|
||||
{"extra on dst, pruning", local, gitx.Refs{"refs/heads/main": "aaa", "refs/heads/x": "c"}, true, true},
|
||||
{"extra on dst, not pruning", local, gitx.Refs{"refs/heads/main": "aaa", "refs/heads/x": "c"}, false, false},
|
||||
{"both empty", gitx.Refs{}, gitx.Refs{}, true, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := needsPush(tc.local, tc.dst, tc.prune); got != tc.want {
|
||||
t.Errorf("needsPush = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func git(t *testing.T, dir string, args ...string) string {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = dir
|
||||
cmd.Env = append(os.Environ(),
|
||||
"GIT_CONFIG_GLOBAL="+os.DevNull,
|
||||
"GIT_CONFIG_SYSTEM="+os.DevNull,
|
||||
"GIT_AUTHOR_NAME=syncbot test",
|
||||
"GIT_AUTHOR_EMAIL=test@example.invalid",
|
||||
"GIT_COMMITTER_NAME=syncbot test",
|
||||
"GIT_COMMITTER_EMAIL=test@example.invalid",
|
||||
)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out)
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
Reference in New Issue
Block a user