init
ci / test (push) Canceled after 0s
docker / build (push) Canceled after 0s

This commit is contained in:
iceBear67
2026-08-14 07:13:22 +00:00
commit a006483bbc
26 changed files with 3826 additions and 0 deletions
+478
View File
@@ -0,0 +1,478 @@
// Package config loads, validates and resolves syncbot's TOML configuration.
//
// The on-disk document is deliberately forgiving: every knob has a default,
// [global] supplies fallbacks for all repositories, and each [[repo]] overrides
// only what it needs. Load flattens all of that into a []Job in which every
// value is already resolved, so the rest of the daemon never has to reason
// about defaults or inheritance — and so the hot-reload logic can decide
// whether a job changed with a plain reflect.DeepEqual.
package config
import (
"fmt"
"os"
"path/filepath"
"regexp"
"runtime"
"sort"
"strings"
"time"
"github.com/BurntSushi/toml"
)
// Duration is a time.Duration that decodes from a TOML string such as "5m".
type Duration time.Duration
// UnmarshalText implements encoding.TextUnmarshaler.
func (d *Duration) UnmarshalText(b []byte) error {
v, err := time.ParseDuration(string(b))
if err != nil {
return fmt.Errorf("invalid duration %q (want e.g. \"30s\", \"5m\", \"2h\")", b)
}
*d = Duration(v)
return nil
}
// D returns the underlying time.Duration.
func (d Duration) D() time.Duration { return time.Duration(d) }
// Endpoint is one side of a mirror. In TOML it may be written either as a bare
// URL string or as a table when it needs its own credentials:
//
// dst = "git@github.com:me/repo.git"
//
// [repo.dst]
// url = "git@github.com:me/repo.git"
// ssh_key = "/etc/syncbot/keys/repo"
type Endpoint struct {
URL string `json:"url"`
SSHKey string `json:"-"`
KnownHosts string `json:"-"`
StrictHostKey string `json:"-"`
}
// UnmarshalTOML accepts both the string and the table spelling of an endpoint.
func (e *Endpoint) UnmarshalTOML(v any) error {
switch t := v.(type) {
case string:
e.URL = t
return nil
case map[string]any:
for _, k := range sortedKeys(t) {
s, ok := t[k].(string)
if !ok {
return fmt.Errorf("key %q must be a string", k)
}
switch k {
case "url":
e.URL = s
case "ssh_key":
e.SSHKey = s
case "known_hosts":
e.KnownHosts = s
case "strict_host_key":
e.StrictHostKey = s
default:
return fmt.Errorf("unknown key %q (want url, ssh_key, known_hosts or strict_host_key)", k)
}
}
if e.URL == "" {
return fmt.Errorf("missing required key \"url\"")
}
return nil
default:
return fmt.Errorf("must be a URL string or a table, got %T", v)
}
}
// Global holds process-wide settings plus the defaults inherited by every repo.
type Global struct {
WorkDir string `toml:"work_dir"`
Listen string `toml:"listen"`
LogLevel string `toml:"log_level"`
LogFormat string `toml:"log_format"`
Concurrency int `toml:"concurrency"`
ReloadInterval Duration `toml:"reload_interval"`
// Inherited by every [[repo]] unless overridden there.
Interval Duration `toml:"interval"`
Timeout Duration `toml:"timeout"`
MaxBackoff Duration `toml:"max_backoff"`
Refs []string `toml:"refs"`
Prune *bool `toml:"prune"`
Force *bool `toml:"force"`
Atomic *bool `toml:"atomic"`
AllowEmpty *bool `toml:"allow_empty"`
SSHKey string `toml:"ssh_key"`
KnownHosts string `toml:"known_hosts"`
StrictHostKey string `toml:"strict_host_key"`
GitConfig []string `toml:"git_config"`
}
// Repo is one [[repo]] block as written by the user.
type Repo struct {
Name string `toml:"name"`
Src Endpoint `toml:"src"`
Dst Endpoint `toml:"dst"`
Enabled *bool `toml:"enabled"`
Interval Duration `toml:"interval"`
Timeout Duration `toml:"timeout"`
MaxBackoff Duration `toml:"max_backoff"`
Refs []string `toml:"refs"`
Prune *bool `toml:"prune"`
Force *bool `toml:"force"`
Atomic *bool `toml:"atomic"`
AllowEmpty *bool `toml:"allow_empty"`
SSHKey string `toml:"ssh_key"`
KnownHosts string `toml:"known_hosts"`
StrictHostKey string `toml:"strict_host_key"`
GitConfig []string `toml:"git_config"`
}
// file mirrors the TOML document itself.
type file struct {
Global Global `toml:"global"`
Repos []Repo `toml:"repo"`
}
// Job is a fully resolved sync unit: all defaults folded in, ready to run.
// Every field is comparable with reflect.DeepEqual, which is how the manager
// detects that a reloaded config actually changed something for this repo.
type Job struct {
Name string
Src Endpoint
Dst Endpoint
Dir string // local bare mirror
Interval time.Duration
Timeout time.Duration
MaxBackoff time.Duration
Refs []string
Prune bool
Force bool
Atomic bool
AllowEmpty bool
GitConfig []string
}
// Config is the resolved configuration the daemon runs on.
type Config struct {
WorkDir string
Listen string
LogLevel string
LogFormat string
Concurrency int
ReloadInterval time.Duration
Jobs []Job
}
// Defaults applied when the document leaves a value out.
const (
DefaultWorkDir = "/var/lib/syncbot"
DefaultInterval = 5 * time.Minute
DefaultTimeout = 30 * time.Minute
DefaultMaxBackoff = time.Hour
DefaultReloadInterval = 5 * time.Second
DefaultLogLevel = "info"
DefaultLogFormat = "text"
)
// DefaultRefs mirrors branches and tags, which is what almost everyone wants.
var DefaultRefs = []string{"refs/heads/*", "refs/tags/*"}
// nameRe keeps repo names usable as directory names and Prometheus labels.
var nameRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`)
// Load reads, validates and resolves the config file at path.
func Load(path string) (*Config, error) {
b, err := os.ReadFile(path)
if err != nil {
return nil, err
}
return Parse(b)
}
// Parse resolves an in-memory TOML document. Load is the usual entry point;
// Parse exists so tests (and -check) can work without touching disk.
func Parse(b []byte) (*Config, error) {
var f file
md, err := toml.Decode(string(b), &f)
if err != nil {
return nil, err
}
if undec := unknownKeys(md); len(undec) > 0 {
return nil, fmt.Errorf("unknown config key(s): %s", strings.Join(undec, ", "))
}
return resolve(&f)
}
// unknownKeys reports keys the decoder did not recognise, so a typo like
// "intervall" fails loudly at startup instead of silently doing nothing.
func unknownKeys(md toml.MetaData) []string {
var out []string
for _, k := range md.Undecoded() {
s := k.String()
// Endpoint consumes its own subtree via UnmarshalTOML and validates the
// keys itself; the decoder cannot see into it, so ignore those paths.
if strings.Contains(s, ".src.") || strings.Contains(s, ".dst.") {
continue
}
out = append(out, s)
}
sort.Strings(out)
return out
}
func resolve(f *file) (*Config, error) {
g := f.Global
workDir, err := expandEnv(orString(g.WorkDir, DefaultWorkDir))
if err != nil {
return nil, fmt.Errorf("global.work_dir: %w", err)
}
if !filepath.IsAbs(workDir) {
if workDir, err = filepath.Abs(workDir); err != nil {
return nil, fmt.Errorf("global.work_dir: %w", err)
}
}
cfg := &Config{
WorkDir: workDir,
Listen: g.Listen,
LogLevel: strings.ToLower(orString(g.LogLevel, DefaultLogLevel)),
LogFormat: strings.ToLower(orString(g.LogFormat, DefaultLogFormat)),
Concurrency: g.Concurrency,
ReloadInterval: orDuration(g.ReloadInterval, DefaultReloadInterval),
}
if cfg.Concurrency <= 0 {
cfg.Concurrency = min(4, runtime.NumCPU())
}
switch cfg.LogLevel {
case "debug", "info", "warn", "error":
default:
return nil, fmt.Errorf("global.log_level: %q is not one of debug, info, warn, error", cfg.LogLevel)
}
switch cfg.LogFormat {
case "text", "json":
default:
return nil, fmt.Errorf("global.log_format: %q is not one of text, json", cfg.LogFormat)
}
if cfg.ReloadInterval < time.Second {
return nil, fmt.Errorf("global.reload_interval: must be at least 1s")
}
if len(f.Repos) == 0 {
return nil, fmt.Errorf("no [[repo]] blocks defined: nothing to sync")
}
seen := make(map[string]bool, len(f.Repos))
for i := range f.Repos {
r := &f.Repos[i]
where := fmt.Sprintf("repo[%d]", i)
if r.Name != "" {
where = fmt.Sprintf("repo %q", r.Name)
}
if !nameRe.MatchString(r.Name) {
return nil, fmt.Errorf("%s: name must match %s", where, nameRe)
}
if seen[r.Name] {
return nil, fmt.Errorf("%s: duplicate name", where)
}
seen[r.Name] = true
if enabled := r.Enabled; enabled != nil && !*enabled {
continue
}
job, err := resolveJob(r, &g, workDir)
if err != nil {
return nil, fmt.Errorf("%s: %w", where, err)
}
cfg.Jobs = append(cfg.Jobs, *job)
}
if len(cfg.Jobs) == 0 {
return nil, fmt.Errorf("every [[repo]] is disabled: nothing to sync")
}
sort.Slice(cfg.Jobs, func(i, j int) bool { return cfg.Jobs[i].Name < cfg.Jobs[j].Name })
return cfg, nil
}
func resolveJob(r *Repo, g *Global, workDir string) (*Job, error) {
j := &Job{
Name: r.Name,
Dir: filepath.Join(workDir, "mirrors", r.Name+".git"),
Interval: pick(r.Interval, g.Interval, DefaultInterval),
Timeout: pick(r.Timeout, g.Timeout, DefaultTimeout),
MaxBackoff: pick(r.MaxBackoff, g.MaxBackoff, DefaultMaxBackoff),
Refs: orSlice(r.Refs, g.Refs, DefaultRefs),
Prune: orBool(true, r.Prune, g.Prune),
Force: orBool(true, r.Force, g.Force),
Atomic: orBool(false, r.Atomic, g.Atomic),
AllowEmpty: orBool(false, r.AllowEmpty, g.AllowEmpty),
GitConfig: orSlice(r.GitConfig, g.GitConfig, nil),
}
var err error
// SSH settings cascade: endpoint table -> [[repo]] -> [global].
if j.Src, err = resolveEndpoint(r.Src, r, g); err != nil {
return nil, fmt.Errorf("src: %w", err)
}
if j.Dst, err = resolveEndpoint(r.Dst, r, g); err != nil {
return nil, fmt.Errorf("dst: %w", err)
}
if j.Src.URL == "" {
return nil, fmt.Errorf("src is required")
}
if j.Dst.URL == "" {
return nil, fmt.Errorf("dst is required")
}
if j.Src.URL == j.Dst.URL {
return nil, fmt.Errorf("src and dst are the same repository")
}
if j.Interval <= 0 {
return nil, fmt.Errorf("interval must be positive")
}
if j.Timeout <= 0 {
return nil, fmt.Errorf("timeout must be positive")
}
if j.MaxBackoff < j.Interval {
j.MaxBackoff = j.Interval
}
if len(j.Refs) == 0 {
return nil, fmt.Errorf("refs must not be empty")
}
for _, p := range j.Refs {
if !strings.HasPrefix(p, "refs/") {
return nil, fmt.Errorf("refs: %q must start with \"refs/\"", p)
}
if strings.Count(p, "*") > 1 {
return nil, fmt.Errorf("refs: %q may contain at most one \"*\"", p)
}
}
for _, kv := range j.GitConfig {
if !strings.Contains(kv, "=") {
return nil, fmt.Errorf("git_config: %q must be in key=value form", kv)
}
}
return j, nil
}
func resolveEndpoint(e Endpoint, r *Repo, g *Global) (Endpoint, error) {
out := Endpoint{
URL: e.URL,
SSHKey: orString(e.SSHKey, r.SSHKey, g.SSHKey),
KnownHosts: orString(e.KnownHosts, r.KnownHosts, g.KnownHosts),
StrictHostKey: orString(e.StrictHostKey, r.StrictHostKey, g.StrictHostKey),
}
var err error
for _, p := range []*string{&out.URL, &out.SSHKey, &out.KnownHosts} {
if *p, err = expandEnv(*p); err != nil {
return out, err
}
}
if out.StrictHostKey == "" {
// With a pinned known_hosts file we can afford to be strict; without
// one, trust-on-first-use is the only thing that can work unattended.
if out.KnownHosts != "" {
out.StrictHostKey = "yes"
} else {
out.StrictHostKey = "accept-new"
}
}
switch out.StrictHostKey {
case "yes", "no", "accept-new":
default:
return out, fmt.Errorf("strict_host_key: %q is not one of yes, no, accept-new", out.StrictHostKey)
}
if out.SSHKey != "" && !filepath.IsAbs(out.SSHKey) {
return out, fmt.Errorf("ssh_key: %q must be an absolute path", out.SSHKey)
}
return out, nil
}
// envRe matches ${VAR}. Bare $VAR is deliberately not expanded so that secrets
// containing a literal '$' survive unharmed.
var envRe = regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*)\}`)
// expandEnv substitutes ${VAR} references, failing loudly on undefined names —
// silently expanding to "" would produce a subtly broken URL instead.
func expandEnv(s string) (string, error) {
if !strings.Contains(s, "${") {
return s, nil
}
var missing []string
out := envRe.ReplaceAllStringFunc(s, func(m string) string {
name := m[2 : len(m)-1]
v, ok := os.LookupEnv(name)
if !ok {
missing = append(missing, name)
return ""
}
return v
})
if len(missing) > 0 {
return "", fmt.Errorf("undefined environment variable(s): %s", strings.Join(missing, ", "))
}
return out, nil
}
func orString(vs ...string) string {
for _, v := range vs {
if v != "" {
return v
}
}
return ""
}
func orBool(def bool, vs ...*bool) bool {
for _, v := range vs {
if v != nil {
return *v
}
}
return def
}
func orSlice(vs ...[]string) []string {
for _, v := range vs {
if len(v) > 0 {
return append([]string(nil), v...)
}
}
return nil
}
func orDuration(v Duration, def time.Duration) time.Duration {
if v != 0 {
return v.D()
}
return def
}
func pick(repo, global Duration, def time.Duration) time.Duration {
if repo != 0 {
return repo.D()
}
if global != 0 {
return global.D()
}
return def
}
func sortedKeys(m map[string]any) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
// orBool with a literal default needs a *bool; these make the call sites read
// naturally without sprinkling helper variables around.
func boolPtr(b bool) *bool { return &b }
+314
View File
@@ -0,0 +1,314 @@
package config
import (
"strings"
"testing"
"time"
)
const minimal = `
[[repo]]
name = "demo"
src = "https://git.example.com/demo.git"
dst = "git@github.com:me/demo.git"
`
func mustParse(t *testing.T, doc string) *Config {
t.Helper()
cfg, err := Parse([]byte(doc))
if err != nil {
t.Fatalf("parse: %v", err)
}
return cfg
}
func TestDefaultsAreApplied(t *testing.T) {
cfg := mustParse(t, minimal)
if cfg.WorkDir != DefaultWorkDir {
t.Errorf("work_dir = %q, want %q", cfg.WorkDir, DefaultWorkDir)
}
if cfg.LogLevel != "info" || cfg.LogFormat != "text" {
t.Errorf("log defaults = %q/%q", cfg.LogLevel, cfg.LogFormat)
}
if cfg.Concurrency < 1 {
t.Errorf("concurrency = %d, want >= 1", cfg.Concurrency)
}
j := cfg.Jobs[0]
if j.Interval != DefaultInterval || j.Timeout != DefaultTimeout {
t.Errorf("interval/timeout = %s/%s", j.Interval, j.Timeout)
}
if !j.Prune || !j.Force {
t.Error("prune and force should default to true for a mirror")
}
if j.Atomic || j.AllowEmpty {
t.Error("atomic and allow_empty should default to false")
}
if strings.Join(j.Refs, ",") != strings.Join(DefaultRefs, ",") {
t.Errorf("refs = %v, want %v", j.Refs, DefaultRefs)
}
if !strings.HasSuffix(j.Dir, "mirrors/demo.git") {
t.Errorf("mirror dir = %q", j.Dir)
}
}
func TestGlobalDefaultsCascadeAndRepoOverrides(t *testing.T) {
cfg := mustParse(t, `
[global]
work_dir = "/data"
interval = "10m"
prune = false
ssh_key = "/keys/shared"
[[repo]]
name = "inherits"
src = "https://example.com/a.git"
dst = "git@github.com:me/a.git"
[[repo]]
name = "overrides"
src = "https://example.com/b.git"
dst = "git@github.com:me/b.git"
interval = "30s"
prune = true
ssh_key = "/keys/b"
`)
byName := map[string]Job{}
for _, j := range cfg.Jobs {
byName[j.Name] = j
}
a := byName["inherits"]
if a.Interval != 10*time.Minute {
t.Errorf("inherited interval = %s, want 10m", a.Interval)
}
if a.Prune {
t.Error("inherited prune should be false")
}
if a.Dst.SSHKey != "/keys/shared" {
t.Errorf("inherited ssh_key = %q", a.Dst.SSHKey)
}
b := byName["overrides"]
if b.Interval != 30*time.Second {
t.Errorf("overridden interval = %s, want 30s", b.Interval)
}
if !b.Prune {
t.Error("overridden prune should be true")
}
if b.Dst.SSHKey != "/keys/b" {
t.Errorf("overridden ssh_key = %q", b.Dst.SSHKey)
}
}
func TestEndpointAcceptsStringOrTable(t *testing.T) {
cfg := mustParse(t, `
[[repo]]
name = "demo"
src = "https://git.example.com/demo.git"
[repo.dst]
url = "git@github.com:me/demo.git"
ssh_key = "/keys/demo"
known_hosts = "/etc/syncbot/known_hosts"
strict_host_key = "yes"
`)
j := cfg.Jobs[0]
if j.Src.URL != "https://git.example.com/demo.git" {
t.Errorf("src url = %q", j.Src.URL)
}
if j.Dst.URL != "git@github.com:me/demo.git" {
t.Errorf("dst url = %q", j.Dst.URL)
}
if j.Dst.SSHKey != "/keys/demo" || j.Dst.KnownHosts != "/etc/syncbot/known_hosts" {
t.Errorf("dst ssh settings = %+v", j.Dst)
}
if j.Dst.StrictHostKey != "yes" {
t.Errorf("strict_host_key = %q", j.Dst.StrictHostKey)
}
// src has no key of its own and none was inherited.
if j.Src.SSHKey != "" {
t.Errorf("src ssh_key = %q, want empty", j.Src.SSHKey)
}
}
func TestStrictHostKeyDefaultFollowsKnownHosts(t *testing.T) {
cfg := mustParse(t, minimal)
if got := cfg.Jobs[0].Dst.StrictHostKey; got != "accept-new" {
t.Errorf("without known_hosts: %q, want accept-new", got)
}
cfg = mustParse(t, minimal+`
[global]
known_hosts = "/etc/syncbot/known_hosts"
`)
if got := cfg.Jobs[0].Dst.StrictHostKey; got != "yes" {
t.Errorf("with known_hosts: %q, want yes", got)
}
}
func TestEnvExpansion(t *testing.T) {
t.Setenv("SYNCBOT_TEST_TOKEN", "s3cr#t$")
cfg := mustParse(t, `
[[repo]]
name = "demo"
src = "https://x-access-token:${SYNCBOT_TEST_TOKEN}@github.com/me/demo.git"
dst = "git@github.com:me/mirror.git"
`)
want := "https://x-access-token:s3cr#t$@github.com/me/demo.git"
if got := cfg.Jobs[0].Src.URL; got != want {
t.Errorf("expanded src = %q, want %q", got, want)
}
}
func TestUndefinedEnvVarIsAnError(t *testing.T) {
_, err := Parse([]byte(`
[[repo]]
name = "demo"
src = "https://${SYNCBOT_DEFINITELY_UNSET}@example.com/a.git"
dst = "git@github.com:me/a.git"
`))
if err == nil || !strings.Contains(err.Error(), "SYNCBOT_DEFINITELY_UNSET") {
t.Fatalf("want an error naming the missing variable, got %v", err)
}
}
func TestDisabledRepoIsSkipped(t *testing.T) {
cfg := mustParse(t, `
[[repo]]
name = "on"
src = "https://example.com/a.git"
dst = "git@github.com:me/a.git"
[[repo]]
name = "off"
enabled = false
src = "https://example.com/b.git"
dst = "git@github.com:me/b.git"
`)
if len(cfg.Jobs) != 1 || cfg.Jobs[0].Name != "on" {
t.Fatalf("want only the enabled repo, got %d: %+v", len(cfg.Jobs), cfg.Jobs)
}
}
func TestValidationErrors(t *testing.T) {
cases := []struct{ name, doc, want string }{
{"no repos", `[global]
work_dir = "/data"`, "nothing to sync"},
{"missing name", `[[repo]]
src = "a"
dst = "b"`, "name must match"},
{"bad name", `[[repo]]
name = "../escape"
src = "a"
dst = "b"`, "name must match"},
{"duplicate name", `[[repo]]
name = "x"
src = "a"
dst = "b"
[[repo]]
name = "x"
src = "c"
dst = "d"`, "duplicate name"},
{"missing dst", `[[repo]]
name = "x"
src = "a"`, "dst is required"},
{"same src and dst", `[[repo]]
name = "x"
src = "a"
dst = "a"`, "same repository"},
{"unknown key", `[[repo]]
name = "x"
src = "a"
dst = "b"
intervall = "5m"`, "unknown config key"},
{"bad duration", `[[repo]]
name = "x"
src = "a"
dst = "b"
interval = "5 minutes"`, "invalid duration"},
{"relative ssh key", `[[repo]]
name = "x"
src = "a"
dst = "b"
ssh_key = "keys/x"`, "absolute path"},
{"ref without prefix", `[[repo]]
name = "x"
src = "a"
dst = "b"
refs = ["heads/*"]`, `must start with "refs/"`},
{"two globs", `[[repo]]
name = "x"
src = "a"
dst = "b"
refs = ["refs/*/*"]`, `at most one`},
{"bad log level", `[global]
log_level = "verbose"
[[repo]]
name = "x"
src = "a"
dst = "b"`, "log_level"},
{"bad endpoint key", `[[repo]]
name = "x"
src = "a"
[repo.dst]
url = "b"
sshkey = "/k"`, "unknown key"},
{"endpoint table without url", `[[repo]]
name = "x"
src = "a"
[repo.dst]
ssh_key = "/k"`, "url"},
{"bad git_config", `[[repo]]
name = "x"
src = "a"
dst = "b"
git_config = ["pack.threads"]`, "key=value"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, err := Parse([]byte(tc.doc))
if err == nil {
t.Fatalf("want an error mentioning %q", tc.want)
}
if !strings.Contains(err.Error(), tc.want) {
t.Errorf("error = %q, want it to mention %q", err, tc.want)
}
})
}
}
func TestMaxBackoffNeverBelowInterval(t *testing.T) {
cfg := mustParse(t, `
[[repo]]
name = "x"
src = "a"
dst = "b"
interval = "10m"
max_backoff = "1m"
`)
if got := cfg.Jobs[0].MaxBackoff; got != 10*time.Minute {
t.Errorf("max_backoff = %s, want it raised to the interval (10m)", got)
}
}
func TestJobsAreSortedForStableDiffs(t *testing.T) {
cfg := mustParse(t, `
[[repo]]
name = "zulu"
src = "a"
dst = "b"
[[repo]]
name = "alpha"
src = "c"
dst = "d"
`)
if cfg.Jobs[0].Name != "alpha" || cfg.Jobs[1].Name != "zulu" {
t.Errorf("jobs not sorted: %s, %s", cfg.Jobs[0].Name, cfg.Jobs[1].Name)
}
}
+444
View File
@@ -0,0 +1,444 @@
// Package gitx is a thin, hermetic wrapper around the git command line.
//
// Shelling out to git — rather than linking a pure-Go implementation — keeps
// the binary small and the memory profile flat: the heavy lifting happens in a
// short-lived child process that the kernel reclaims when it exits.
//
// Every invocation runs with an isolated HOME and with the system/global git
// config disabled, so syncbot behaves identically no matter whose account or
// container it runs in.
package gitx
import (
"bytes"
"context"
"fmt"
"io"
"log/slog"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
// SSH describes how to authenticate to one endpoint over SSH.
type SSH struct {
KeyPath string // deploy key; empty means "use the agent / default keys"
KnownHosts string // pinned host key file; empty means use Home/known_hosts
StrictHostKey string // yes | no | accept-new
}
// Options carry everything a git invocation needs beyond its arguments.
type Options struct {
Log *slog.Logger
Home string // isolated HOME for git and ssh; must exist
GitConfig []string // extra "key=value" settings passed as -c
SSHCmd string // pre-built GIT_SSH_COMMAND, see PrepareSSH
Secrets []string // substrings scrubbed from logs and error messages
}
// Refs maps a full ref name to the object it points at.
type Refs map[string]string
// Equal reports whether two ref sets are identical.
func (r Refs) Equal(other Refs) bool {
if len(r) != len(other) {
return false
}
for k, v := range r {
if other[k] != v {
return false
}
}
return true
}
// termGrace is how long a git child gets to exit after SIGTERM before we
// escalate to SIGKILL on its whole process group.
const termGrace = 3 * time.Second
// Run executes git with the given arguments and returns its stdout.
//
// The child is put in its own process group so that a timeout or a shutdown
// takes down the helpers git spawns (ssh, git-remote-https) instead of leaking
// them.
func Run(ctx context.Context, o Options, dir string, args ...string) (string, error) {
full := make([]string, 0, len(args)+2*len(o.GitConfig))
for _, kv := range o.GitConfig {
full = append(full, "-c", kv)
}
full = append(full, args...)
cmd := exec.Command("git", full...)
cmd.Dir = dir
cmd.Env = environ(o)
cmd.SysProcAttr = sysProcAttr()
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &limitedWriter{W: &stderr, N: 64 << 10}
started := time.Now()
if err := cmd.Start(); err != nil {
return "", fmt.Errorf("git %s: %w", args[0], err)
}
// Watchdog: translate context cancellation into signals for the group.
watchdogDone := make(chan struct{})
go func() {
select {
case <-ctx.Done():
terminate(cmd.Process.Pid)
case <-watchdogDone:
}
}()
err := cmd.Wait()
close(watchdogDone)
if o.Log != nil && o.Log.Enabled(ctx, slog.LevelDebug) {
o.Log.Debug("git", "args", scrub(strings.Join(args, " "), o.Secrets),
"dur", time.Since(started).Round(time.Millisecond), "err", err)
}
if err != nil {
msg := scrub(strings.TrimSpace(stderr.String()), o.Secrets)
if ctx.Err() != nil {
return "", fmt.Errorf("git %s: %w (%s)", args[0], ctx.Err(), firstLines(msg, 3))
}
if msg == "" {
msg = err.Error()
}
return "", fmt.Errorf("git %s: %s", args[0], firstLines(msg, 8))
}
return stdout.String(), nil
}
// environ builds a deterministic environment: the parent's, minus anything that
// could redirect git's authentication or config, plus our own settings.
func environ(o Options) []string {
drop := map[string]bool{
"HOME": true, "XDG_CONFIG_HOME": true,
"GIT_SSH": true, "GIT_SSH_COMMAND": true, "GIT_ASKPASS": true, "SSH_ASKPASS": true,
"GIT_CONFIG": true, "GIT_CONFIG_GLOBAL": true, "GIT_CONFIG_SYSTEM": true,
"GIT_DIR": true, "GIT_WORK_TREE": true, "GIT_TERMINAL_PROMPT": true,
}
out := make([]string, 0, 16)
for _, kv := range os.Environ() {
if k, _, ok := strings.Cut(kv, "="); ok && !drop[k] {
out = append(out, kv)
}
}
out = append(out,
"HOME="+o.Home,
"GIT_CONFIG_GLOBAL="+os.DevNull,
"GIT_CONFIG_SYSTEM="+os.DevNull,
"GIT_TERMINAL_PROMPT=0", // never block waiting for a password
"SSH_ASKPASS_REQUIRE=never",
"LC_ALL=C",
)
if o.SSHCmd != "" {
out = append(out, "GIT_SSH_COMMAND="+o.SSHCmd)
}
return out
}
// PrepareSSH builds a GIT_SSH_COMMAND for the endpoint.
//
// Deploy keys are usually mounted read-only from a secret store, which often
// means mode 0644 — and ssh flatly refuses group- or world-readable keys. When
// that happens we copy the key into tmpDir at 0600 rather than asking the
// operator to fix permissions they may not control.
func PrepareSSH(s SSH, home, tmpDir string) (string, error) {
knownHosts := s.KnownHosts
if knownHosts == "" {
knownHosts = filepath.Join(home, "known_hosts")
if _, err := os.Stat(knownHosts); os.IsNotExist(err) {
if err := os.WriteFile(knownHosts, nil, 0o600); err != nil {
return "", fmt.Errorf("create known_hosts: %w", err)
}
}
}
strict := s.StrictHostKey
if strict == "" {
strict = "accept-new"
}
args := []string{"ssh",
"-o", "BatchMode=yes",
"-o", "StrictHostKeyChecking=" + strict,
"-o", "UserKnownHostsFile=" + knownHosts,
"-o", "ConnectTimeout=30",
}
if s.KeyPath != "" {
key, err := usableKey(s.KeyPath, tmpDir)
if err != nil {
return "", err
}
// IdentitiesOnly stops ssh from offering an agent's keys first and
// tripping GitHub's "too many authentication failures".
args = append(args, "-i", key, "-o", "IdentitiesOnly=yes")
}
quoted := make([]string, len(args))
for i, a := range args {
quoted[i] = shellQuote(a)
}
return strings.Join(quoted, " "), nil
}
func usableKey(path, tmpDir string) (string, error) {
fi, err := os.Stat(path)
if err != nil {
return "", fmt.Errorf("ssh_key: %w", err)
}
if fi.IsDir() {
return "", fmt.Errorf("ssh_key: %s is a directory", path)
}
if fi.Mode().Perm()&0o077 == 0 {
return path, nil
}
// Too permissive for ssh: stage a private copy.
b, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("ssh_key: %w", err)
}
dst := filepath.Join(tmpDir, "id_"+filepath.Base(path))
if err := os.WriteFile(dst, b, 0o600); err != nil {
return "", fmt.Errorf("ssh_key: stage private copy: %w", err)
}
return dst, nil
}
// EnsureMirror makes sure dir holds a usable bare repository, creating it on
// first run. A directory that exists but is not a bare repo is reported rather
// than deleted — that is almost always a misconfigured mount, and silently
// wiping it would be the wrong kind of helpful.
func EnsureMirror(ctx context.Context, o Options, dir string) error {
if _, err := os.Stat(filepath.Join(dir, "HEAD")); err == nil {
out, err := Run(ctx, o, dir, "rev-parse", "--is-bare-repository")
if err != nil {
return fmt.Errorf("%s exists but is not a git repository: %w", dir, err)
}
if strings.TrimSpace(out) != "true" {
return fmt.Errorf("%s is not a bare repository", dir)
}
return nil
} else if !os.IsNotExist(err) {
return err
}
if err := os.MkdirAll(dir, 0o700); err != nil {
return err
}
_, err := Run(ctx, o, o.Home, "init", "--bare", "--quiet", "--initial-branch=main", dir)
return err
}
// LsRemote asks a remote which refs it currently has, without transferring any
// objects. This is the "check for updates" probe: cheap enough to run on a
// short interval even against large repositories.
func LsRemote(ctx context.Context, o Options, dir, repoURL string, patterns []string) (Refs, error) {
args := append([]string{"ls-remote", "--refs", "--", repoURL}, patterns...)
out, err := Run(ctx, o, dir, args...)
if err != nil {
return nil, err
}
return parseRefs(out, "\t", true, patterns), nil
}
// LocalRefs reads the mirror's own refs, filtered to the managed patterns.
func LocalRefs(ctx context.Context, o Options, dir string, patterns []string) (Refs, error) {
out, err := Run(ctx, o, dir, "for-each-ref", "--format=%(objectname)\t%(refname)")
if err != nil {
return nil, err
}
return parseRefs(out, "\t", true, patterns), nil
}
// Fetch updates the mirror from src. Refs that vanished upstream are pruned so
// the mirror is an exact copy of the managed namespace, not an accumulation.
func Fetch(ctx context.Context, o Options, dir, src string, patterns []string) error {
args := []string{"fetch", "--force", "--no-tags", "--no-write-fetch-head", "--prune", "--quiet", "--", src}
for _, p := range patterns {
args = append(args, "+"+p+":"+p)
}
_, err := Run(ctx, o, dir, args...)
return err
}
// PushOptions controls how the mirror is written to the destination.
type PushOptions struct {
Prune bool // delete destination refs that no longer exist upstream
Force bool // allow non-fast-forward updates (a mirror must)
Atomic bool // all refs update, or none do
}
// Push writes the mirror's managed refs to dst and returns the porcelain lines
// describing what actually changed.
func Push(ctx context.Context, o Options, dir, dst string, patterns []string, po PushOptions) ([]string, error) {
args := []string{"push", "--porcelain"}
if po.Prune {
args = append(args, "--prune")
}
if po.Force {
args = append(args, "--force")
}
if po.Atomic {
args = append(args, "--atomic")
}
args = append(args, "--", dst)
for _, p := range patterns {
args = append(args, p+":"+p)
}
out, err := Run(ctx, o, dir, args...)
if err != nil {
return nil, err
}
var changed []string
for _, line := range strings.Split(out, "\n") {
line = strings.TrimRight(line, "\r")
// Porcelain format: "<flag>\t<from>:<to>\t<summary>". '=' means the ref
// was already up to date, which is the boring majority.
if line == "" || strings.HasPrefix(line, "To ") || line == "Done" || strings.HasPrefix(line, "=\t") {
continue
}
changed = append(changed, scrub(line, o.Secrets))
}
return changed, nil
}
// MatchRef implements git's refspec globbing: at most one "*", which matches
// any run of characters including "/".
func MatchRef(pattern, ref string) bool {
i := strings.IndexByte(pattern, '*')
if i < 0 {
return pattern == ref
}
prefix, suffix := pattern[:i], pattern[i+1:]
return len(ref) >= len(prefix)+len(suffix) &&
strings.HasPrefix(ref, prefix) &&
strings.HasSuffix(ref, suffix)
}
// MatchAny reports whether ref matches any of the patterns.
func MatchAny(patterns []string, ref string) bool {
for _, p := range patterns {
if MatchRef(p, ref) {
return true
}
}
return false
}
// parseRefs reads "<object><sep><ref>" lines, optionally filtering to patterns.
func parseRefs(out, sep string, filter bool, patterns []string) Refs {
refs := make(Refs)
for _, line := range strings.Split(out, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
obj, ref, ok := strings.Cut(line, sep)
if !ok {
continue
}
// Peeled entries ("refs/tags/v1^{}") describe the commit behind an
// annotated tag; the tag object itself is what we mirror.
if strings.HasSuffix(ref, "^{}") {
continue
}
if filter && !MatchAny(patterns, ref) {
continue
}
refs[ref] = obj
}
return refs
}
// RedactURL strips the password from a URL so it can be logged.
func RedactURL(raw string) string {
if !strings.Contains(raw, "://") {
return raw // scp-style (git@host:path) carries no inline secret
}
u, err := url.Parse(raw)
if err != nil || u.User == nil {
return raw
}
if _, hasPassword := u.User.Password(); hasPassword {
// Plain letters: anything punctuation-ish would come back
// percent-encoded from URL.String() and read as noise in a log line.
u.User = url.UserPassword(u.User.Username(), "redacted")
} else {
u.User = url.User(u.User.Username())
}
return u.String()
}
// URLSecret returns the credential embedded in a URL, if any, so callers can
// register it with Options.Secrets and keep it out of logs.
func URLSecret(raw string) string {
if !strings.Contains(raw, "://") {
return ""
}
u, err := url.Parse(raw)
if err != nil || u.User == nil {
return ""
}
if pw, ok := u.User.Password(); ok && pw != "" {
return pw
}
return ""
}
func scrub(s string, secrets []string) string {
for _, sec := range secrets {
if sec != "" {
s = strings.ReplaceAll(s, sec, "***")
}
}
return s
}
func firstLines(s string, n int) string {
lines := strings.Split(s, "\n")
if len(lines) > n {
lines = append(lines[:n], "...")
}
return strings.Join(lines, "; ")
}
// shellQuote makes a token safe for GIT_SSH_COMMAND, which git hands to a shell.
func shellQuote(s string) string {
if s != "" && !strings.ContainsAny(s, " \t\n\"'\\$`&;|<>()*?[]{}#~!") {
return s
}
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}
// limitedWriter keeps a runaway stderr from growing without bound.
type limitedWriter struct {
W io.Writer
N int
}
// Write always reports the full length: a short write would be treated as an
// error by os/exec and would abort an otherwise healthy git invocation.
func (l *limitedWriter) Write(p []byte) (int, error) {
total := len(p)
if l.N <= 0 {
return total, nil
}
if len(p) > l.N {
p = p[:l.N]
}
n, err := l.W.Write(p)
l.N -= n
return total, err
}
+221
View File
@@ -0,0 +1,221 @@
package gitx
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestMatchRef(t *testing.T) {
cases := []struct {
pattern, ref string
want bool
}{
{"refs/heads/*", "refs/heads/main", true},
{"refs/heads/*", "refs/heads/feature/nested/deep", true}, // '*' spans '/'
{"refs/heads/*", "refs/tags/v1", false},
{"refs/heads/*", "refs/heads/", true},
{"refs/heads/main", "refs/heads/main", true},
{"refs/heads/main", "refs/heads/maint", false},
{"refs/tags/v*", "refs/tags/v1.2.3", true},
{"refs/tags/v*", "refs/tags/rc1", false},
{"refs/heads/*-stable", "refs/heads/2.0-stable", true},
{"refs/heads/*-stable", "refs/heads/2.0-beta", false},
}
for _, tc := range cases {
if got := MatchRef(tc.pattern, tc.ref); got != tc.want {
t.Errorf("MatchRef(%q, %q) = %v, want %v", tc.pattern, tc.ref, got, tc.want)
}
}
}
func TestParseRefsSkipsPeeledAndFilters(t *testing.T) {
out := strings.Join([]string{
"aaa\trefs/heads/main",
"bbb\trefs/tags/v1",
"ccc\trefs/tags/v1^{}", // peeled annotated tag
"ddd\trefs/pull/7/head",
"",
}, "\n")
refs := parseRefs(out, "\t", true, []string{"refs/heads/*", "refs/tags/*"})
if len(refs) != 2 {
t.Fatalf("got %d refs, want 2: %v", len(refs), refs)
}
if refs["refs/heads/main"] != "aaa" || refs["refs/tags/v1"] != "bbb" {
t.Errorf("unexpected refs: %v", refs)
}
if _, ok := refs["refs/tags/v1^{}"]; ok {
t.Error("peeled tag entry should be dropped")
}
if _, ok := refs["refs/pull/7/head"]; ok {
t.Error("unmanaged ref should be filtered out")
}
}
func TestRefsEqual(t *testing.T) {
a := Refs{"refs/heads/main": "1", "refs/tags/v1": "2"}
if !a.Equal(Refs{"refs/tags/v1": "2", "refs/heads/main": "1"}) {
t.Error("same contents should compare equal regardless of order")
}
if a.Equal(Refs{"refs/heads/main": "1"}) {
t.Error("different sizes should not compare equal")
}
if a.Equal(Refs{"refs/heads/main": "1", "refs/tags/v1": "9"}) {
t.Error("different objects should not compare equal")
}
}
func TestRedactURL(t *testing.T) {
cases := []struct{ in, want string }{
{"https://user:token@github.com/me/x.git", "https://user:redacted@github.com/me/x.git"},
{"https://token@github.com/me/x.git", "https://token@github.com/me/x.git"},
{"https://github.com/me/x.git", "https://github.com/me/x.git"},
{"git@github.com:me/x.git", "git@github.com:me/x.git"},
{"/srv/git/local.git", "/srv/git/local.git"},
}
for _, tc := range cases {
if got := RedactURL(tc.in); got != tc.want {
t.Errorf("RedactURL(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
func TestURLSecret(t *testing.T) {
if got := URLSecret("https://x-access-token:ghp_abc@github.com/me/x.git"); got != "ghp_abc" {
t.Errorf("URLSecret = %q, want ghp_abc", got)
}
if got := URLSecret("git@github.com:me/x.git"); got != "" {
t.Errorf("URLSecret = %q, want empty", got)
}
}
func TestScrubRemovesSecrets(t *testing.T) {
got := scrub("fatal: auth failed for ghp_abc123", []string{"ghp_abc123"})
if strings.Contains(got, "ghp_abc123") {
t.Errorf("secret leaked: %q", got)
}
}
func TestShellQuote(t *testing.T) {
cases := []struct{ in, want string }{
{"ssh", "ssh"},
{"/etc/keys/id_ed25519", "/etc/keys/id_ed25519"},
{"/keys/my key", `'/keys/my key'`},
{"it's", `'it'\''s'`},
{"StrictHostKeyChecking=accept-new", "StrictHostKeyChecking=accept-new"},
}
for _, tc := range cases {
if got := shellQuote(tc.in); got != tc.want {
t.Errorf("shellQuote(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
// A deploy key mounted from a secret store is often world-readable, which ssh
// rejects outright. PrepareSSH must stage a 0600 copy instead of failing.
func TestPrepareSSHStagesPermissiveKey(t *testing.T) {
home, tmp := t.TempDir(), t.TempDir()
key := filepath.Join(t.TempDir(), "deploy_key")
if err := os.WriteFile(key, []byte("PRIVATE KEY"), 0o644); err != nil {
t.Fatal(err)
}
cmd, err := PrepareSSH(SSH{KeyPath: key, StrictHostKey: "accept-new"}, home, tmp)
if err != nil {
t.Fatal(err)
}
if strings.Contains(cmd, key) {
t.Errorf("should use a staged copy, not the 0644 original: %s", cmd)
}
staged := filepath.Join(tmp, "id_deploy_key")
fi, err := os.Stat(staged)
if err != nil {
t.Fatalf("staged copy missing: %v", err)
}
if perm := fi.Mode().Perm(); perm != 0o600 {
t.Errorf("staged key mode = %o, want 600", perm)
}
for _, want := range []string{"IdentitiesOnly=yes", "BatchMode=yes", "StrictHostKeyChecking=accept-new"} {
if !strings.Contains(cmd, want) {
t.Errorf("ssh command missing %q: %s", want, cmd)
}
}
}
func TestPrepareSSHKeepsPrivateKeyPath(t *testing.T) {
home, tmp := t.TempDir(), t.TempDir()
key := filepath.Join(t.TempDir(), "deploy_key")
if err := os.WriteFile(key, []byte("PRIVATE KEY"), 0o600); err != nil {
t.Fatal(err)
}
cmd, err := PrepareSSH(SSH{KeyPath: key}, home, tmp)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(cmd, key) {
t.Errorf("an already-private key should be used in place: %s", cmd)
}
}
func TestPrepareSSHCreatesKnownHosts(t *testing.T) {
home, tmp := t.TempDir(), t.TempDir()
if _, err := PrepareSSH(SSH{}, home, tmp); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(home, "known_hosts")); err != nil {
t.Errorf("known_hosts not created in HOME: %v", err)
}
}
func TestEnvironIsHermetic(t *testing.T) {
t.Setenv("GIT_SSH_COMMAND", "ssh -i /attacker/key")
t.Setenv("GIT_CONFIG_GLOBAL", "/attacker/gitconfig")
t.Setenv("HTTPS_PROXY", "http://proxy.internal:3128")
env := environ(Options{Home: "/var/lib/syncbot/home", SSHCmd: "ssh -o BatchMode=yes"})
got := map[string]string{}
for _, kv := range env {
if k, v, ok := strings.Cut(kv, "="); ok {
got[k] = v // later entries win, matching exec's behaviour
}
}
if got["GIT_SSH_COMMAND"] != "ssh -o BatchMode=yes" {
t.Errorf("inherited GIT_SSH_COMMAND not overridden: %q", got["GIT_SSH_COMMAND"])
}
if got["GIT_CONFIG_GLOBAL"] != os.DevNull {
t.Errorf("global git config not disabled: %q", got["GIT_CONFIG_GLOBAL"])
}
if got["HOME"] != "/var/lib/syncbot/home" {
t.Errorf("HOME = %q", got["HOME"])
}
if got["GIT_TERMINAL_PROMPT"] != "0" {
t.Error("git must never prompt for credentials")
}
if got["HTTPS_PROXY"] != "http://proxy.internal:3128" {
t.Error("proxy settings should be inherited")
}
}
func TestLimitedWriterCaps(t *testing.T) {
var sb strings.Builder
w := &limitedWriter{W: &sb, N: 10}
n, err := w.Write([]byte(strings.Repeat("x", 100)))
if err != nil {
t.Fatal(err)
}
if n != 100 {
t.Errorf("Write reported %d, want the full 100 so callers do not see a short write", n)
}
if sb.Len() != 10 {
t.Errorf("captured %d bytes, want the 10-byte cap", sb.Len())
}
}
+19
View File
@@ -0,0 +1,19 @@
//go:build !unix
package gitx
import (
"os"
"syscall"
)
// sysProcAttr has no portable equivalent outside unix; the default is fine.
func sysProcAttr() *syscall.SysProcAttr { return nil }
// terminate kills just the child. Helper processes it spawned may outlive it,
// but syncbot is deployed on Linux, where proc_unix.go handles this properly.
func terminate(pid int) {
if p, err := os.FindProcess(pid); err == nil {
_ = p.Kill()
}
}
+24
View File
@@ -0,0 +1,24 @@
//go:build unix
package gitx
import (
"syscall"
"time"
)
// sysProcAttr puts git in its own process group so we can signal the whole
// tree — git itself plus the ssh or git-remote-https helper it spawned.
func sysProcAttr() *syscall.SysProcAttr {
return &syscall.SysProcAttr{Setpgid: true}
}
// terminate asks the process group to exit, then insists.
func terminate(pid int) {
if pid <= 0 {
return
}
_ = syscall.Kill(-pid, syscall.SIGTERM)
time.Sleep(termGrace)
_ = syscall.Kill(-pid, syscall.SIGKILL)
}
+152
View File
@@ -0,0 +1,152 @@
package manager
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"strconv"
"strings"
"time"
)
// Handler exposes the manager over HTTP:
//
// /healthz the process is alive (use this for a container health check)
// /readyz every repo has synced successfully at least once
// /status JSON snapshot of every repo
// /metrics Prometheus text format
func (m *Manager) Handler(version string) http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
writeText(w, http.StatusOK, "ok\n")
})
mux.HandleFunc("GET /readyz", func(w http.ResponseWriter, r *http.Request) {
var pending []string
for _, s := range m.Statuses() {
if s.LastSuccess.IsZero() {
pending = append(pending, s.Name)
}
}
if len(pending) > 0 {
writeText(w, http.StatusServiceUnavailable,
"awaiting first successful sync: "+strings.Join(pending, ", ")+"\n")
return
}
writeText(w, http.StatusOK, "ready\n")
})
mux.HandleFunc("GET /status", func(w http.ResponseWriter, r *http.Request) {
statuses := m.Statuses()
body := struct {
Version string `json:"version"`
Uptime string `json:"uptime"`
Repos []Status `json:"repos"`
Healthy bool `json:"healthy"`
Failing int `json:"failing"`
Reported string `json:"reported_at"`
}{
Version: version,
Uptime: m.Uptime().Round(time.Second).String(),
Repos: statuses,
Healthy: true,
Reported: time.Now().UTC().Format(time.RFC3339),
}
for _, s := range statuses {
if s.Failures > 0 {
body.Failing++
body.Healthy = false
}
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
_ = enc.Encode(body)
})
mux.HandleFunc("GET /metrics", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
var b strings.Builder
fmt.Fprintf(&b, "# HELP syncbot_build_info Version of the running binary.\n")
fmt.Fprintf(&b, "# TYPE syncbot_build_info gauge\n")
fmt.Fprintf(&b, "syncbot_build_info{version=%q} 1\n", version)
fmt.Fprintf(&b, "# HELP syncbot_uptime_seconds Time since start.\n")
fmt.Fprintf(&b, "# TYPE syncbot_uptime_seconds gauge\n")
fmt.Fprintf(&b, "syncbot_uptime_seconds %s\n", seconds(m.Uptime()))
metric(&b, "syncbot_sync_total", "counter", "Sync cycles started.")
for _, s := range m.Statuses() {
fmt.Fprintf(&b, "syncbot_sync_total{repo=%q} %d\n", s.Name, s.Syncs)
}
metric(&b, "syncbot_push_total", "counter", "Cycles that pushed to dst.")
for _, s := range m.Statuses() {
fmt.Fprintf(&b, "syncbot_push_total{repo=%q} %d\n", s.Name, s.Pushes)
}
metric(&b, "syncbot_consecutive_failures", "gauge", "Failed cycles since the last success.")
for _, s := range m.Statuses() {
fmt.Fprintf(&b, "syncbot_consecutive_failures{repo=%q} %d\n", s.Name, s.Failures)
}
metric(&b, "syncbot_refs", "gauge", "Mirrored refs.")
for _, s := range m.Statuses() {
fmt.Fprintf(&b, "syncbot_refs{repo=%q} %d\n", s.Name, s.Refs)
}
metric(&b, "syncbot_last_success_timestamp_seconds", "gauge", "Unix time of the last successful sync.")
for _, s := range m.Statuses() {
fmt.Fprintf(&b, "syncbot_last_success_timestamp_seconds{repo=%q} %d\n", s.Name, unix(s.LastSuccess))
}
metric(&b, "syncbot_last_duration_seconds", "gauge", "Duration of the last sync cycle.")
for _, s := range m.Statuses() {
fmt.Fprintf(&b, "syncbot_last_duration_seconds{repo=%q} %s\n", s.Name,
strconv.FormatFloat(float64(s.LastDurMS)/1000, 'f', 3, 64))
}
_, _ = w.Write([]byte(b.String()))
})
return mux
}
// Serve runs the HTTP endpoint until ctx is cancelled.
func (m *Manager) Serve(ctx context.Context, addr, version string, log *slog.Logger) error {
srv := &http.Server{
Addr: addr,
Handler: m.Handler(version),
ReadHeaderTimeout: 10 * time.Second,
}
go func() {
<-ctx.Done()
shutdown, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = srv.Shutdown(shutdown)
}()
log.Info("http listening", "addr", addr,
"endpoints", "/healthz /readyz /status /metrics")
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
return err
}
return nil
}
func metric(b *strings.Builder, name, kind, help string) {
fmt.Fprintf(b, "# HELP %s %s\n# TYPE %s %s\n", name, help, name, kind)
}
func seconds(d time.Duration) string {
return strconv.FormatFloat(d.Seconds(), 'f', 3, 64)
}
func unix(t time.Time) int64 {
if t.IsZero() {
return 0
}
return t.Unix()
}
func writeText(w http.ResponseWriter, code int, body string) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(code)
_, _ = w.Write([]byte(body))
}
+412
View File
@@ -0,0 +1,412 @@
// Package manager owns the running set of sync jobs.
//
// Each repository gets its own goroutine with its own timer, so a slow or
// broken repo never delays the others. A shared semaphore caps how many git
// processes run at once, which is what actually bounds memory use.
//
// Reloading is incremental: Apply diffs the new configuration against the
// running jobs and touches only what changed. Repos whose settings are
// unchanged keep their timer and their in-flight work.
package manager
import (
"context"
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"reflect"
"sort"
"sync"
"time"
"syncbot/internal/config"
"syncbot/internal/gitx"
"syncbot/internal/syncer"
)
// Status is the observable state of one repository.
type Status struct {
Name string `json:"name"`
Src string `json:"src"`
Dst string `json:"dst"`
Interval string `json:"interval"`
Syncing bool `json:"syncing"`
Refs int `json:"refs"`
Syncs int64 `json:"syncs"`
Pushes int64 `json:"pushes"`
Failures int `json:"consecutive_failures"`
LastRun time.Time `json:"last_run,omitzero"`
LastSuccess time.Time `json:"last_success,omitzero"`
NextRun time.Time `json:"next_run,omitzero"`
LastError string `json:"last_error,omitempty"`
LastDurMS int64 `json:"last_duration_ms"`
}
// Manager supervises one goroutine per repository.
type Manager struct {
ctx context.Context
log *slog.Logger
mu sync.Mutex
jobs map[string]*job
stats map[string]*Status
sem chan struct{}
conc int
workDir string
sync *syncer.Syncer
started time.Time
// wg covers every goroutine ever started, including ones already replaced
// by a reload, so shutdown does not leave a git process behind.
wg sync.WaitGroup
}
type job struct {
spec config.Job
cancel context.CancelFunc
done chan struct{}
}
// New returns a Manager whose jobs all derive from ctx.
func New(ctx context.Context, log *slog.Logger) *Manager {
return &Manager{
ctx: ctx,
log: log,
jobs: make(map[string]*job),
stats: make(map[string]*Status),
sem: make(chan struct{}, 1),
conc: 1,
started: time.Now(),
}
}
// Apply reconciles the running jobs with cfg. It never blocks on in-flight
// syncs: a job being replaced is cancelled, and its successor waits for the
// handover before touching the same mirror directory.
func (m *Manager) Apply(cfg *config.Config) error {
home, err := prepareDirs(cfg.WorkDir)
if err != nil {
return err
}
if cfg.Concurrency <= 0 {
cfg.Concurrency = 1
}
m.mu.Lock()
if m.conc != cfg.Concurrency {
// Callers release into the channel they acquired from, so swapping the
// semaphore is safe even while syncs are in flight.
m.log.Info("concurrency changed", "from", m.conc, "to", cfg.Concurrency)
m.sem = make(chan struct{}, cfg.Concurrency)
m.conc = cfg.Concurrency
}
workDirChanged := m.workDir != cfg.WorkDir
if workDirChanged {
m.workDir = cfg.WorkDir
m.sync = &syncer.Syncer{Log: m.log, Home: home}
}
want := make(map[string]config.Job, len(cfg.Jobs))
for _, j := range cfg.Jobs {
want[j.Name] = j
}
// Stop jobs that disappeared or whose resolved spec changed.
var stopped []*job
for name, j := range m.jobs {
w, keep := want[name]
if keep && !workDirChanged && reflect.DeepEqual(w, j.spec) {
continue
}
delete(m.jobs, name)
stopped = append(stopped, j)
if !keep {
delete(m.stats, name)
m.log.Info("repo removed", "repo", name)
}
}
handover := make(map[string]<-chan struct{}, len(stopped))
for _, j := range stopped {
j.cancel()
handover[j.spec.Name] = j.done
}
// Start whatever is now missing.
var added, restarted int
for i, spec := range cfg.Jobs {
if _, running := m.jobs[spec.Name]; running {
continue
}
prev := handover[spec.Name]
if prev != nil {
restarted++
} else {
added++
}
m.startLocked(spec, prev, stagger(i))
}
m.mu.Unlock()
if added+restarted+len(stopped) > 0 {
m.log.Info("configuration applied",
"repos", len(cfg.Jobs), "started", added, "restarted", restarted,
"stopped", len(stopped)-restarted, "concurrency", cfg.Concurrency)
}
return nil
}
// startLocked launches a job goroutine. m.mu must be held.
func (m *Manager) startLocked(spec config.Job, waitFor <-chan struct{}, delay time.Duration) {
ctx, cancel := context.WithCancel(m.ctx)
j := &job{spec: spec, cancel: cancel, done: make(chan struct{})}
m.jobs[spec.Name] = j
st, ok := m.stats[spec.Name]
if !ok {
st = &Status{Name: spec.Name}
m.stats[spec.Name] = st
}
// Counters survive a restart; the descriptive fields follow the new spec.
st.Src = gitx.RedactURL(spec.Src.URL)
st.Dst = gitx.RedactURL(spec.Dst.URL)
st.Interval = spec.Interval.String()
m.wg.Add(1)
go m.loop(ctx, j, waitFor, delay)
}
func (m *Manager) loop(ctx context.Context, j *job, waitFor <-chan struct{}, delay time.Duration) {
defer m.wg.Done()
defer close(j.done)
// When replacing a job, let the previous goroutine finish unwinding before
// operating on its mirror directory.
if waitFor != nil {
select {
case <-waitFor:
case <-ctx.Done():
return
}
}
log := m.log.With("repo", j.spec.Name)
log.Info("watching", "src", gitx.RedactURL(j.spec.Src.URL),
"dst", gitx.RedactURL(j.spec.Dst.URL), "interval", j.spec.Interval)
timer := time.NewTimer(delay)
defer timer.Stop()
for {
m.update(j.spec.Name, func(s *Status) { s.NextRun = time.Now().Add(delay) })
select {
case <-ctx.Done():
return
case <-timer.C:
}
failures := m.runOnce(ctx, j.spec, log)
if ctx.Err() != nil {
return
}
delay = j.spec.Interval
if failures > 0 {
delay = backoff(j.spec.Interval, j.spec.MaxBackoff, failures)
if delay > j.spec.Interval {
log.Warn("backing off", "failures", failures, "retry_in", delay)
}
}
timer.Reset(delay)
}
}
// runOnce performs a single sync and returns the consecutive failure count.
func (m *Manager) runOnce(ctx context.Context, spec config.Job, log *slog.Logger) int {
release, err := m.acquire(ctx)
if err != nil {
return 0 // shutting down
}
defer release()
m.update(spec.Name, func(s *Status) {
s.Syncing = true
s.LastRun = time.Now()
s.Syncs++
})
res, err := m.syncer().Sync(ctx, spec)
failures := 0
m.update(spec.Name, func(s *Status) {
s.Syncing = false
s.LastDurMS = res.Duration.Milliseconds()
if err != nil {
s.Failures++
s.LastError = err.Error()
} else {
s.Failures = 0
s.LastError = ""
s.LastSuccess = time.Now()
s.Refs = res.Refs
if res.Pushed {
s.Pushes++
}
}
failures = s.Failures
})
switch {
case err != nil && ctx.Err() != nil:
log.Info("sync cancelled")
case err != nil:
log.Error("sync failed", "err", err, "consecutive_failures", failures)
case res.Pushed:
log.Info("pushed", "refs", res.Refs, "changes", len(res.Changes),
"dur", res.Duration.Round(time.Millisecond))
for _, c := range res.Changes {
log.Debug("ref updated", "change", c)
}
}
return failures
}
// RunOnce syncs every job exactly once and reports whether all succeeded. It
// is the engine behind the -once flag, for cron-style or one-shot deployments.
func (m *Manager) RunOnce(cfg *config.Config) error {
if err := m.Apply(&config.Config{ // reuse Apply's dir/semaphore setup, no jobs
WorkDir: cfg.WorkDir, Concurrency: cfg.Concurrency,
ReloadInterval: cfg.ReloadInterval, LogLevel: cfg.LogLevel, LogFormat: cfg.LogFormat,
}); err != nil {
return err
}
var wg sync.WaitGroup
errs := make([]error, len(cfg.Jobs))
for i, spec := range cfg.Jobs {
wg.Add(1)
go func() {
defer wg.Done()
release, err := m.acquire(m.ctx)
if err != nil {
errs[i] = err
return
}
defer release()
log := m.log.With("repo", spec.Name)
res, err := m.syncer().Sync(m.ctx, spec)
if err != nil {
errs[i] = fmt.Errorf("%s: %w", spec.Name, err)
log.Error("sync failed", "err", err)
return
}
log.Info("synced", "refs", res.Refs, "pushed", res.Pushed,
"dur", res.Duration.Round(time.Millisecond))
}()
}
wg.Wait()
return errors.Join(errs...)
}
// Stop cancels every job and waits up to grace for them to unwind. No further
// Apply may be called afterwards.
func (m *Manager) Stop(grace time.Duration) {
m.mu.Lock()
for _, j := range m.jobs {
j.cancel()
}
m.jobs = make(map[string]*job)
m.mu.Unlock()
// Waiting on the group rather than on the current jobs also covers
// goroutines a recent reload replaced but that have not finished unwinding.
done := make(chan struct{})
go func() {
m.wg.Wait()
close(done)
}()
select {
case <-done:
case <-time.After(grace):
m.log.Warn("shutdown grace period expired; abandoning in-flight syncs", "grace", grace)
}
}
// Statuses returns a snapshot of every known repository, sorted by name.
func (m *Manager) Statuses() []Status {
m.mu.Lock()
defer m.mu.Unlock()
out := make([]Status, 0, len(m.stats))
for _, s := range m.stats {
out = append(out, *s)
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
// Uptime reports how long the manager has been running.
func (m *Manager) Uptime() time.Duration { return time.Since(m.started) }
// acquire takes a slot from the concurrency semaphore. The release function
// returns the slot to the same channel it came from, which keeps the count
// correct even if Apply swapped the semaphore in the meantime.
func (m *Manager) acquire(ctx context.Context) (func(), error) {
m.mu.Lock()
sem := m.sem
m.mu.Unlock()
select {
case sem <- struct{}{}:
return func() { <-sem }, nil
case <-ctx.Done():
return nil, ctx.Err()
}
}
func (m *Manager) syncer() *syncer.Syncer {
m.mu.Lock()
defer m.mu.Unlock()
return m.sync
}
func (m *Manager) update(name string, fn func(*Status)) {
m.mu.Lock()
defer m.mu.Unlock()
if s, ok := m.stats[name]; ok {
fn(s)
}
}
// prepareDirs creates the mirror store and the isolated HOME git/ssh will use.
func prepareDirs(workDir string) (home string, err error) {
home = filepath.Join(workDir, "home")
for _, d := range []string{filepath.Join(workDir, "mirrors"), home} {
if err := os.MkdirAll(d, 0o700); err != nil {
return "", fmt.Errorf("prepare %s: %w", d, err)
}
}
return home, nil
}
// backoff grows the retry delay geometrically, capped at max, so a repo that
// is down for hours stops hammering it (and our logs) every interval.
func backoff(interval, max time.Duration, failures int) time.Duration {
d := interval
for i := 1; i < failures && d < max; i++ {
d *= 2
}
return min(d, max)
}
// stagger spreads the first run of each repo so a restart with many repos does
// not fire every git process in the same instant.
func stagger(index int) time.Duration {
return min(time.Duration(index)*250*time.Millisecond, 15*time.Second)
}
+243
View File
@@ -0,0 +1,243 @@
package manager
import (
"context"
"io"
"log/slog"
"os"
"path/filepath"
"testing"
"time"
"syncbot/internal/config"
)
func testConfig(workDir string, jobs ...config.Job) *config.Config {
return &config.Config{
WorkDir: workDir,
Concurrency: 2,
ReloadInterval: time.Second,
Jobs: jobs,
}
}
// newJob builds a spec pointing at paths that do not exist. The job goroutine
// will fail its sync quickly and harmlessly, which is all these tests need —
// they are about supervision, not about git.
func newJob(name, workDir string, interval time.Duration) config.Job {
return config.Job{
Name: name,
Src: config.Endpoint{URL: filepath.Join(workDir, name+"-src.git")},
Dst: config.Endpoint{URL: filepath.Join(workDir, name+"-dst.git")},
Dir: filepath.Join(workDir, "mirrors", name+".git"),
Interval: interval,
Timeout: 5 * time.Second,
MaxBackoff: time.Hour,
Refs: config.DefaultRefs,
Prune: true,
Force: true,
}
}
func newTestManager(t *testing.T) (*Manager, string) {
t.Helper()
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
workDir := t.TempDir()
m := New(ctx, slog.New(slog.NewTextHandler(io.Discard, nil)))
t.Cleanup(func() { m.Stop(5 * time.Second) })
return m, workDir
}
func (m *Manager) jobPointers() map[string]*job {
m.mu.Lock()
defer m.mu.Unlock()
out := make(map[string]*job, len(m.jobs))
for k, v := range m.jobs {
out[k] = v
}
return out
}
func TestApplyStartsJobs(t *testing.T) {
m, dir := newTestManager(t)
if err := m.Apply(testConfig(dir, newJob("a", dir, time.Hour), newJob("b", dir, time.Hour))); err != nil {
t.Fatal(err)
}
if got := len(m.jobPointers()); got != 2 {
t.Fatalf("running jobs = %d, want 2", got)
}
if got := len(m.Statuses()); got != 2 {
t.Errorf("statuses = %d, want 2", got)
}
}
// The whole point of an incremental reload: editing one repo must not disturb
// the others' timers or in-flight work.
func TestApplyLeavesUnchangedJobsRunning(t *testing.T) {
m, dir := newTestManager(t)
cfg := testConfig(dir, newJob("a", dir, time.Hour), newJob("b", dir, time.Hour))
if err := m.Apply(cfg); err != nil {
t.Fatal(err)
}
before := m.jobPointers()
// Identical configuration: nothing should be touched.
if err := m.Apply(testConfig(dir, newJob("a", dir, time.Hour), newJob("b", dir, time.Hour))); err != nil {
t.Fatal(err)
}
after := m.jobPointers()
for name, j := range before {
if after[name] != j {
t.Errorf("job %q was restarted despite an identical config", name)
}
}
// Change only "a": "b" must survive untouched.
if err := m.Apply(testConfig(dir, newJob("a", dir, 30*time.Minute), newJob("b", dir, time.Hour))); err != nil {
t.Fatal(err)
}
changed := m.jobPointers()
if changed["a"] == before["a"] {
t.Error("job \"a\" should have been restarted after its interval changed")
}
if changed["b"] != before["b"] {
t.Error("job \"b\" should not have been restarted")
}
if got := changed["a"].spec.Interval; got != 30*time.Minute {
t.Errorf("restarted job carries interval %s, want 30m", got)
}
}
func TestApplyStopsRemovedJobs(t *testing.T) {
m, dir := newTestManager(t)
if err := m.Apply(testConfig(dir, newJob("a", dir, time.Hour), newJob("b", dir, time.Hour))); err != nil {
t.Fatal(err)
}
removed := m.jobPointers()["b"]
if err := m.Apply(testConfig(dir, newJob("a", dir, time.Hour))); err != nil {
t.Fatal(err)
}
if _, still := m.jobPointers()["b"]; still {
t.Fatal("job \"b\" is still registered after being removed from the config")
}
select {
case <-removed.done:
case <-time.After(5 * time.Second):
t.Fatal("goroutine for the removed job did not exit")
}
for _, s := range m.Statuses() {
if s.Name == "b" {
t.Error("status for the removed job should be dropped")
}
}
}
func TestCountersSurviveJobRestart(t *testing.T) {
m, dir := newTestManager(t)
if err := m.Apply(testConfig(dir, newJob("a", dir, time.Hour))); err != nil {
t.Fatal(err)
}
m.update("a", func(s *Status) { s.Syncs, s.Pushes = 7, 3 })
if err := m.Apply(testConfig(dir, newJob("a", dir, 5*time.Minute))); err != nil {
t.Fatal(err)
}
got := m.Statuses()[0]
if got.Syncs != 7 || got.Pushes != 3 {
t.Errorf("counters reset on restart: syncs=%d pushes=%d, want 7/3", got.Syncs, got.Pushes)
}
if got.Interval != "5m0s" {
t.Errorf("status interval = %q, want it to follow the new spec", got.Interval)
}
}
func TestConcurrencyChangeKeepsSemaphoreConsistent(t *testing.T) {
m, dir := newTestManager(t)
cfg := testConfig(dir, newJob("a", dir, time.Hour))
cfg.Concurrency = 1
if err := m.Apply(cfg); err != nil {
t.Fatal(err)
}
// Hold a slot from the old semaphore, then resize.
release, err := m.acquire(context.Background())
if err != nil {
t.Fatal(err)
}
cfg2 := testConfig(dir, newJob("a", dir, time.Hour))
cfg2.Concurrency = 4
if err := m.Apply(cfg2); err != nil {
t.Fatal(err)
}
release() // must return the slot to the channel it came from, not the new one
// The new semaphore should have its full capacity available.
var releases []func()
for i := 0; i < 4; i++ {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
r, err := m.acquire(ctx)
cancel()
if err != nil {
t.Fatalf("acquire %d/4 blocked after resize: %v", i+1, err)
}
releases = append(releases, r)
}
for _, r := range releases {
r()
}
}
func TestApplyRejectsUnusableWorkDir(t *testing.T) {
m, dir := newTestManager(t)
// A regular file cannot host the mirror directories.
blocked := filepath.Join(dir, "not-a-dir")
if err := os.WriteFile(blocked, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
if err := m.Apply(testConfig(blocked, newJob("a", dir, time.Hour))); err == nil {
t.Fatal("want an error for a work_dir that cannot be created")
}
}
func TestBackoff(t *testing.T) {
const interval, max = time.Minute, 30 * time.Minute
cases := []struct {
failures int
want time.Duration
}{
{0, time.Minute},
{1, time.Minute},
{2, 2 * time.Minute},
{3, 4 * time.Minute},
{4, 8 * time.Minute},
{6, 30 * time.Minute}, // capped
{100, 30 * time.Minute},
}
for _, tc := range cases {
if got := backoff(interval, max, tc.failures); got != tc.want {
t.Errorf("backoff(failures=%d) = %s, want %s", tc.failures, got, tc.want)
}
}
}
func TestStaggerIsBounded(t *testing.T) {
if got := stagger(0); got != 0 {
t.Errorf("the first repo should start immediately, got %s", got)
}
if got := stagger(3); got != 750*time.Millisecond {
t.Errorf("stagger(3) = %s, want 750ms", got)
}
if got := stagger(1000); got != 15*time.Second {
t.Errorf("stagger should be capped at 15s, got %s", got)
}
}
+163
View File
@@ -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
}
+322
View File
@@ -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)
}