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

263 lines
6.3 KiB
Go

// Command syncbot mirrors git repositories from a source to a destination on a
// timer. See README.md for the configuration format.
package main
import (
"context"
"crypto/sha256"
"encoding/hex"
"flag"
"fmt"
"io"
"log/slog"
"os"
"os/signal"
"runtime/debug"
"syscall"
"time"
"syncbot/internal/config"
"syncbot/internal/gitx"
"syncbot/internal/manager"
)
// version is stamped at build time with -ldflags "-X main.version=...".
var version = "dev"
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, "syncbot: "+err.Error())
os.Exit(1)
}
}
func run() error {
var (
cfgPath = flag.String("config", "/etc/syncbot/config.toml", "path to the TOML config file")
check = flag.Bool("check", false, "validate the config file and exit")
once = flag.Bool("once", false, "sync every repository once, then exit")
showVer = flag.Bool("version", false, "print the version and exit")
)
flag.Parse()
if *showVer {
fmt.Println("syncbot", buildVersion())
return nil
}
cfg, err := config.Load(*cfgPath)
if err != nil {
return err
}
if *check {
printSummary(cfg, *cfgPath)
return nil
}
level := new(slog.LevelVar)
log := newLogger(cfg, level, os.Stderr)
log.Info("starting", "version", buildVersion(), "config", *cfgPath,
"work_dir", cfg.WorkDir, "repos", len(cfg.Jobs))
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
mgr := manager.New(ctx, log)
if *once {
return mgr.RunOnce(cfg)
}
if err := mgr.Apply(cfg); err != nil {
return err
}
if cfg.Listen != "" {
go func() {
if err := mgr.Serve(ctx, cfg.Listen, buildVersion(), log); err != nil {
log.Error("http server stopped", "err", err)
}
}()
}
go watchConfig(ctx, *cfgPath, cfg, level, log, mgr)
<-ctx.Done()
log.Info("shutting down")
mgr.Stop(20 * time.Second)
log.Info("stopped")
return nil
}
// watchConfig re-reads the config file whenever its contents change, and on
// SIGHUP. A file that fails to parse is reported and ignored — the daemon keeps
// running on the last configuration that worked, which is what you want when a
// typo lands in production at 3am.
func watchConfig(ctx context.Context, path string, initial *config.Config, level *slog.LevelVar, log *slog.Logger, mgr *manager.Manager) {
digest, _ := fileDigest(path)
current := initial
// Note: signal.Notify with no signals subscribes to *every* signal, so only
// register when the platform actually has a reload signal to offer.
hup := make(chan os.Signal, 1)
if sigs := reloadSignals(); len(sigs) > 0 {
signal.Notify(hup, sigs...)
defer signal.Stop(hup)
}
ticker := time.NewTicker(current.ReloadInterval)
defer ticker.Stop()
for {
forced := false
select {
case <-ctx.Done():
return
case <-ticker.C:
case <-hup:
forced = true
log.Info("reload requested by signal")
}
d, err := fileDigest(path)
if err != nil {
log.Error("cannot read config", "path", path, "err", err)
continue
}
if d == digest && !forced {
continue
}
// Record the digest even on failure so a broken edit is reported once
// rather than on every tick.
digest = d
cfg, err := config.Load(path)
if err != nil {
log.Error("config reload failed, keeping the previous configuration", "err", err)
continue
}
if cfg.LogLevel != current.LogLevel {
level.Set(parseLevel(cfg.LogLevel))
log.Info("log level changed", "level", cfg.LogLevel)
}
if cfg.Listen != current.Listen {
log.Warn("listen address changed; this takes effect after a restart",
"current", current.Listen, "configured", cfg.Listen)
}
if cfg.ReloadInterval != current.ReloadInterval {
ticker.Reset(cfg.ReloadInterval)
}
if err := mgr.Apply(cfg); err != nil {
log.Error("cannot apply new configuration, keeping the previous one", "err", err)
continue
}
current = cfg
}
}
func fileDigest(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}
func newLogger(cfg *config.Config, level *slog.LevelVar, w io.Writer) *slog.Logger {
level.Set(parseLevel(cfg.LogLevel))
opts := &slog.HandlerOptions{Level: level}
var h slog.Handler
if cfg.LogFormat == "json" {
h = slog.NewJSONHandler(w, opts)
} else {
h = slog.NewTextHandler(w, opts)
}
return slog.New(h)
}
func parseLevel(s string) slog.Level {
switch s {
case "debug":
return slog.LevelDebug
case "warn":
return slog.LevelWarn
case "error":
return slog.LevelError
default:
return slog.LevelInfo
}
}
func printSummary(cfg *config.Config, path string) {
fmt.Printf("%s: OK\n\n", path)
fmt.Printf("work_dir %s\n", cfg.WorkDir)
fmt.Printf("concurrency %d\n", cfg.Concurrency)
fmt.Printf("reload every %s\n", cfg.ReloadInterval)
if cfg.Listen != "" {
fmt.Printf("listen %s\n", cfg.Listen)
}
fmt.Printf("\n%d repositor%s:\n", len(cfg.Jobs), plural(len(cfg.Jobs)))
for _, j := range cfg.Jobs {
fmt.Printf("\n %s\n", j.Name)
fmt.Printf(" src %s\n", gitx.RedactURL(j.Src.URL))
fmt.Printf(" dst %s\n", gitx.RedactURL(j.Dst.URL))
fmt.Printf(" every %s (timeout %s)\n", j.Interval, j.Timeout)
fmt.Printf(" refs %v\n", j.Refs)
fmt.Printf(" flags prune=%t force=%t atomic=%t allow_empty=%t\n",
j.Prune, j.Force, j.Atomic, j.AllowEmpty)
if j.Dst.SSHKey != "" {
fmt.Printf(" dst key %s (strict_host_key=%s)\n", j.Dst.SSHKey, j.Dst.StrictHostKey)
}
if j.Src.SSHKey != "" {
fmt.Printf(" src key %s (strict_host_key=%s)\n", j.Src.SSHKey, j.Src.StrictHostKey)
}
fmt.Printf(" mirror %s\n", j.Dir)
}
}
func plural(n int) string {
if n == 1 {
return "y"
}
return "ies"
}
// buildVersion prefers the ldflags value and falls back to VCS data stamped in
// by the Go toolchain, so a `go build` without flags still says something.
func buildVersion() string {
if version != "dev" {
return version
}
info, ok := debug.ReadBuildInfo()
if !ok {
return version
}
var rev, dirty string
for _, s := range info.Settings {
switch s.Key {
case "vcs.revision":
if len(s.Value) > 12 {
rev = s.Value[:12]
} else {
rev = s.Value
}
case "vcs.modified":
if s.Value == "true" {
dirty = "-dirty"
}
}
}
if rev == "" {
return version
}
return version + "+" + rev + dirty
}