a414918350
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
269 lines
8.3 KiB
Go
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)
|
|
}
|