Address PR review: PXE gate + callback, git-sync templates, distro catalog, k8s targets, http+https
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful

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:
2026-07-28 22:34:44 +10:00
parent 274c480b09
commit 8f356346eb
32 changed files with 2119 additions and 357 deletions
+134 -28
View File
@@ -4,10 +4,13 @@ package server
import (
"context"
"crypto/subtle"
"errors"
"fmt"
"log/slog"
"net/http"
"strings"
"sync"
"time"
"github.com/go-chi/chi/v5"
@@ -19,23 +22,35 @@ import (
"git.unkin.net/unkin/bootapi/internal/render"
)
// Server wires the NetBox resolver and template engine into HTTP handlers.
// Server wires the NetBox API and template engine into HTTP handlers.
type Server struct {
resolver netbox.Resolver
engine *render.Engine
metrics *metrics
nb netbox.API
engine *render.Engine
metrics *metrics
// fallback is the unknown-MAC iPXE behavior: "local" (safe default) or
// "shell" (debug).
fallback string
// provisionToken guards POST /provisioned; empty disables the endpoint.
provisionToken string
// TLS listener (optional); the plain-HTTP listener is always on.
tlsAddr string
tlsCert string
tlsKey string
}
// Options configures a Server.
type Options struct {
Resolver netbox.Resolver
Engine *render.Engine
// Cache, when non-nil, has its hit/miss counters published as metrics.
NetBox netbox.API
Engine *render.Engine
// Cache/GitStats, when non-nil, have their counters published as metrics.
Cache cacheStats
GitStats gitStats
UnknownMACFallback string
ProvisionToken string
TLSAddr string
TLSCertFile string
TLSKeyFile string
}
// New builds a Server.
@@ -45,10 +60,14 @@ func New(o Options) *Server {
fb = "local"
}
return &Server{
resolver: o.Resolver,
engine: o.Engine,
metrics: newMetrics(o.Cache),
fallback: fb,
nb: o.NetBox,
engine: o.Engine,
metrics: newMetrics(o.Cache, o.GitStats),
fallback: fb,
provisionToken: o.ProvisionToken,
tlsAddr: o.TLSAddr,
tlsCert: o.TLSCertFile,
tlsKey: o.TLSKeyFile,
}
}
@@ -70,6 +89,9 @@ func (s *Server) Router() http.Handler {
// Rendered kickstart, keyed by MAC or hostname.
r.Get("/ks/{ident}", s.handleKickstart)
// End-of-kickstart callback: flips pxe_enabled off in NetBox. Token-guarded.
r.Post("/provisioned/{ident}", s.handleProvisioned)
return r
}
@@ -116,6 +138,15 @@ func (s *Server) serveIPXE(w http.ResponseWriter, r *http.Request, mac string) {
s.renderFallback(w, "ipxe", "unknown or unresolvable MAC")
return
}
// Per-host PXE-enable gate (Cobbler's netboot_enabled): a KNOWN host whose
// pxe_enabled is false must NOT re-install. Serve the safe local-boot script
// so an already-provisioned machine just boots its disk.
if !host.ShouldPXEInstall() {
s.metrics.ipxeGated.Inc()
slog.Info("ipxe gated: pxe_enabled=false; serving local boot", "host", host.Hostname)
s.renderFallback(w, "ipxe", "pxe disabled for host")
return
}
body, err := s.engine.RenderIPXE(host)
if err != nil {
s.metrics.renders.WithLabelValues("ipxe", "error").Inc()
@@ -180,15 +211,68 @@ func (s *Server) handleKickstart(w http.ResponseWriter, r *http.Request) {
s.ok(w, http.StatusOK, "text/plain", body, "ks")
}
// handleProvisioned is the end-of-kickstart callback. The %post posts here with
// the shared provision token when the install finishes; bootapi flips the host's
// pxe_enabled custom field to false in NetBox so the next PXE boots local disk
// instead of re-installing. This is bootapi's only NetBox write.
func (s *Server) handleProvisioned(w http.ResponseWriter, r *http.Request) {
if s.provisionToken == "" {
http.Error(w, "provisioned callback disabled: no token configured", http.StatusServiceUnavailable)
s.metrics.provisioned.WithLabelValues("disabled").Inc()
return
}
if subtle.ConstantTimeCompare([]byte(bearer(r)), []byte(s.provisionToken)) != 1 {
http.Error(w, "invalid or missing provision token", http.StatusUnauthorized)
s.metrics.provisioned.WithLabelValues("unauthorized").Inc()
return
}
ident := chi.URLParam(r, "ident")
field := "name"
if looksLikeMAC(ident) {
field = "mac"
}
host, err := s.lookup(r.Context(), field, ident)
if err != nil {
if errors.Is(err, netbox.ErrNotFound) {
http.Error(w, "no host in NetBox for "+ident, http.StatusNotFound)
s.metrics.provisioned.WithLabelValues("notfound").Inc()
return
}
http.Error(w, "netbox lookup failed", http.StatusBadGateway)
s.metrics.provisioned.WithLabelValues("error").Inc()
return
}
if err := s.nb.SetPXEEnabled(r.Context(), host.DeviceID, false); err != nil {
slog.Error("provisioned: failed to clear pxe_enabled", "host", host.Hostname, "err", err)
http.Error(w, "failed to update NetBox", http.StatusBadGateway)
s.metrics.provisioned.WithLabelValues("error").Inc()
return
}
s.metrics.provisioned.WithLabelValues("ok").Inc()
slog.Info("host provisioned; pxe_enabled cleared", "host", host.Hostname)
w.WriteHeader(http.StatusNoContent)
}
// bearer extracts a token from "Authorization: Bearer <t>" or a bare "token"
// header.
func bearer(r *http.Request) string {
if h := r.Header.Get("Authorization"); h != "" {
if after, ok := strings.CutPrefix(h, "Bearer "); ok {
return after
}
}
return r.Header.Get("token")
}
// lookup resolves a host by field ("mac" or "name"), recording metrics.
func (s *Server) lookup(ctx context.Context, field, value string) (*model.Host, error) {
start := time.Now()
var host *model.Host
var err error
if field == "mac" {
host, err = s.resolver.HostByMAC(ctx, value)
host, err = s.nb.HostByMAC(ctx, value)
} else {
host, err = s.resolver.HostByName(ctx, value)
host, err = s.nb.HostByName(ctx, value)
}
s.metrics.netboxDuration.WithLabelValues(field).Observe(time.Since(start).Seconds())
switch {
@@ -209,24 +293,46 @@ func (s *Server) ok(w http.ResponseWriter, status int, contentType string, body
s.metrics.httpRequests.WithLabelValues(endpoint, statusClass(status)).Inc()
}
// ListenAndServe runs the HTTP server until ctx is cancelled.
// ListenAndServe runs the plain-HTTP server (always) plus, when a TLS listener
// is configured, an HTTPS server sharing the same handler — both until ctx is
// cancelled. The boot path works over plain HTTP because PXE installers have no
// internal CA trust; HTTPS is offered in parallel for clients that do.
func (s *Server) ListenAndServe(ctx context.Context, addr string) error {
srv := &http.Server{
Addr: addr,
Handler: s.Router(),
ReadHeaderTimeout: 10 * time.Second,
h := s.Router()
var wg sync.WaitGroup
errc := make(chan error, 2)
serve := func(name string, srv *http.Server, tls bool) {
defer wg.Done()
go func() {
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = srv.Shutdown(shutdownCtx)
}()
slog.Info("bootapi listening", "listener", name, "addr", srv.Addr)
var err error
if tls {
err = srv.ListenAndServeTLS(s.tlsCert, s.tlsKey)
} else {
err = srv.ListenAndServe()
}
if err != nil && !errors.Is(err, http.ErrServerClosed) {
errc <- fmt.Errorf("%s listener: %w", name, err)
}
}
go func() {
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = srv.Shutdown(shutdownCtx)
}()
slog.Info("bootapi listening", "addr", addr)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
return err
wg.Add(1)
go serve("http", &http.Server{Addr: addr, Handler: h, ReadHeaderTimeout: 10 * time.Second}, false)
if s.tlsAddr != "" && s.tlsCert != "" && s.tlsKey != "" {
wg.Add(1)
go serve("https", &http.Server{Addr: s.tlsAddr, Handler: h, ReadHeaderTimeout: 10 * time.Second}, true)
}
return nil
wg.Wait()
close(errc)
return <-errc // first error, or nil (channel closed empty)
}
// looksLikeMAC reports whether s is plausibly a MAC (12 hex nibbles, ignoring