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
|
||||
}
|
||||
Reference in New Issue
Block a user