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
|
||||
}
|
||||
Reference in New Issue
Block a user