Address PR review: PXE gate + callback, git-sync templates, distro catalog, k8s targets, http+https
Implements the six review comments on PR #1: - Per-host PXE-enable gate: read NetBox pxe_enabled custom field; a known host with it false gets the safe local-boot script (Cobbler netboot_enabled). Add a token-guarded POST /provisioned/{ident} callback that clears pxe_enabled in NetBox, plus a %post snippet in the default kickstarts that calls it. - Templates from a git repo: bootapi clones a templates repo and re-pulls every BOOTAPI_TEMPLATE_GIT_INTERVAL (default 3m), atomically swapping the template set (last-good kept on parse failure; embedded defaults are the startup fallback). Metrics for syncs/failures/generation. - Distro catalog (catalog/*.yaml): NetBox host -> boot images/kickstart, so adding an OS is a YAML + template change. Ships almalinux + fedora entries (artifactapi remotes); debian/talos path documented. - Boot images from the artifactapi almalinux/fedora remotes via the catalog. - Bind resolvers, puppet server/CA and PUPPETCA_URL env file now target the k8s services (198.18.200.7; puppet(ca).k8s.syd1.au.unkin.net). - Boot path served over plain HTTP (installers lack CA trust) with an optional parallel HTTPS listener; docs say do not 301 the boot endpoints. New packages: internal/catalog, internal/gitsync. NetBox client gains a pxe_enabled write (token needs that scope - noted in docs). `bootapi validate` subcommand validates a template/catalog set for the templates-repo CI. go build/vet clean, go test -race green, golangci-lint v2 clean, pre-commit clean. Claude-Session: https://claude.ai/code/session_015ur3i7D2azsMAWTSVABApv
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
// 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
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"git.unkin.net/unkin/bootapi/internal/render"
|
||||
)
|
||||
|
||||
// Options configures the syncer.
|
||||
type Options struct {
|
||||
URL string
|
||||
Branch string
|
||||
Token string // optional; injected into the HTTPS URL for a private repo
|
||||
Interval time.Duration
|
||||
WorkDir string // local checkout path
|
||||
}
|
||||
|
||||
// Syncer pulls a templates repo and reloads an Engine on change.
|
||||
type Syncer struct {
|
||||
opt Options
|
||||
embedded fs.FS
|
||||
engine *render.Engine
|
||||
|
||||
syncs atomic.Int64 // successful reloads (Set swapped)
|
||||
failures atomic.Int64 // pull or parse failures (last-good kept)
|
||||
generation atomic.Int64 // increments on every successful swap
|
||||
}
|
||||
|
||||
// New builds a Syncer. embedded is the fallback template FS. Call SetEngine
|
||||
// before Run so reloads have an Engine to swap into (the Engine needs the
|
||||
// initial Set from Bootstrap first, hence the two-step wiring).
|
||||
func New(opt Options, embedded fs.FS) *Syncer {
|
||||
if opt.Branch == "" {
|
||||
opt.Branch = "main"
|
||||
}
|
||||
if opt.Interval <= 0 {
|
||||
opt.Interval = 3 * time.Minute
|
||||
}
|
||||
return &Syncer{opt: opt, embedded: embedded}
|
||||
}
|
||||
|
||||
// SetEngine points the syncer at the live Engine whose Set it swaps on reload.
|
||||
func (s *Syncer) SetEngine(e *render.Engine) { s.engine = e }
|
||||
|
||||
// Syncs/Failures/Generation are exported for the server's metrics collector.
|
||||
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
|
||||
// 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))
|
||||
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)
|
||||
}
|
||||
s.generation.Add(1)
|
||||
return set, nil
|
||||
}
|
||||
|
||||
// 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)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
s.pollOnce(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Syncer) pollOnce(ctx context.Context) {
|
||||
changed, head, err := s.pull(ctx)
|
||||
if err != nil {
|
||||
s.failures.Add(1)
|
||||
slog.Error("template git pull failed; keeping last-good set", "err", err)
|
||||
return
|
||||
}
|
||||
if !changed {
|
||||
return
|
||||
}
|
||||
set, err := render.BuildSet(s.embedded, os.DirFS(s.opt.WorkDir))
|
||||
if err != nil {
|
||||
s.failures.Add(1)
|
||||
slog.Error("template reload failed to parse; keeping last-good set", "commit", head, "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())
|
||||
}
|
||||
|
||||
// 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)
|
||||
if err != nil {
|
||||
return false, "", 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)))
|
||||
}
|
||||
return 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
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package gitsync
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.unkin.net/unkin/bootapi/internal/model"
|
||||
"git.unkin.net/unkin/bootapi/internal/render"
|
||||
"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) {
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func gitCmd(t *testing.T, dir, name string, args ...string) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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 TestBootstrapAndReload(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.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") {
|
||||
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")
|
||||
|
||||
s.pollOnce(context.Background())
|
||||
if got := renderKS(t, eng); !contains(got, "GITSYNC-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 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")
|
||||
|
||||
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") {
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
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)
|
||||
// Embedded almalinux9 template still renders.
|
||||
if got := renderKS(t, eng); !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) }
|
||||
Reference in New Issue
Block a user