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
239 lines
7.1 KiB
Go
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
|
|
}
|