263 lines
6.3 KiB
Go
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
|
|
}
|