164 lines
5.0 KiB
Go
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
|
|
}
|