Fetch templates over HTTP instead of shelling out to git
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful

The runtime image is distroless and has no git binary, so every sync
failed and bootapi silently served the stale embedded templates.

- fetch the branch tarball (<repo>/archive/<branch>.tar.gz) and extract
  it into an in-memory FS; no checkout, no writable volume
- digest the extracted tree, not the archive bytes, so a recompressed
  identical archive is not a change
- skip entries that would escape the tree
- log the source commit from Gitea's immutable Link header
This commit is contained in:
2026-09-26 18:59:07 +10:00
parent ca86d9cedc
commit a414918350
7 changed files with 351 additions and 166 deletions
+174 -84
View File
@@ -1,42 +1,63 @@
// 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 keeps bootapi's template Set in step with the templates repo.
// It fetches the repo's branch tarball over plain HTTP (Gitea's
// /archive/<branch>.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"
"os"
"os/exec"
"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
URL string // repo URL, e.g. https://git.unkin.net/unkin/bootapi-templates.git
Branch string
Token string // optional; injected into the HTTPS URL for a private repo
Token string // optional; sent as a Gitea token header for a private repo
Interval time.Duration
WorkDir string // local checkout path
Client *http.Client // optional; defaults to a 30s-timeout client
}
// Syncer pulls a templates repo and reloads an Engine on change.
// 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 // pull or parse failures (last-good kept)
failures atomic.Int64 // fetch or parse failures (last-good kept)
generation atomic.Int64 // increments on every successful swap
}
@@ -50,6 +71,9 @@ func New(opt Options, embedded fs.FS) *Syncer {
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}
}
@@ -61,35 +85,46 @@ 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
// 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) {
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))
tree, digest, commit, err := s.fetch(ctx)
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)
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 git-sync started", "url", s.opt.URL, "branch", s.opt.Branch, "interval", s.opt.Interval)
slog.Info("template sync started", "url", s.opt.ArchiveURL(), "interval", s.opt.Interval)
for {
select {
case <-ctx.Done():
@@ -101,78 +136,133 @@ func (s *Syncer) Run(ctx context.Context) {
}
func (s *Syncer) pollOnce(ctx context.Context) {
changed, head, err := s.pull(ctx)
tree, digest, commit, err := s.fetch(ctx)
if err != nil {
s.failures.Add(1)
slog.Error("template git pull failed; keeping last-good set", "err", err)
slog.Error("template fetch failed; keeping last-good set", "err", err)
return
}
if !changed {
if digest == s.digest {
return
}
set, err := render.BuildSet(s.embedded, os.DirFS(s.opt.WorkDir))
// 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", head, "err", err)
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 from git", "commit", head, "generation", s.generation.Load())
slog.Info("templates reloaded", "commit", commit, "digest", digest, "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)
// 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 false, "", err
return nil, "", "", 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)))
if s.opt.Token != "" {
req.Header.Set("Authorization", "token "+s.opt.Token)
}
return nil
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
}
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
// 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 "<repo>/"). 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/<sha>.tar.gz?rev=<sha>>; rel="immutable".
func commitFromLink(link string) string {
_, rev, ok := strings.Cut(link, "rev=")
if !ok {
return ""
}
sha, _, _ := strings.Cut(rev, ">")
return strings.TrimSpace(sha)
}