Compare commits
2 Commits
ca86d9cedc
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| edc90f01d9 | |||
| a414918350 |
@@ -88,7 +88,7 @@ internal/model/ Host/Interface data model (incl. pxe_enabled gate)
|
||||
internal/netbox/ NetBox client (reads + pxe_enabled write) + TTL cache, behind an interface
|
||||
internal/catalog/ distro catalog: NetBox host -> boot images/kickstart
|
||||
internal/render/ text/template engine (swappable Set), selection, loader
|
||||
internal/gitsync/ periodic git pull + atomic template reload (last-good)
|
||||
internal/gitsync/ periodic templates-tarball fetch + atomic reload (last-good)
|
||||
internal/server/ chi HTTP handlers + Prometheus metrics
|
||||
templates/ embedded defaults: kickstart, iPXE, catalog/*.yaml
|
||||
docs/ see above
|
||||
|
||||
+5
-7
@@ -9,7 +9,6 @@ import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
|
||||
"git.unkin.net/unkin/bootapi/internal/config"
|
||||
@@ -133,10 +132,10 @@ func runValidate(dir string) int {
|
||||
return 0
|
||||
}
|
||||
|
||||
// buildEngine constructs the render Engine and, when a templates git repo is
|
||||
// configured, a Syncer that reloads it periodically. Precedence: git repo →
|
||||
// local override dir → embedded defaults only. Git/dir failures degrade to the
|
||||
// embedded defaults rather than failing startup.
|
||||
// buildEngine constructs the render Engine and, when a templates repo is
|
||||
// configured, a Syncer that reloads it periodically. Precedence: templates repo
|
||||
// → local override dir → embedded defaults only. Fetch/dir failures degrade to
|
||||
// the embedded defaults rather than failing startup.
|
||||
func buildEngine(ctx context.Context, cfg *config.Config, rcfg render.RenderConfig) (*render.Engine, *gitsync.Syncer, error) {
|
||||
switch {
|
||||
case cfg.TemplateGitURL != "":
|
||||
@@ -145,11 +144,10 @@ func buildEngine(ctx context.Context, cfg *config.Config, rcfg render.RenderConf
|
||||
Branch: cfg.TemplateGitBranch,
|
||||
Token: cfg.TemplateGitToken,
|
||||
Interval: cfg.TemplateGitInterval,
|
||||
WorkDir: filepath.Join(os.TempDir(), "bootapi-templates"),
|
||||
}, templates.FS)
|
||||
set, gerr := syncer.Bootstrap(ctx)
|
||||
if gerr != nil {
|
||||
slog.Warn("template git bootstrap degraded to embedded defaults", "err", gerr)
|
||||
slog.Warn("template bootstrap degraded to embedded defaults", "err", gerr)
|
||||
}
|
||||
engine := render.NewEngine(rcfg, set)
|
||||
syncer.SetEngine(engine)
|
||||
|
||||
+3
-4
@@ -44,10 +44,9 @@ Create `apps/base/bootapi/` following the argocd-apps `AGENTS.md` pattern:
|
||||
as `BOOTAPI_NETBOX_TOKEN_FILE` / `BOOTAPI_PROVISION_TOKEN_FILE` /
|
||||
`BOOTAPI_ROOT_PASSWORD_HASH_FILE` (mount the Secret). Least-privilege
|
||||
securityContext (`runAsNonRoot`, `drop: [all]`). Baseline resources: requests
|
||||
`512Mi`/`1`, limits `2Gi`/`2` cpu. The pod needs `git` on PATH for template
|
||||
sync (the distroless image includes only the static binary — either add a git
|
||||
layer, use an initContainer that seeds the checkout, or fall back to a
|
||||
ConfigMap; simplest is a small alpine+git base for this service).
|
||||
`512Mi`/`1`, limits `2Gi`/`2` cpu. No `git` binary and no writable volume
|
||||
are needed: template sync is an HTTP fetch of the repo's branch tarball, held
|
||||
in memory.
|
||||
6. **Service + exposure**: see the Gateway section below.
|
||||
7. Register in `argocd/applicationsets/platform.yaml` (`apps/overlays/*/bootapi`)
|
||||
and the platform AppProject destinations.
|
||||
|
||||
@@ -8,7 +8,8 @@ bootapi ships an embedded default set and lets you override or extend it.
|
||||
`templates/ipxe/*.ipxe.tmpl` and `templates/catalog/*.yaml`, compiled into the
|
||||
binary (`templates/embed.go`). These are the always-available startup fallback.
|
||||
- **Template git repo** (preferred in prod): `BOOTAPI_TEMPLATE_GIT_URL`. bootapi
|
||||
clones it at startup and re-pulls every `BOOTAPI_TEMPLATE_GIT_INTERVAL`
|
||||
fetches the branch tarball (`<repo>/archive/<branch>.tar.gz`) over HTTP at
|
||||
startup and re-fetches every `BOOTAPI_TEMPLATE_GIT_INTERVAL`
|
||||
(default 3m, like argocd), atomically swapping the loaded set on change. A
|
||||
parse failure keeps the **last-good** set and is only logged + counted
|
||||
(`bootapi_template_sync_failures_total`), so a bad push can't take bootapi
|
||||
|
||||
@@ -46,14 +46,15 @@ type Config struct {
|
||||
DefaultTemplate string
|
||||
|
||||
// --- template git-sync (preferred over TemplateDir) ---
|
||||
// TemplateGitURL, when set, makes bootapi clone a templates repo and re-pull
|
||||
// TemplateGitURL, when set, makes bootapi fetch a templates repo's branch
|
||||
// tarball over HTTP (<repo>/archive/<branch>.tar.gz) and re-fetch
|
||||
// it every TemplateGitInterval, atomically swapping the loaded set on change
|
||||
// and keeping the last-good set on a parse failure.
|
||||
TemplateGitURL string
|
||||
TemplateGitBranch string
|
||||
TemplateGitInterval time.Duration
|
||||
// TemplateGitToken is an optional token for a private templates repo,
|
||||
// injected into the HTTPS clone URL. Empty for a public repo.
|
||||
// sent as a Gitea token header. Empty for a public repo.
|
||||
TemplateGitToken string
|
||||
|
||||
// BaseURL is the http:// base PXE clients use to reach bootapi. It is baked
|
||||
|
||||
+174
-84
@@ -1,42 +1,63 @@
|
||||
// Package gitsync keeps bootapi's template Set in step with a git repo. It
|
||||
// clones the templates repo at startup and re-pulls it every interval (default
|
||||
// 3m, like argocd), atomically swapping the Engine's active Set when the repo
|
||||
// changes. A parse failure keeps the last-good Set and is only logged/counted,
|
||||
// so a bad template push can never take bootapi down. The embedded defaults
|
||||
// remain the fallback when git is unreachable at startup.
|
||||
// Package gitsync keeps bootapi's template Set in step with the templates repo.
|
||||
// It fetches the repo's branch tarball over plain HTTP (Gitea's
|
||||
// /archive/<branch>.tar.gz) every interval (default 3m, like argocd), holds the
|
||||
// template files in memory, and atomically swaps the Engine's active Set when
|
||||
// the content changes. A parse failure keeps the last-good Set and is only
|
||||
// logged/counted, so a bad template push can never take bootapi down. The
|
||||
// embedded defaults remain the fallback when the repo is unreachable at startup.
|
||||
//
|
||||
// The repo is only ever read as a file tree plus a change signal, so no git
|
||||
// binary is involved: the runtime image stays distroless and the pod needs no
|
||||
// writable volume.
|
||||
package gitsync
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"net/http"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
"git.unkin.net/unkin/bootapi/internal/render"
|
||||
)
|
||||
|
||||
// maxArchiveBytes caps the downloaded tarball. The templates repo is a handful
|
||||
// of text files (~12KB compressed); this only exists to bound a hostile or
|
||||
// broken response.
|
||||
const maxArchiveBytes = 32 << 20
|
||||
|
||||
// maxFileBytes caps a single extracted template.
|
||||
const maxFileBytes = 1 << 20
|
||||
|
||||
// Options configures the syncer.
|
||||
type Options struct {
|
||||
URL string
|
||||
URL string // repo URL, e.g. https://git.unkin.net/unkin/bootapi-templates.git
|
||||
Branch string
|
||||
Token string // optional; injected into the HTTPS URL for a private repo
|
||||
Token string // optional; sent as a Gitea token header for a private repo
|
||||
Interval time.Duration
|
||||
WorkDir string // local checkout path
|
||||
Client *http.Client // optional; defaults to a 30s-timeout client
|
||||
}
|
||||
|
||||
// Syncer pulls a templates repo and reloads an Engine on change.
|
||||
// Syncer fetches a templates repo and reloads an Engine on change.
|
||||
type Syncer struct {
|
||||
opt Options
|
||||
embedded fs.FS
|
||||
engine *render.Engine
|
||||
|
||||
digest string // content digest of the last fetched tree
|
||||
syncs atomic.Int64 // successful reloads (Set swapped)
|
||||
failures atomic.Int64 // pull or parse failures (last-good kept)
|
||||
failures atomic.Int64 // fetch or parse failures (last-good kept)
|
||||
generation atomic.Int64 // increments on every successful swap
|
||||
}
|
||||
|
||||
@@ -50,6 +71,9 @@ func New(opt Options, embedded fs.FS) *Syncer {
|
||||
if opt.Interval <= 0 {
|
||||
opt.Interval = 3 * time.Minute
|
||||
}
|
||||
if opt.Client == nil {
|
||||
opt.Client = &http.Client{Timeout: 30 * time.Second}
|
||||
}
|
||||
return &Syncer{opt: opt, embedded: embedded}
|
||||
}
|
||||
|
||||
@@ -61,35 +85,46 @@ func (s *Syncer) Syncs() int64 { return s.syncs.Load() }
|
||||
func (s *Syncer) Failures() int64 { return s.failures.Load() }
|
||||
func (s *Syncer) Generation() int64 { return s.generation.Load() }
|
||||
|
||||
// Bootstrap clones the repo and builds the initial Set from embedded + the
|
||||
// checkout. On any git/parse failure it returns an embedded-only Set plus a
|
||||
// non-nil error (which the caller logs but treats as non-fatal, so bootapi
|
||||
// ArchiveURL is the branch tarball URL derived from the repo URL.
|
||||
func (o Options) ArchiveURL() string {
|
||||
base := strings.TrimSuffix(strings.TrimRight(o.URL, "/"), ".git")
|
||||
return base + "/archive/" + o.Branch + ".tar.gz"
|
||||
}
|
||||
|
||||
// Bootstrap fetches the repo and builds the initial Set from embedded + the
|
||||
// fetched tree. On any fetch/parse failure it returns an embedded-only Set plus
|
||||
// a non-nil error (which the caller logs but treats as non-fatal, so bootapi
|
||||
// always starts with at least the embedded defaults).
|
||||
func (s *Syncer) Bootstrap(ctx context.Context) (*render.Set, error) {
|
||||
if err := s.clone(ctx); err != nil {
|
||||
set, berr := render.BuildSet(s.embedded, nil)
|
||||
if berr != nil {
|
||||
return nil, berr // embedded defaults broken: genuinely fatal
|
||||
}
|
||||
return set, fmt.Errorf("git clone failed, using embedded defaults: %w", err)
|
||||
}
|
||||
set, err := render.BuildSet(s.embedded, os.DirFS(s.opt.WorkDir))
|
||||
tree, digest, commit, err := s.fetch(ctx)
|
||||
if err != nil {
|
||||
emb, berr := render.BuildSet(s.embedded, nil)
|
||||
if berr != nil {
|
||||
return nil, berr
|
||||
}
|
||||
return emb, fmt.Errorf("git templates failed to parse, using embedded defaults: %w", err)
|
||||
return s.embeddedSet(fmt.Errorf("template fetch failed, using embedded defaults: %w", err))
|
||||
}
|
||||
s.digest = digest
|
||||
set, err := render.BuildSet(s.embedded, tree)
|
||||
if err != nil {
|
||||
return s.embeddedSet(fmt.Errorf("fetched templates failed to parse, using embedded defaults: %w", err))
|
||||
}
|
||||
s.generation.Add(1)
|
||||
slog.Info("templates loaded", "commit", commit, "digest", digest)
|
||||
return set, nil
|
||||
}
|
||||
|
||||
// embeddedSet returns the embedded-only Set alongside the degrade reason. A
|
||||
// broken embedded set is genuinely fatal.
|
||||
func (s *Syncer) embeddedSet(reason error) (*render.Set, error) {
|
||||
set, err := render.BuildSet(s.embedded, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return set, reason
|
||||
}
|
||||
|
||||
// Run polls the repo every interval until ctx is cancelled.
|
||||
func (s *Syncer) Run(ctx context.Context) {
|
||||
t := time.NewTicker(s.opt.Interval)
|
||||
defer t.Stop()
|
||||
slog.Info("template git-sync started", "url", s.opt.URL, "branch", s.opt.Branch, "interval", s.opt.Interval)
|
||||
slog.Info("template sync started", "url", s.opt.ArchiveURL(), "interval", s.opt.Interval)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
@@ -101,78 +136,133 @@ func (s *Syncer) Run(ctx context.Context) {
|
||||
}
|
||||
|
||||
func (s *Syncer) pollOnce(ctx context.Context) {
|
||||
changed, head, err := s.pull(ctx)
|
||||
tree, digest, commit, err := s.fetch(ctx)
|
||||
if err != nil {
|
||||
s.failures.Add(1)
|
||||
slog.Error("template git pull failed; keeping last-good set", "err", err)
|
||||
slog.Error("template fetch failed; keeping last-good set", "err", err)
|
||||
return
|
||||
}
|
||||
if !changed {
|
||||
if digest == s.digest {
|
||||
return
|
||||
}
|
||||
set, err := render.BuildSet(s.embedded, os.DirFS(s.opt.WorkDir))
|
||||
// Record the new digest before parsing so an unchanged bad push is counted
|
||||
// once, not on every poll.
|
||||
s.digest = digest
|
||||
set, err := render.BuildSet(s.embedded, tree)
|
||||
if err != nil {
|
||||
s.failures.Add(1)
|
||||
slog.Error("template reload failed to parse; keeping last-good set", "commit", head, "err", err)
|
||||
slog.Error("template reload failed to parse; keeping last-good set", "commit", commit, "err", err)
|
||||
return
|
||||
}
|
||||
s.engine.Swap(set)
|
||||
s.syncs.Add(1)
|
||||
s.generation.Add(1)
|
||||
slog.Info("templates reloaded from git", "commit", head, "generation", s.generation.Load())
|
||||
slog.Info("templates reloaded", "commit", commit, "digest", digest, "generation", s.generation.Load())
|
||||
}
|
||||
|
||||
// authURL injects a token into the HTTPS clone URL when configured.
|
||||
func (s *Syncer) authURL() string {
|
||||
if s.opt.Token == "" {
|
||||
return s.opt.URL
|
||||
}
|
||||
if rest, ok := strings.CutPrefix(s.opt.URL, "https://"); ok {
|
||||
return "https://" + s.opt.Token + "@" + rest
|
||||
}
|
||||
return s.opt.URL
|
||||
}
|
||||
|
||||
func (s *Syncer) clone(ctx context.Context) error {
|
||||
if err := os.RemoveAll(s.opt.WorkDir); err != nil {
|
||||
return err
|
||||
}
|
||||
return run(ctx, "", "git", "clone", "--depth", "1", "--branch", s.opt.Branch, s.authURL(), s.opt.WorkDir)
|
||||
}
|
||||
|
||||
// pull fetches origin/branch and hard-resets to it, reporting whether HEAD moved.
|
||||
func (s *Syncer) pull(ctx context.Context) (changed bool, head string, err error) {
|
||||
old, _ := s.head(ctx)
|
||||
if err := run(ctx, s.opt.WorkDir, "git", "fetch", "--depth", "1", "origin", s.opt.Branch); err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
if err := run(ctx, s.opt.WorkDir, "git", "reset", "--hard", "origin/"+s.opt.Branch); err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
newHead, err := s.head(ctx)
|
||||
// fetch downloads the branch tarball and extracts it into an in-memory FS. The
|
||||
// digest is taken over the extracted tree (not the gzip bytes) so a
|
||||
// re-compressed but identical archive is not treated as a change. commit is the
|
||||
// source commit Gitea advertises in its immutable Link header, for logging only.
|
||||
func (s *Syncer) fetch(ctx context.Context) (fs.FS, string, string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.opt.ArchiveURL(), nil)
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
return nil, "", "", err
|
||||
}
|
||||
return old != newHead, newHead, nil
|
||||
}
|
||||
|
||||
func (s *Syncer) head(ctx context.Context) (string, error) {
|
||||
out, err := output(ctx, s.opt.WorkDir, "git", "rev-parse", "HEAD")
|
||||
return strings.TrimSpace(out), err
|
||||
}
|
||||
|
||||
func run(ctx context.Context, dir, name string, args ...string) error {
|
||||
cmd := exec.CommandContext(ctx, name, args...)
|
||||
cmd.Dir = dir
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("%s %s: %w: %s", name, strings.Join(args, " "), err, strings.TrimSpace(string(out)))
|
||||
if s.opt.Token != "" {
|
||||
req.Header.Set("Authorization", "token "+s.opt.Token)
|
||||
}
|
||||
return nil
|
||||
resp, err := s.opt.Client.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, "", "", fmt.Errorf("GET %s: %s", s.opt.ArchiveURL(), resp.Status)
|
||||
}
|
||||
|
||||
tree, err := extract(io.LimitReader(resp.Body, maxArchiveBytes))
|
||||
if err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
if len(tree) == 0 {
|
||||
return nil, "", "", fmt.Errorf("archive contained no files")
|
||||
}
|
||||
return tree, digest(tree), commitFromLink(resp.Header.Get("Link")), nil
|
||||
}
|
||||
|
||||
func output(ctx context.Context, dir, name string, args ...string) (string, error) {
|
||||
cmd := exec.CommandContext(ctx, name, args...)
|
||||
cmd.Dir = dir
|
||||
out, err := cmd.Output()
|
||||
return string(out), err
|
||||
// extract reads a gzipped tar and returns its regular files keyed by path with
|
||||
// the archive's single top-level directory stripped (Gitea prefixes every entry
|
||||
// with "<repo>/"). Entries that would escape the tree are skipped rather than
|
||||
// trusted: the archive is a network input.
|
||||
func extract(r io.Reader) (fstest.MapFS, error) {
|
||||
gz, err := gzip.NewReader(r)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gzip: %w", err)
|
||||
}
|
||||
defer func() { _ = gz.Close() }()
|
||||
|
||||
out := fstest.MapFS{}
|
||||
tr := tar.NewReader(gz)
|
||||
for {
|
||||
h, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
return out, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("tar: %w", err)
|
||||
}
|
||||
if h.Typeflag != tar.TypeReg {
|
||||
continue
|
||||
}
|
||||
name := stripRoot(h.Name)
|
||||
if name == "" || !fs.ValidPath(name) {
|
||||
continue
|
||||
}
|
||||
b, err := io.ReadAll(io.LimitReader(tr, maxFileBytes))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("tar %s: %w", h.Name, err)
|
||||
}
|
||||
out[name] = &fstest.MapFile{Data: b, Mode: 0o444}
|
||||
}
|
||||
}
|
||||
|
||||
// stripRoot removes the archive's leading directory component.
|
||||
func stripRoot(name string) string {
|
||||
clean := path.Clean(strings.TrimPrefix(name, "./"))
|
||||
if strings.HasPrefix(clean, "/") || strings.HasPrefix(clean, "..") {
|
||||
return ""
|
||||
}
|
||||
_, rest, ok := strings.Cut(clean, "/")
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return rest
|
||||
}
|
||||
|
||||
// digest hashes the extracted tree: every path and its contents, in path order.
|
||||
func digest(tree fstest.MapFS) string {
|
||||
names := make([]string, 0, len(tree))
|
||||
for n := range tree {
|
||||
names = append(names, n)
|
||||
}
|
||||
sort.Strings(names)
|
||||
|
||||
h := sha256.New()
|
||||
for _, n := range names {
|
||||
_, _ = fmt.Fprintf(h, "%s\x00%d\x00", n, len(tree[n].Data))
|
||||
_, _ = h.Write(tree[n].Data)
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil))[:16]
|
||||
}
|
||||
|
||||
// commitFromLink pulls the commit SHA out of Gitea's immutable-archive Link
|
||||
// header: <.../archive/<sha>.tar.gz?rev=<sha>>; rel="immutable".
|
||||
func commitFromLink(link string) string {
|
||||
_, rev, ok := strings.Cut(link, "rev=")
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
sha, _, _ := strings.Cut(rev, ">")
|
||||
return strings.TrimSpace(sha)
|
||||
}
|
||||
|
||||
@@ -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