8f356346eb
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
179 lines
5.5 KiB
Go
179 lines
5.5 KiB
Go
// 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
|
|
}
|