// Package gitsync keeps bootapi's template Set in step with the templates repo. // It fetches the repo's branch tarball over plain HTTP (Gitea's // /archive/.tar.gz) every interval (default 3m, like argocd), holds the // template files in memory, and atomically swaps the Engine's active Set when // the content 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 the repo is unreachable at startup. // // The repo is only ever read as a file tree plus a change signal, so no git // binary is involved: the runtime image stays distroless and the pod needs no // writable volume. package gitsync import ( "archive/tar" "compress/gzip" "context" "crypto/sha256" "encoding/hex" "fmt" "io" "io/fs" "log/slog" "net/http" "path" "sort" "strings" "sync/atomic" "testing/fstest" "time" "git.unkin.net/unkin/bootapi/internal/render" ) // maxArchiveBytes caps the downloaded tarball. The templates repo is a handful // of text files (~12KB compressed); this only exists to bound a hostile or // broken response. const maxArchiveBytes = 32 << 20 // maxFileBytes caps a single extracted template. const maxFileBytes = 1 << 20 // Options configures the syncer. type Options struct { URL string // repo URL, e.g. https://git.unkin.net/unkin/bootapi-templates.git Branch string Token string // optional; sent as a Gitea token header for a private repo Interval time.Duration Client *http.Client // optional; defaults to a 30s-timeout client } // Syncer fetches a templates repo and reloads an Engine on change. type Syncer struct { opt Options embedded fs.FS engine *render.Engine digest string // content digest of the last fetched tree syncs atomic.Int64 // successful reloads (Set swapped) failures atomic.Int64 // fetch 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 } if opt.Client == nil { opt.Client = &http.Client{Timeout: 30 * time.Second} } 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() } // ArchiveURL is the branch tarball URL derived from the repo URL. func (o Options) ArchiveURL() string { base := strings.TrimSuffix(strings.TrimRight(o.URL, "/"), ".git") return base + "/archive/" + o.Branch + ".tar.gz" } // Bootstrap fetches the repo and builds the initial Set from embedded + the // fetched tree. On any fetch/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) { tree, digest, commit, err := s.fetch(ctx) if err != nil { return s.embeddedSet(fmt.Errorf("template fetch failed, using embedded defaults: %w", err)) } s.digest = digest set, err := render.BuildSet(s.embedded, tree) if err != nil { return s.embeddedSet(fmt.Errorf("fetched templates failed to parse, using embedded defaults: %w", err)) } s.generation.Add(1) slog.Info("templates loaded", "commit", commit, "digest", digest) return set, nil } // embeddedSet returns the embedded-only Set alongside the degrade reason. A // broken embedded set is genuinely fatal. func (s *Syncer) embeddedSet(reason error) (*render.Set, error) { set, err := render.BuildSet(s.embedded, nil) if err != nil { return nil, err } return set, reason } // 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 sync started", "url", s.opt.ArchiveURL(), "interval", s.opt.Interval) for { select { case <-ctx.Done(): return case <-t.C: s.pollOnce(ctx) } } } func (s *Syncer) pollOnce(ctx context.Context) { tree, digest, commit, err := s.fetch(ctx) if err != nil { s.failures.Add(1) slog.Error("template fetch failed; keeping last-good set", "err", err) return } if digest == s.digest { return } // Record the new digest before parsing so an unchanged bad push is counted // once, not on every poll. s.digest = digest set, err := render.BuildSet(s.embedded, tree) if err != nil { s.failures.Add(1) slog.Error("template reload failed to parse; keeping last-good set", "commit", commit, "err", err) return } s.engine.Swap(set) s.syncs.Add(1) s.generation.Add(1) slog.Info("templates reloaded", "commit", commit, "digest", digest, "generation", s.generation.Load()) } // fetch downloads the branch tarball and extracts it into an in-memory FS. The // digest is taken over the extracted tree (not the gzip bytes) so a // re-compressed but identical archive is not treated as a change. commit is the // source commit Gitea advertises in its immutable Link header, for logging only. func (s *Syncer) fetch(ctx context.Context) (fs.FS, string, string, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.opt.ArchiveURL(), nil) if err != nil { return nil, "", "", err } if s.opt.Token != "" { req.Header.Set("Authorization", "token "+s.opt.Token) } resp, err := s.opt.Client.Do(req) if err != nil { return nil, "", "", err } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return nil, "", "", fmt.Errorf("GET %s: %s", s.opt.ArchiveURL(), resp.Status) } tree, err := extract(io.LimitReader(resp.Body, maxArchiveBytes)) if err != nil { return nil, "", "", err } if len(tree) == 0 { return nil, "", "", fmt.Errorf("archive contained no files") } return tree, digest(tree), commitFromLink(resp.Header.Get("Link")), nil } // extract reads a gzipped tar and returns its regular files keyed by path with // the archive's single top-level directory stripped (Gitea prefixes every entry // with "/"). Entries that would escape the tree are skipped rather than // trusted: the archive is a network input. func extract(r io.Reader) (fstest.MapFS, error) { gz, err := gzip.NewReader(r) if err != nil { return nil, fmt.Errorf("gzip: %w", err) } defer func() { _ = gz.Close() }() out := fstest.MapFS{} tr := tar.NewReader(gz) for { h, err := tr.Next() if err == io.EOF { return out, nil } if err != nil { return nil, fmt.Errorf("tar: %w", err) } if h.Typeflag != tar.TypeReg { continue } name := stripRoot(h.Name) if name == "" || !fs.ValidPath(name) { continue } b, err := io.ReadAll(io.LimitReader(tr, maxFileBytes)) if err != nil { return nil, fmt.Errorf("tar %s: %w", h.Name, err) } out[name] = &fstest.MapFile{Data: b, Mode: 0o444} } } // stripRoot removes the archive's leading directory component. func stripRoot(name string) string { clean := path.Clean(strings.TrimPrefix(name, "./")) if strings.HasPrefix(clean, "/") || strings.HasPrefix(clean, "..") { return "" } _, rest, ok := strings.Cut(clean, "/") if !ok { return "" } return rest } // digest hashes the extracted tree: every path and its contents, in path order. func digest(tree fstest.MapFS) string { names := make([]string, 0, len(tree)) for n := range tree { names = append(names, n) } sort.Strings(names) h := sha256.New() for _, n := range names { _, _ = fmt.Fprintf(h, "%s\x00%d\x00", n, len(tree[n].Data)) _, _ = h.Write(tree[n].Data) } return hex.EncodeToString(h.Sum(nil))[:16] } // commitFromLink pulls the commit SHA out of Gitea's immutable-archive Link // header: <.../archive/.tar.gz?rev=>; rel="immutable". func commitFromLink(link string) string { _, rev, ok := strings.Cut(link, "rev=") if !ok { return "" } sha, _, _ := strings.Cut(rev, ">") return strings.TrimSpace(sha) }