Files
unkinben 8f356346eb
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
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
2026-07-28 22:34:44 +10:00

274 lines
8.2 KiB
Go

package netbox
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
)
// patchedDevice records whether the fake NetBox saw a PATCH on device 12.
var patchedDevice atomic.Bool
// 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 (GET) + pxe_enabled write (PATCH).
mux.HandleFunc("/api/dcim/devices/", func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPatch && strings.HasSuffix(r.URL.Path, "/12/") {
patchedDevice.Store(true)
writeJSON(w, `{"id":12,"name":"web01"}`)
return
}
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,"pxe_enabled":true}}`)
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 h.DeviceID != 12 {
t.Errorf("deviceID = %d, want 12", h.DeviceID)
}
if h.PXEEnabled == nil || !*h.PXEEnabled || !h.ShouldPXEInstall() {
t.Errorf("pxe_enabled = %v, want true", h.PXEEnabled)
}
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 TestSetPXEEnabled(t *testing.T) {
srv := fakeNetBox(t)
defer srv.Close()
patchedDevice.Store(false)
c := newTestClient(t, srv.URL)
if err := c.SetPXEEnabled(context.Background(), 12, false); err != nil {
t.Fatalf("SetPXEEnabled: %v", err)
}
if !patchedDevice.Load() {
t.Error("expected a PATCH to device 12, got none")
}
}
func TestCfBool(t *testing.T) {
tr := true
cases := []struct {
cf map[string]any
want *bool
}{
{map[string]any{"pxe_enabled": true}, &tr},
{map[string]any{"pxe_enabled": "false"}, boolp(false)},
{map[string]any{"pxe_enabled": nil}, nil},
{map[string]any{}, nil},
}
for _, c := range cases {
got := cfBool(c.cf, "pxe_enabled")
switch {
case got == nil && c.want == nil:
case got != nil && c.want != nil && *got == *c.want:
default:
t.Errorf("cfBool(%v) = %v, want %v", c.cf, got, c.want)
}
}
}
func boolp(b bool) *bool { return &b }
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)
}
}
}