8f356346eb
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
274 lines
8.2 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|