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