This commit is contained in:
@@ -0,0 +1,262 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user