Files
bootapi/internal/gitsync/gitsync.go
T
unkin-agent a414918350
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
Fetch templates over HTTP instead of shelling out to git
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
2026-09-26 18:59:07 +10:00

269 lines
8.3 KiB
Go

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