Files
syncbot/internal/syncer/syncer.go
T
iceBear67 a006483bbc
ci / test (push) Canceled after 0s
docker / build (push) Canceled after 0s
init
2026-08-14 07:13:22 +00:00

164 lines
5.0 KiB
Go

// 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
}