Files
bootapi/internal/netbox/netbox_test.go
T
unkinben 274c480b09
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
Initial bootapi: NetBox-driven PXE/kickstart boot service
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
2026-07-28 20:57:11 +10:00

221 lines
6.8 KiB
Go

package netbox
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// fakeNetBox serves canned NetBox v4.x JSON for the endpoints bootapi calls.
// The payloads are trimmed but structurally faithful to real API responses.
func fakeNetBox(t *testing.T) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
// Interfaces filtered by MAC -> the interface (with nested device brief).
mux.HandleFunc("/api/dcim/interfaces/", func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("Authorization"); got != "Token testtoken" {
http.Error(w, `{"detail":"auth"}`, http.StatusForbidden)
return
}
q := r.URL.Query()
switch {
case q.Get("mac_address") == "aa:bb:cc:00:11:22":
writeJSON(w, `{"count":1,"results":[
{"id":40,"name":"eth0","mac_address":"AA:BB:CC:00:11:22",
"untagged_vlan":{"vid":100,"name":"prod"},
"device":{"id":12,"name":"web01","slug":""}}]}`)
case q.Get("device_id") == "12":
// Full interface list for the device (two NICs).
writeJSON(w, `{"count":2,"results":[
{"id":40,"name":"eth0","mac_address":"AA:BB:CC:00:11:22","untagged_vlan":{"vid":100,"name":"prod"}},
{"id":41,"name":"eth1","mac_address":"AA:BB:CC:00:11:33"}]}`)
default:
writeJSON(w, `{"count":0,"results":[]}`)
}
})
// Device detail.
mux.HandleFunc("/api/dcim/devices/", func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/12/") {
writeJSON(w, `{
"id":12,"name":"web01",
"platform":{"id":3,"name":"AlmaLinux 9","slug":"almalinux9"},
"role":{"id":2,"name":"K8s Worker","slug":"kubernetes-worker"},
"site":{"slug":"syd1"},
"primary_ip":{"address":"10.0.1.20/24"},
"custom_fields":{"domain":"syd1.au.unkin.net","gateway":"10.0.1.254","nameservers":"10.0.0.1,10.0.0.2","provision_template":null}}`)
return
}
// name= query (HostByName)
if r.URL.Query().Get("name") == "web01" {
writeJSON(w, `{"count":1,"results":[{"id":12,"name":"web01"}]}`)
return
}
writeJSON(w, `{"count":0,"results":[]}`)
})
// IP addresses for the device: eth0 has the primary, eth1 a second addr.
mux.HandleFunc("/api/ipam/ip-addresses/", func(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, `{"count":2,"results":[
{"address":"10.0.1.20/24","assigned_object_id":40,"custom_fields":{"gateway":null}},
{"address":"10.9.9.5/24","assigned_object_id":41,"custom_fields":{"gateway":"10.9.9.1"}}]}`)
})
return httptest.NewServer(mux)
}
func writeJSON(w http.ResponseWriter, body string) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(body))
}
func newTestClient(t *testing.T, base string) *Client {
t.Helper()
return New(Options{BaseURL: base, Token: "testtoken"})
}
func TestHostByMAC(t *testing.T) {
srv := fakeNetBox(t)
defer srv.Close()
c := newTestClient(t, srv.URL)
h, err := c.HostByMAC(context.Background(), "AA-BB-CC-00-11-22")
if err != nil {
t.Fatalf("HostByMAC: %v", err)
}
if h.Hostname != "web01" {
t.Errorf("hostname = %q, want web01", h.Hostname)
}
if h.FQDN != "web01.syd1.au.unkin.net" {
t.Errorf("fqdn = %q", h.FQDN)
}
if h.Platform != "almalinux9" || h.OSFamily != "almalinux" || h.OSVersion != "9" {
t.Errorf("platform=%q family=%q version=%q", h.Platform, h.OSFamily, h.OSVersion)
}
if h.Role != "kubernetes-worker" {
t.Errorf("role = %q", h.Role)
}
if h.PrimaryIP != "10.0.1.20" {
t.Errorf("primaryIP = %q", h.PrimaryIP)
}
if len(h.Nameservers) != 2 || h.Nameservers[0] != "10.0.0.1" {
t.Errorf("nameservers = %v", h.Nameservers)
}
if len(h.Interfaces) != 2 {
t.Fatalf("interfaces = %d, want 2", len(h.Interfaces))
}
// Primary interface (eth0, carrying the primary IP) must sort first.
pi := h.PrimaryInterface()
if pi == nil || pi.Name != "eth0" || !pi.Primary {
t.Fatalf("primary interface = %+v", pi)
}
if pi.MAC != "aa:bb:cc:00:11:22" {
t.Errorf("primary MAC = %q (want normalized lower-colon)", pi.MAC)
}
if pi.IP != "10.0.1.20" || pi.Netmask != "255.255.255.0" || pi.PrefixLen != 24 {
t.Errorf("primary iface addr = %+v", pi)
}
// eth0 gateway comes from the device custom field (its IP had none).
if pi.Gateway != "10.0.1.254" {
t.Errorf("primary gateway = %q, want device CF 10.0.1.254", pi.Gateway)
}
if pi.VLAN != 100 {
t.Errorf("primary vlan = %d", pi.VLAN)
}
// eth1's IP custom field gateway wins over the device default.
for i := range h.Interfaces {
if h.Interfaces[i].Name == "eth1" {
if h.Interfaces[i].Gateway != "10.9.9.1" {
t.Errorf("eth1 gateway = %q, want per-IP CF 10.9.9.1", h.Interfaces[i].Gateway)
}
}
}
}
func TestHostByMACNotFound(t *testing.T) {
srv := fakeNetBox(t)
defer srv.Close()
c := newTestClient(t, srv.URL)
_, err := c.HostByMAC(context.Background(), "de:ad:be:ef:00:00")
if !errors.Is(err, ErrNotFound) {
t.Fatalf("err = %v, want ErrNotFound", err)
}
}
func TestHostByName(t *testing.T) {
srv := fakeNetBox(t)
defer srv.Close()
c := newTestClient(t, srv.URL)
// FQDN input should be reduced to the short name for the NetBox query.
h, err := c.HostByName(context.Background(), "web01.syd1.au.unkin.net")
if err != nil {
t.Fatalf("HostByName: %v", err)
}
if h.Hostname != "web01" || h.PrimaryIP != "10.0.1.20" {
t.Errorf("host = %+v", h)
}
}
func TestHostByNameNotFound(t *testing.T) {
srv := fakeNetBox(t)
defer srv.Close()
c := newTestClient(t, srv.URL)
if _, err := c.HostByName(context.Background(), "nope"); !errors.Is(err, ErrNotFound) {
t.Fatalf("err = %v, want ErrNotFound", err)
}
}
func TestAuthTokenRequired(t *testing.T) {
srv := fakeNetBox(t)
defer srv.Close()
// Client with the wrong token -> NetBox 403 -> surfaced as an error.
c := New(Options{BaseURL: srv.URL, Token: "wrong"})
if _, err := c.HostByMAC(context.Background(), "aa:bb:cc:00:11:22"); err == nil {
t.Fatal("expected an auth error, got nil")
}
}
func TestNormalizeMAC(t *testing.T) {
cases := map[string]string{
"AA:BB:CC:00:11:22": "aa:bb:cc:00:11:22",
"aa-bb-cc-00-11-22": "aa:bb:cc:00:11:22",
"aabb.cc00.1122": "aa:bb:cc:00:11:22",
"AABBCC001122": "aa:bb:cc:00:11:22",
}
for in, want := range cases {
if got := normalizeMAC(in); got != want {
t.Errorf("normalizeMAC(%q) = %q, want %q", in, got, want)
}
}
}
func TestSplitPlatform(t *testing.T) {
cases := []struct{ in, fam, ver string }{
{"almalinux9", "almalinux", "9"},
{"fedora42", "fedora", "42"},
{"rocky9.4", "rocky", "9"},
{"debian", "debian", ""},
}
for _, c := range cases {
f, v := splitPlatform(c.in)
if f != c.fam || v != c.ver {
t.Errorf("splitPlatform(%q) = (%q,%q), want (%q,%q)", c.in, f, v, c.fam, c.ver)
}
}
}
func TestNetmaskFor(t *testing.T) {
cases := map[int]string{24: "255.255.255.0", 16: "255.255.0.0", 25: "255.255.255.128", 0: ""}
for prefix, want := range cases {
if got := netmaskFor(prefix); got != want {
t.Errorf("netmaskFor(%d) = %q, want %q", prefix, got, want)
}
}
}