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
+163 -67
View File
@@ -1,11 +1,14 @@
package gitsync
import (
"archive/tar"
"bytes"
"compress/gzip"
"context"
"os"
"os/exec"
"path/filepath"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
@@ -14,31 +17,81 @@ import (
"git.unkin.net/unkin/bootapi/templates"
)
// gitRepo creates a real git repo at dir with an initial almalinux9 override.
func gitRepo(t *testing.T, dir string) {
// archive builds a gzipped tar shaped like Gitea's: every entry under a single
// "<repo>/" root, plus the directory entries Gitea includes.
func archive(t *testing.T, files map[string]string) []byte {
t.Helper()
gitCmd(t, "", "git", "init", "-b", "main", dir)
gitCmd(t, dir, "git", "config", "user.email", "t@example.net")
gitCmd(t, dir, "git", "config", "user.name", "test")
writeKS(t, dir, "GITSYNC-V1 {{ .Hostname }}\n")
gitCmd(t, dir, "git", "add", "-A")
gitCmd(t, dir, "git", "commit", "-m", "v1")
}
func writeKS(t *testing.T, dir, body string) {
t.Helper()
if err := os.WriteFile(filepath.Join(dir, "almalinux9.ks.tmpl"), []byte(body), 0o600); err != nil {
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
tw := tar.NewWriter(gz)
if err := tw.WriteHeader(&tar.Header{Name: "bootapi-templates/", Typeflag: tar.TypeDir, Mode: 0o755}); err != nil {
t.Fatal(err)
}
for name, body := range files {
h := &tar.Header{Name: "bootapi-templates/" + name, Typeflag: tar.TypeReg, Mode: 0o644, Size: int64(len(body))}
if err := tw.WriteHeader(h); err != nil {
t.Fatal(err)
}
if _, err := tw.Write([]byte(body)); err != nil {
t.Fatal(err)
}
}
if err := tw.Close(); err != nil {
t.Fatal(err)
}
if err := gz.Close(); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
func gitCmd(t *testing.T, dir, name string, args ...string) {
// repo serves a mutable archive at Gitea's /archive path and counts requests.
type repo struct {
t *testing.T
srv *httptest.Server
body atomic.Value // []byte
status atomic.Int64
fetches atomic.Int64
}
func newRepo(t *testing.T, files map[string]string) *repo {
t.Helper()
cmd := exec.Command(name, args...)
cmd.Dir = dir
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("%s %v: %v: %s", name, args, err, out)
r := &repo{t: t}
r.body.Store(archive(t, files))
r.status.Store(http.StatusOK)
mux := http.NewServeMux()
mux.HandleFunc("/unkin/bootapi-templates/archive/main.tar.gz", func(w http.ResponseWriter, _ *http.Request) {
r.fetches.Add(1)
if code := int(r.status.Load()); code != http.StatusOK {
w.WriteHeader(code)
return
}
w.Header().Set("Link", `<https://git.example.net/api/v1/repos/unkin/bootapi-templates/archive/abc123.tar.gz?rev=abc123>; rel="immutable"`)
_, _ = w.Write(r.body.Load().([]byte))
})
r.srv = httptest.NewServer(mux)
t.Cleanup(r.srv.Close)
return r
}
func (r *repo) url() string { return r.srv.URL + "/unkin/bootapi-templates.git" }
func (r *repo) push(files map[string]string) { r.body.Store(archive(r.t, files)) }
func ks(body string) map[string]string { return map[string]string{"almalinux9.ks.tmpl": body} }
func syncer(t *testing.T, r *repo) *Syncer {
t.Helper()
return New(Options{URL: r.url(), Branch: "main", Interval: time.Hour}, templates.FS)
}
func engineFor(t *testing.T, s *Syncer) *render.Engine {
t.Helper()
set, err := s.Bootstrap(context.Background())
if err != nil {
t.Fatalf("Bootstrap: %v", err)
}
eng := render.NewEngine(render.RenderConfig{DefaultTemplate: "almalinux9", ArtifactBase: "https://af"}, set)
s.SetEngine(eng)
return eng
}
func renderKS(t *testing.T, e *render.Engine) string {
@@ -51,33 +104,32 @@ func renderKS(t *testing.T, e *render.Engine) string {
return string(out)
}
func TestArchiveURL(t *testing.T) {
for _, tc := range []struct{ in, want string }{
{"https://git.unkin.net/unkin/bootapi-templates.git", "https://git.unkin.net/unkin/bootapi-templates/archive/main.tar.gz"},
{"https://git.unkin.net/unkin/bootapi-templates", "https://git.unkin.net/unkin/bootapi-templates/archive/main.tar.gz"},
{"https://git.unkin.net/unkin/bootapi-templates/", "https://git.unkin.net/unkin/bootapi-templates/archive/main.tar.gz"},
} {
if got := (Options{URL: tc.in, Branch: "main"}).ArchiveURL(); got != tc.want {
t.Errorf("ArchiveURL(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
func TestBootstrapAndReload(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not available")
}
src := t.TempDir()
gitRepo(t, src)
r := newRepo(t, ks("SYNC-V1 {{ .Hostname }}\n"))
s := syncer(t, r)
eng := engineFor(t, s)
s := New(Options{URL: src, Branch: "main", Interval: time.Hour, WorkDir: filepath.Join(t.TempDir(), "co")}, templates.FS)
set, err := s.Bootstrap(context.Background())
if err != nil {
t.Fatalf("Bootstrap: %v", err)
}
eng := render.NewEngine(render.RenderConfig{DefaultTemplate: "almalinux9", ArtifactBase: "https://af"}, set)
s.SetEngine(eng)
if got := renderKS(t, eng); !contains(got, "GITSYNC-V1 web01") {
if got := renderKS(t, eng); !strings.Contains(got, "SYNC-V1 web01") {
t.Fatalf("initial render missing v1 override:\n%s", got)
}
gen1 := s.Generation()
// Commit v2 upstream, then poll: the engine must swap to the new content.
writeKS(t, src, "GITSYNC-V2 {{ .Hostname }}\n")
gitCmd(t, src, "git", "add", "-A")
gitCmd(t, src, "git", "commit", "-m", "v2")
r.push(ks("SYNC-V2 {{ .Hostname }}\n"))
s.pollOnce(context.Background())
if got := renderKS(t, eng); !contains(got, "GITSYNC-V2 web01") {
if got := renderKS(t, eng); !strings.Contains(got, "SYNC-V2 web01") {
t.Fatalf("after reload, render missing v2:\n%s", got)
}
if s.Generation() <= gen1 {
@@ -88,30 +140,33 @@ func TestBootstrapAndReload(t *testing.T) {
}
}
func TestReloadKeepsLastGoodOnParseError(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not available")
}
src := t.TempDir()
gitRepo(t, src)
s := New(Options{URL: src, Branch: "main", Interval: time.Hour, WorkDir: filepath.Join(t.TempDir(), "co")}, templates.FS)
set, err := s.Bootstrap(context.Background())
if err != nil {
t.Fatal(err)
}
eng := render.NewEngine(render.RenderConfig{DefaultTemplate: "almalinux9", ArtifactBase: "https://af"}, set)
s.SetEngine(eng)
// Push a template that fails to parse.
writeKS(t, src, "BROKEN {{ .Hostname \n")
gitCmd(t, src, "git", "add", "-A")
gitCmd(t, src, "git", "commit", "-m", "broken")
func TestUnchangedContentDoesNotReload(t *testing.T) {
r := newRepo(t, ks("SYNC-V1 {{ .Hostname }}\n"))
s := syncer(t, r)
engineFor(t, s)
// Re-archiving the same files yields fresh gzip bytes; the digest is taken
// over the extracted tree, so this must NOT count as a change.
r.push(ks("SYNC-V1 {{ .Hostname }}\n"))
s.pollOnce(context.Background())
// The last-good v1 set must still be served, and a failure recorded.
if got := renderKS(t, eng); !contains(got, "GITSYNC-V1 web01") {
if s.Syncs() != 0 {
t.Errorf("syncs = %d, want 0 (identical content is not a change)", s.Syncs())
}
if s.Failures() != 0 {
t.Errorf("failures = %d, want 0", s.Failures())
}
}
func TestReloadKeepsLastGoodOnParseError(t *testing.T) {
r := newRepo(t, ks("SYNC-V1 {{ .Hostname }}\n"))
s := syncer(t, r)
eng := engineFor(t, s)
r.push(ks("BROKEN {{ .Hostname \n"))
s.pollOnce(context.Background())
if got := renderKS(t, eng); !strings.Contains(got, "SYNC-V1 web01") {
t.Fatalf("last-good not kept after parse failure:\n%s", got)
}
if s.Failures() != 1 {
@@ -120,11 +175,19 @@ func TestReloadKeepsLastGoodOnParseError(t *testing.T) {
if s.Syncs() != 0 {
t.Errorf("syncs = %d, want 0 (bad push must not count as a sync)", s.Syncs())
}
// The same bad content on the next poll must not be counted again.
s.pollOnce(context.Background())
if s.Failures() != 1 {
t.Errorf("failures = %d, want 1 (an unchanged bad push is counted once)", s.Failures())
}
}
func TestBootstrapDegradesToEmbedded(t *testing.T) {
// A bogus URL must not fail startup: Bootstrap returns the embedded set.
s := New(Options{URL: "/nonexistent/repo", Branch: "main", Interval: time.Hour, WorkDir: filepath.Join(t.TempDir(), "co")}, templates.FS)
r := newRepo(t, ks("SYNC-V1 {{ .Hostname }}\n"))
r.status.Store(http.StatusNotFound)
s := syncer(t, r)
set, err := s.Bootstrap(context.Background())
if err == nil {
t.Error("expected a non-nil (non-fatal) error describing the degrade")
@@ -133,10 +196,43 @@ func TestBootstrapDegradesToEmbedded(t *testing.T) {
t.Fatal("expected the embedded fallback Set, got nil")
}
eng := render.NewEngine(render.RenderConfig{DefaultTemplate: "almalinux9", ArtifactBase: "https://af"}, set)
// Embedded almalinux9 template still renders.
if got := renderKS(t, eng); !contains(got, "rootpw") {
if got := renderKS(t, eng); !strings.Contains(got, "rootpw") {
t.Errorf("embedded fallback did not render a real kickstart:\n%s", got)
}
}
func contains(s, sub string) bool { return strings.Contains(s, sub) }
func TestExtractStripsRootAndSkipsEscapes(t *testing.T) {
tree, err := extract(bytes.NewReader(archive(t, map[string]string{
"catalog/almalinux9.yaml": "name: almalinux9\n",
"../escape.ks.tmpl": "nope\n",
})))
if err != nil {
t.Fatalf("extract: %v", err)
}
if _, ok := tree["catalog/almalinux9.yaml"]; !ok {
t.Errorf("root not stripped; got keys %v", keys(tree))
}
for k := range tree {
if strings.Contains(k, "escape") {
t.Errorf("traversal entry was kept: %q", k)
}
}
}
func TestCommitFromLink(t *testing.T) {
link := `<https://git.unkin.net/api/v1/repos/unkin/bootapi-templates/archive/5df1894.tar.gz?rev=5df1894>; rel="immutable"`
if got := commitFromLink(link); got != "5df1894" {
t.Errorf("commitFromLink = %q, want 5df1894", got)
}
if got := commitFromLink(""); got != "" {
t.Errorf("commitFromLink(\"\") = %q, want empty", got)
}
}
func keys[V any](m map[string]V) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}