274c480b09
bootapi replaces Cobbler's PXE/kickstart side. It resolves a PXE-booting host
from NetBox (by MAC or hostname), renders an iPXE boot script and a kickstart
from Go text/templates, and serves them over HTTP. The ENC half already moved to
encapi; this covers the provisioning/boot half.
What's here:
- cmd/bootapi + internal/{config,model,netbox,render,server}; embedded default
templates under templates/ (AlmaLinux 9 + Fedora kickstarts, iPXE boot +
unknown-MAC fallbacks) ported from Cobbler's boot/bootstrap contract.
- NetBox client (v4.x API) behind a Resolver interface with a short-TTL cache;
tested against httptest fixtures using real NetBox JSON shapes.
- chi HTTP server: /ipxe/{mac}, /boot/ipxe?mac=, /ks/{ident}, healthz/readyz,
Prometheus /metrics. Unknown MAC -> safe fallback iPXE (200), unknown KS -> 404.
- Secrets (root pw hash, ssh keys) injected at render time from env/Vault, never
NetBox. Config is env-based per estate convention.
- Makefile (build/test/lint/docker + patch/minor/major), Dockerfile (distroless),
.woodpecker (pre-commit, golangci-lint v2 + go test -race, docker build on PR;
image push + Gitea binary release on v* tag), docs/ and example config.
go build/vet clean, go test -race green, golangci-lint v2 clean, pre-commit clean.
Claude-Session: https://claude.ai/code/session_015ur3i7D2azsMAWTSVABApv
201 lines
6.4 KiB
Go
201 lines
6.4 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.unkin.net/unkin/bootapi/internal/model"
|
|
"git.unkin.net/unkin/bootapi/internal/netbox"
|
|
"git.unkin.net/unkin/bootapi/internal/render"
|
|
"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
|
|
}
|
|
|
|
func (f *fakeResolver) HostByMAC(_ context.Context, mac string) (*model.Host, error) {
|
|
if f.err != nil {
|
|
return nil, f.err
|
|
}
|
|
// The real NetBox client normalizes MAC case/separators before matching;
|
|
// mirror that here so case-insensitive lookups behave like production.
|
|
if h, ok := f.byMAC[strings.ToLower(mac)]; ok {
|
|
return h, nil
|
|
}
|
|
return nil, netbox.ErrNotFound
|
|
}
|
|
func (f *fakeResolver) HostByName(_ context.Context, name string) (*model.Host, error) {
|
|
if f.err != nil {
|
|
return nil, f.err
|
|
}
|
|
if h, ok := f.byName[name]; ok {
|
|
return h, nil
|
|
}
|
|
return nil, netbox.ErrNotFound
|
|
}
|
|
|
|
func testHost() *model.Host {
|
|
return &model.Host{
|
|
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",
|
|
Interfaces: []model.Interface{
|
|
{Name: "eth0", MAC: "aa:bb:cc:00:11:22", IP: "10.0.1.20", PrefixLen: 24, Netmask: "255.255.255.0", Gateway: "10.0.1.254", Primary: true},
|
|
},
|
|
}
|
|
}
|
|
|
|
func newTestServer(t *testing.T, res netbox.Resolver, 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",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return New(Options{Resolver: res, Engine: eng, UnknownMACFallback: fallback})
|
|
}
|
|
|
|
func do(t *testing.T, h http.Handler, path string) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
|
|
return rec
|
|
}
|
|
|
|
func TestIPXEKnownMAC(t *testing.T) {
|
|
res := &fakeResolver{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")
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d", rec.Code)
|
|
}
|
|
body := rec.Body.String()
|
|
if !strings.Contains(body, "inst.ks=http://bootapi.example.net/ks/web01") {
|
|
t.Errorf("ipxe body missing inst.ks:\n%s", body)
|
|
}
|
|
}
|
|
|
|
func TestIPXEUnknownMACServesFallback200(t *testing.T) {
|
|
h := newTestServer(t, &fakeResolver{}, "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 {
|
|
t.Fatalf("status = %d, want 200 with fallback", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), "sanboot") {
|
|
t.Errorf("expected local-boot fallback, got:\n%s", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestIPXEUnknownMACShellFallback(t *testing.T) {
|
|
h := newTestServer(t, &fakeResolver{}, "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())
|
|
}
|
|
}
|
|
|
|
func TestIPXEQueryAlias(t *testing.T) {
|
|
res := &fakeResolver{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=") {
|
|
t.Fatalf("query-style ipxe failed: %d\n%s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestKickstartByMAC(t *testing.T) {
|
|
res := &fakeResolver{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 {
|
|
t.Fatalf("status = %d", rec.Code)
|
|
}
|
|
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/plain") {
|
|
t.Errorf("content-type = %q", ct)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), "rootpw --iscrypted") {
|
|
t.Errorf("kickstart body missing rootpw:\n%s", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestKickstartByHostname(t *testing.T) {
|
|
res := &fakeResolver{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 {
|
|
t.Fatalf("status = %d\n%s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestKickstartUnknownIs404(t *testing.T) {
|
|
h := newTestServer(t, &fakeResolver{}, "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)
|
|
}
|
|
}
|
|
|
|
func TestHealthAndReady(t *testing.T) {
|
|
h := newTestServer(t, &fakeResolver{}, "local").Router()
|
|
if rec := do(t, h, "/healthz"); rec.Code != http.StatusOK {
|
|
t.Errorf("healthz = %d", rec.Code)
|
|
}
|
|
if rec := do(t, h, "/readyz"); rec.Code != http.StatusOK {
|
|
t.Errorf("readyz = %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestMetricsEndpoint(t *testing.T) {
|
|
res := &fakeResolver{byMAC: map[string]*model.Host{"aa:bb:cc:00:11:22": testHost()}}
|
|
srv := newTestServer(t, res, "local")
|
|
h := srv.Router()
|
|
|
|
do(t, h, "/ipxe/aa:bb:cc:00:11:22") // ok render + netbox ok
|
|
do(t, h, "/ipxe/de:ad:be:ef:00:00") // notfound + fallback
|
|
do(t, h, "/ks/aa:bb:cc:00:11:22") // kickstart render
|
|
|
|
rec := do(t, h, "/metrics")
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("metrics status = %d", rec.Code)
|
|
}
|
|
body := rec.Body.String()
|
|
for _, want := range []string{
|
|
`bootapi_render_total{kind="ipxe",result="ok"} 1`,
|
|
`bootapi_render_total{kind="kickstart",result="ok"} 1`,
|
|
`bootapi_netbox_lookups_total{field="mac",result="notfound"} 1`,
|
|
"bootapi_http_requests_total",
|
|
} {
|
|
if !strings.Contains(body, want) {
|
|
t.Errorf("metrics missing %q", want)
|
|
}
|
|
}
|
|
}
|
|
|
|
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"}
|
|
for _, s := range yes {
|
|
if !looksLikeMAC(s) {
|
|
t.Errorf("looksLikeMAC(%q) = false, want true", s)
|
|
}
|
|
}
|
|
for _, s := range no {
|
|
if looksLikeMAC(s) {
|
|
t.Errorf("looksLikeMAC(%q) = true, want false", s)
|
|
}
|
|
}
|
|
}
|