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:
@@ -11,6 +11,13 @@ type cacheStats interface {
|
||||
Misses() int64
|
||||
}
|
||||
|
||||
// gitStats is the read side of the template git-syncer the collector publishes.
|
||||
type gitStats interface {
|
||||
Syncs() int64
|
||||
Failures() int64
|
||||
Generation() int64
|
||||
}
|
||||
|
||||
// metrics holds bootapi's Prometheus instruments, registered on a private
|
||||
// registry so tests can construct isolated servers.
|
||||
type metrics struct {
|
||||
@@ -20,9 +27,11 @@ type metrics struct {
|
||||
renders *prometheus.CounterVec // by kind,result
|
||||
netboxLookups *prometheus.CounterVec // by field,result
|
||||
netboxDuration *prometheus.HistogramVec
|
||||
provisioned *prometheus.CounterVec // by result
|
||||
ipxeGated prometheus.Counter
|
||||
}
|
||||
|
||||
func newMetrics(cache cacheStats) *metrics {
|
||||
func newMetrics(cache cacheStats, git gitStats) *metrics {
|
||||
reg := prometheus.NewRegistry()
|
||||
m := &metrics{
|
||||
reg: reg,
|
||||
@@ -43,11 +52,22 @@ func newMetrics(cache cacheStats) *metrics {
|
||||
Help: "Latency of NetBox host resolutions.",
|
||||
Buckets: prometheus.DefBuckets,
|
||||
}, []string{"field"}),
|
||||
provisioned: prometheus.NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "bootapi_provisioned_total",
|
||||
Help: "Provisioned callbacks, by result (ok|unauthorized|notfound|error|disabled).",
|
||||
}, []string{"result"}),
|
||||
ipxeGated: prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "bootapi_ipxe_gated_total",
|
||||
Help: "Known hosts served the local-boot fallback because pxe_enabled=false.",
|
||||
}),
|
||||
}
|
||||
reg.MustRegister(m.httpRequests, m.renders, m.netboxLookups, m.netboxDuration)
|
||||
reg.MustRegister(m.httpRequests, m.renders, m.netboxLookups, m.netboxDuration, m.provisioned, m.ipxeGated)
|
||||
if cache != nil {
|
||||
reg.MustRegister(newCacheCollector(cache))
|
||||
}
|
||||
if git != nil {
|
||||
reg.MustRegister(newGitCollector(git))
|
||||
}
|
||||
reg.MustRegister(
|
||||
collectors.NewGoCollector(),
|
||||
collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}),
|
||||
@@ -55,6 +75,35 @@ func newMetrics(cache cacheStats) *metrics {
|
||||
return m
|
||||
}
|
||||
|
||||
// gitCollector publishes the template git-syncer counters.
|
||||
type gitCollector struct {
|
||||
stats gitStats
|
||||
syncs *prometheus.Desc
|
||||
failures *prometheus.Desc
|
||||
generation *prometheus.Desc
|
||||
}
|
||||
|
||||
func newGitCollector(s gitStats) *gitCollector {
|
||||
return &gitCollector{
|
||||
stats: s,
|
||||
syncs: prometheus.NewDesc("bootapi_template_sync_total", "Successful template reloads from git.", nil, nil),
|
||||
failures: prometheus.NewDesc("bootapi_template_sync_failures_total", "Template git pull/parse failures (last-good kept).", nil, nil),
|
||||
generation: prometheus.NewDesc("bootapi_template_generation", "Monotonic counter of the active template generation.", nil, nil),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *gitCollector) Describe(ch chan<- *prometheus.Desc) {
|
||||
ch <- c.syncs
|
||||
ch <- c.failures
|
||||
ch <- c.generation
|
||||
}
|
||||
|
||||
func (c *gitCollector) Collect(ch chan<- prometheus.Metric) {
|
||||
ch <- prometheus.MustNewConstMetric(c.syncs, prometheus.CounterValue, float64(c.stats.Syncs()))
|
||||
ch <- prometheus.MustNewConstMetric(c.failures, prometheus.CounterValue, float64(c.stats.Failures()))
|
||||
ch <- prometheus.MustNewConstMetric(c.generation, prometheus.GaugeValue, float64(c.stats.Generation()))
|
||||
}
|
||||
|
||||
// cacheCollector publishes the NetBox cache hit/miss counters, which live on
|
||||
// the Cache itself (atomic ints) rather than in a CounterVec.
|
||||
type cacheCollector struct {
|
||||
|
||||
+134
-28
@@ -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
|
||||
|
||||
+117
-24
@@ -13,14 +13,16 @@ import (
|
||||
"git.unkin.net/unkin/bootapi/templates"
|
||||
)
|
||||
|
||||
// fakeResolver is a canned netbox.Resolver for handler tests.
|
||||
type fakeResolver struct {
|
||||
byMAC map[string]*model.Host
|
||||
byName map[string]*model.Host
|
||||
err error
|
||||
// fakeNB is a canned netbox.API (reads + pxe_enabled write) for handler tests.
|
||||
type fakeNB struct {
|
||||
byMAC map[string]*model.Host
|
||||
byName map[string]*model.Host
|
||||
err error
|
||||
writeErr error
|
||||
writes []int // device IDs written via SetPXEEnabled
|
||||
}
|
||||
|
||||
func (f *fakeResolver) HostByMAC(_ context.Context, mac string) (*model.Host, error) {
|
||||
func (f *fakeNB) HostByMAC(_ context.Context, mac string) (*model.Host, error) {
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
@@ -31,7 +33,7 @@ func (f *fakeResolver) HostByMAC(_ context.Context, mac string) (*model.Host, er
|
||||
}
|
||||
return nil, netbox.ErrNotFound
|
||||
}
|
||||
func (f *fakeResolver) HostByName(_ context.Context, name string) (*model.Host, error) {
|
||||
func (f *fakeNB) HostByName(_ context.Context, name string) (*model.Host, error) {
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
@@ -40,9 +42,17 @@ func (f *fakeResolver) HostByName(_ context.Context, name string) (*model.Host,
|
||||
}
|
||||
return nil, netbox.ErrNotFound
|
||||
}
|
||||
func (f *fakeNB) SetPXEEnabled(_ context.Context, deviceID int, _ bool) error {
|
||||
if f.writeErr != nil {
|
||||
return f.writeErr
|
||||
}
|
||||
f.writes = append(f.writes, deviceID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func testHost() *model.Host {
|
||||
return &model.Host{
|
||||
DeviceID: 12,
|
||||
Hostname: "web01", Domain: "syd1.au.unkin.net", FQDN: "web01.syd1.au.unkin.net",
|
||||
Platform: "almalinux9", OSFamily: "almalinux", OSVersion: "9", Arch: "x86_64",
|
||||
PrimaryIP: "10.0.1.20",
|
||||
@@ -52,18 +62,25 @@ func testHost() *model.Host {
|
||||
}
|
||||
}
|
||||
|
||||
func newTestServer(t *testing.T, res netbox.Resolver, fallback string) *Server {
|
||||
func newTestServer(t *testing.T, nb netbox.API, fallback string) *Server {
|
||||
t.Helper()
|
||||
eng, err := render.NewEngine(templates.FS, "", render.RenderConfig{
|
||||
PuppetServer: "puppet.query.consul", PuppetCAServer: "puppetca.query.consul",
|
||||
BaseURL: "http://bootapi.example.net", BootBaseURL: "http://mirror.example.net/almalinux/9",
|
||||
DefaultDomain: "main.unkin.net", DefaultTemplate: "almalinux9",
|
||||
RootPasswordHash: "$6$abc$def",
|
||||
})
|
||||
return newTestServerToken(t, nb, fallback, "")
|
||||
}
|
||||
|
||||
func newTestServerToken(t *testing.T, nb netbox.API, fallback, provToken string) *Server {
|
||||
t.Helper()
|
||||
set, err := render.BuildSet(templates.FS, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return New(Options{Resolver: res, Engine: eng, UnknownMACFallback: fallback})
|
||||
eng := render.NewEngine(render.RenderConfig{
|
||||
PuppetServer: "puppet.k8s.syd1.au.unkin.net", PuppetCAServer: "puppetca.k8s.syd1.au.unkin.net",
|
||||
BaseURL: "http://bootapi.example.net", CallbackBaseURL: "http://bootapi.example.net",
|
||||
ArtifactBase: "https://af.example/api/v1/remote", ProvisionToken: provToken,
|
||||
DefaultDomain: "main.unkin.net", DefaultTemplate: "almalinux9",
|
||||
RootPasswordHash: "$6$abc$def",
|
||||
}, set)
|
||||
return New(Options{NetBox: nb, Engine: eng, UnknownMACFallback: fallback, ProvisionToken: provToken})
|
||||
}
|
||||
|
||||
func do(t *testing.T, h http.Handler, path string) *httptest.ResponseRecorder {
|
||||
@@ -73,8 +90,19 @@ func do(t *testing.T, h http.Handler, path string) *httptest.ResponseRecorder {
|
||||
return rec
|
||||
}
|
||||
|
||||
func post(t *testing.T, h http.Handler, path, token string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, path, nil)
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestIPXEKnownMAC(t *testing.T) {
|
||||
res := &fakeResolver{byMAC: map[string]*model.Host{"aa:bb:cc:00:11:22": testHost()}}
|
||||
res := &fakeNB{byMAC: map[string]*model.Host{"aa:bb:cc:00:11:22": testHost()}}
|
||||
h := newTestServer(t, res, "local").Router()
|
||||
|
||||
rec := do(t, h, "/ipxe/aa:bb:cc:00:11:22")
|
||||
@@ -88,7 +116,7 @@ func TestIPXEKnownMAC(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestIPXEUnknownMACServesFallback200(t *testing.T) {
|
||||
h := newTestServer(t, &fakeResolver{}, "local").Router()
|
||||
h := newTestServer(t, &fakeNB{}, "local").Router()
|
||||
rec := do(t, h, "/ipxe/de:ad:be:ef:00:00")
|
||||
// Unknown MAC must NOT 404 — iPXE needs a valid script. Safe local-boot.
|
||||
if rec.Code != http.StatusOK {
|
||||
@@ -100,7 +128,7 @@ func TestIPXEUnknownMACServesFallback200(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestIPXEUnknownMACShellFallback(t *testing.T) {
|
||||
h := newTestServer(t, &fakeResolver{}, "shell").Router()
|
||||
h := newTestServer(t, &fakeNB{}, "shell").Router()
|
||||
rec := do(t, h, "/ipxe/de:ad:be:ef:00:00")
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "shell") {
|
||||
t.Fatalf("shell fallback not served: %d\n%s", rec.Code, rec.Body.String())
|
||||
@@ -108,7 +136,7 @@ func TestIPXEUnknownMACShellFallback(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestIPXEQueryAlias(t *testing.T) {
|
||||
res := &fakeResolver{byMAC: map[string]*model.Host{"aa:bb:cc:00:11:22": testHost()}}
|
||||
res := &fakeNB{byMAC: map[string]*model.Host{"aa:bb:cc:00:11:22": testHost()}}
|
||||
h := newTestServer(t, res, "local").Router()
|
||||
rec := do(t, h, "/boot/ipxe?mac=AA:BB:CC:00:11:22")
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "inst.ks=") {
|
||||
@@ -117,7 +145,7 @@ func TestIPXEQueryAlias(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestKickstartByMAC(t *testing.T) {
|
||||
res := &fakeResolver{byMAC: map[string]*model.Host{"aa:bb:cc:00:11:22": testHost()}}
|
||||
res := &fakeNB{byMAC: map[string]*model.Host{"aa:bb:cc:00:11:22": testHost()}}
|
||||
h := newTestServer(t, res, "local").Router()
|
||||
rec := do(t, h, "/ks/aa:bb:cc:00:11:22")
|
||||
if rec.Code != http.StatusOK {
|
||||
@@ -132,7 +160,7 @@ func TestKickstartByMAC(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestKickstartByHostname(t *testing.T) {
|
||||
res := &fakeResolver{byName: map[string]*model.Host{"web01": testHost()}}
|
||||
res := &fakeNB{byName: map[string]*model.Host{"web01": testHost()}}
|
||||
h := newTestServer(t, res, "local").Router()
|
||||
rec := do(t, h, "/ks/web01.cfg") // .cfg suffix must be stripped
|
||||
if rec.Code != http.StatusOK {
|
||||
@@ -141,7 +169,7 @@ func TestKickstartByHostname(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestKickstartUnknownIs404(t *testing.T) {
|
||||
h := newTestServer(t, &fakeResolver{}, "local").Router()
|
||||
h := newTestServer(t, &fakeNB{}, "local").Router()
|
||||
rec := do(t, h, "/ks/nosuchhost")
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404 (kickstart must fail loudly)", rec.Code)
|
||||
@@ -149,7 +177,7 @@ func TestKickstartUnknownIs404(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestHealthAndReady(t *testing.T) {
|
||||
h := newTestServer(t, &fakeResolver{}, "local").Router()
|
||||
h := newTestServer(t, &fakeNB{}, "local").Router()
|
||||
if rec := do(t, h, "/healthz"); rec.Code != http.StatusOK {
|
||||
t.Errorf("healthz = %d", rec.Code)
|
||||
}
|
||||
@@ -159,7 +187,7 @@ func TestHealthAndReady(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestMetricsEndpoint(t *testing.T) {
|
||||
res := &fakeResolver{byMAC: map[string]*model.Host{"aa:bb:cc:00:11:22": testHost()}}
|
||||
res := &fakeNB{byMAC: map[string]*model.Host{"aa:bb:cc:00:11:22": testHost()}}
|
||||
srv := newTestServer(t, res, "local")
|
||||
h := srv.Router()
|
||||
|
||||
@@ -184,6 +212,71 @@ func TestMetricsEndpoint(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIPXEGatedWhenPXEDisabled(t *testing.T) {
|
||||
disabled := false
|
||||
host := testHost()
|
||||
host.PXEEnabled = &disabled // pxe_enabled=false: known host must NOT reinstall
|
||||
res := &fakeNB{byMAC: map[string]*model.Host{"aa:bb:cc:00:11:22": host}}
|
||||
srv := newTestServer(t, res, "local")
|
||||
h := srv.Router()
|
||||
|
||||
rec := do(t, h, "/ipxe/aa:bb:cc:00:11:22")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", rec.Code)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, "sanboot") || strings.Contains(body, "inst.ks=") {
|
||||
t.Errorf("gated host should get local-boot fallback, not an installer:\n%s", body)
|
||||
}
|
||||
if !strings.Contains(do(t, h, "/metrics").Body.String(), "bootapi_ipxe_gated_total 1") {
|
||||
t.Error("gate metric not incremented")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvisionedCallbackOK(t *testing.T) {
|
||||
res := &fakeNB{byName: map[string]*model.Host{"web01": testHost()}}
|
||||
h := newTestServerToken(t, res, "local", "prov-secret").Router()
|
||||
|
||||
rec := post(t, h, "/provisioned/web01", "prov-secret")
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d, want 204\n%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if len(res.writes) != 1 || res.writes[0] != 12 {
|
||||
t.Errorf("expected SetPXEEnabled on device 12, got writes=%v", res.writes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvisionedCallbackAuth(t *testing.T) {
|
||||
res := &fakeNB{byName: map[string]*model.Host{"web01": testHost()}}
|
||||
h := newTestServerToken(t, res, "local", "prov-secret").Router()
|
||||
|
||||
if rec := post(t, h, "/provisioned/web01", "wrong"); rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("wrong token: status = %d, want 401", rec.Code)
|
||||
}
|
||||
if rec := post(t, h, "/provisioned/web01", ""); rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("no token: status = %d, want 401", rec.Code)
|
||||
}
|
||||
if len(res.writes) != 0 {
|
||||
t.Errorf("unauthorized calls must not write NetBox, got %v", res.writes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvisionedCallbackDisabled(t *testing.T) {
|
||||
// No provision token configured -> endpoint fails closed.
|
||||
res := &fakeNB{byName: map[string]*model.Host{"web01": testHost()}}
|
||||
h := newTestServer(t, res, "local").Router()
|
||||
if rec := post(t, h, "/provisioned/web01", "anything"); rec.Code != http.StatusServiceUnavailable {
|
||||
t.Errorf("status = %d, want 503 when no token configured", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvisionedCallbackUnknownHost(t *testing.T) {
|
||||
h := newTestServerToken(t, &fakeNB{}, "local", "prov-secret").Router()
|
||||
if rec := post(t, h, "/provisioned/nosuch", "prov-secret"); rec.Code != http.StatusNotFound {
|
||||
t.Errorf("status = %d, want 404", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLooksLikeMAC(t *testing.T) {
|
||||
yes := []string{"aa:bb:cc:00:11:22", "aa-bb-cc-00-11-22", "aabbcc001122", "aabb.cc00.1122"}
|
||||
no := []string{"web01", "web01.example.net", "aa:bb:cc", "zz:bb:cc:00:11:22"}
|
||||
|
||||
Reference in New Issue
Block a user