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
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user