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 // "/" 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", `; 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 := `; 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 }