Files
bootapi/internal/gitsync/gitsync_test.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

239 lines
7.1 KiB
Go

package gitsync
import (
"archive/tar"
"bytes"
"compress/gzip"
"context"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"git.unkin.net/unkin/bootapi/internal/model"
"git.unkin.net/unkin/bootapi/internal/render"
"git.unkin.net/unkin/bootapi/templates"
)
// 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()
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()
}
// 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()
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 {
t.Helper()
h := &model.Host{Hostname: "web01", Platform: "almalinux9", OSFamily: "almalinux", OSVersion: "9", Arch: "x86_64"}
out, _, err := e.RenderKickstart(h)
if err != nil {
t.Fatalf("RenderKickstart: %v", err)
}
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) {
r := newRepo(t, ks("SYNC-V1 {{ .Hostname }}\n"))
s := syncer(t, r)
eng := engineFor(t, s)
if got := renderKS(t, eng); !strings.Contains(got, "SYNC-V1 web01") {
t.Fatalf("initial render missing v1 override:\n%s", got)
}
gen1 := s.Generation()
r.push(ks("SYNC-V2 {{ .Hostname }}\n"))
s.pollOnce(context.Background())
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 {
t.Errorf("generation did not advance: %d <= %d", s.Generation(), gen1)
}
if s.Syncs() != 1 {
t.Errorf("syncs = %d, want 1", s.Syncs())
}
}
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())
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 {
t.Errorf("failures = %d, want 1", s.Failures())
}
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) {
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")
}
if set == nil {
t.Fatal("expected the embedded fallback Set, got nil")
}
eng := render.NewEngine(render.RenderConfig{DefaultTemplate: "almalinux9", ArtifactBase: "https://af"}, set)
if got := renderKS(t, eng); !strings.Contains(got, "rootpw") {
t.Errorf("embedded fallback did not render a real kickstart:\n%s", got)
}
}
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
}