// Package gitsync keeps bootapi's template Set in step with a git repo. It // clones the templates repo at startup and re-pulls it every interval (default // 3m, like argocd), atomically swapping the Engine's active Set when the repo // changes. A parse failure keeps the last-good Set and is only logged/counted, // so a bad template push can never take bootapi down. The embedded defaults // remain the fallback when git is unreachable at startup. package gitsync import ( "context" "fmt" "io/fs" "log/slog" "os" "os/exec" "strings" "sync/atomic" "time" "git.unkin.net/unkin/bootapi/internal/render" ) // Options configures the syncer. type Options struct { URL string Branch string Token string // optional; injected into the HTTPS URL for a private repo Interval time.Duration WorkDir string // local checkout path } // Syncer pulls a templates repo and reloads an Engine on change. type Syncer struct { opt Options embedded fs.FS engine *render.Engine syncs atomic.Int64 // successful reloads (Set swapped) failures atomic.Int64 // pull or parse failures (last-good kept) generation atomic.Int64 // increments on every successful swap } // New builds a Syncer. embedded is the fallback template FS. Call SetEngine // before Run so reloads have an Engine to swap into (the Engine needs the // initial Set from Bootstrap first, hence the two-step wiring). func New(opt Options, embedded fs.FS) *Syncer { if opt.Branch == "" { opt.Branch = "main" } if opt.Interval <= 0 { opt.Interval = 3 * time.Minute } return &Syncer{opt: opt, embedded: embedded} } // SetEngine points the syncer at the live Engine whose Set it swaps on reload. func (s *Syncer) SetEngine(e *render.Engine) { s.engine = e } // Syncs/Failures/Generation are exported for the server's metrics collector. func (s *Syncer) Syncs() int64 { return s.syncs.Load() } func (s *Syncer) Failures() int64 { return s.failures.Load() } func (s *Syncer) Generation() int64 { return s.generation.Load() } // Bootstrap clones the repo and builds the initial Set from embedded + the // checkout. On any git/parse failure it returns an embedded-only Set plus a // non-nil error (which the caller logs but treats as non-fatal, so bootapi // always starts with at least the embedded defaults). func (s *Syncer) Bootstrap(ctx context.Context) (*render.Set, error) { if err := s.clone(ctx); err != nil { set, berr := render.BuildSet(s.embedded, nil) if berr != nil { return nil, berr // embedded defaults broken: genuinely fatal } return set, fmt.Errorf("git clone failed, using embedded defaults: %w", err) } set, err := render.BuildSet(s.embedded, os.DirFS(s.opt.WorkDir)) if err != nil { emb, berr := render.BuildSet(s.embedded, nil) if berr != nil { return nil, berr } return emb, fmt.Errorf("git templates failed to parse, using embedded defaults: %w", err) } s.generation.Add(1) return set, nil } // Run polls the repo every interval until ctx is cancelled. func (s *Syncer) Run(ctx context.Context) { t := time.NewTicker(s.opt.Interval) defer t.Stop() slog.Info("template git-sync started", "url", s.opt.URL, "branch", s.opt.Branch, "interval", s.opt.Interval) for { select { case <-ctx.Done(): return case <-t.C: s.pollOnce(ctx) } } } func (s *Syncer) pollOnce(ctx context.Context) { changed, head, err := s.pull(ctx) if err != nil { s.failures.Add(1) slog.Error("template git pull failed; keeping last-good set", "err", err) return } if !changed { return } set, err := render.BuildSet(s.embedded, os.DirFS(s.opt.WorkDir)) if err != nil { s.failures.Add(1) slog.Error("template reload failed to parse; keeping last-good set", "commit", head, "err", err) return } s.engine.Swap(set) s.syncs.Add(1) s.generation.Add(1) slog.Info("templates reloaded from git", "commit", head, "generation", s.generation.Load()) } // authURL injects a token into the HTTPS clone URL when configured. func (s *Syncer) authURL() string { if s.opt.Token == "" { return s.opt.URL } if rest, ok := strings.CutPrefix(s.opt.URL, "https://"); ok { return "https://" + s.opt.Token + "@" + rest } return s.opt.URL } func (s *Syncer) clone(ctx context.Context) error { if err := os.RemoveAll(s.opt.WorkDir); err != nil { return err } return run(ctx, "", "git", "clone", "--depth", "1", "--branch", s.opt.Branch, s.authURL(), s.opt.WorkDir) } // pull fetches origin/branch and hard-resets to it, reporting whether HEAD moved. func (s *Syncer) pull(ctx context.Context) (changed bool, head string, err error) { old, _ := s.head(ctx) if err := run(ctx, s.opt.WorkDir, "git", "fetch", "--depth", "1", "origin", s.opt.Branch); err != nil { return false, "", err } if err := run(ctx, s.opt.WorkDir, "git", "reset", "--hard", "origin/"+s.opt.Branch); err != nil { return false, "", err } newHead, err := s.head(ctx) if err != nil { return false, "", err } return old != newHead, newHead, nil } func (s *Syncer) head(ctx context.Context) (string, error) { out, err := output(ctx, s.opt.WorkDir, "git", "rev-parse", "HEAD") return strings.TrimSpace(out), err } func run(ctx context.Context, dir, name string, args ...string) error { cmd := exec.CommandContext(ctx, name, args...) cmd.Dir = dir if out, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("%s %s: %w: %s", name, strings.Join(args, " "), err, strings.TrimSpace(string(out))) } return nil } func output(ctx context.Context, dir, name string, args ...string) (string, error) { cmd := exec.CommandContext(ctx, name, args...) cmd.Dir = dir out, err := cmd.Output() return string(out), err }