Address PR review: PXE gate + callback, git-sync templates, distro catalog, k8s targets, http+https
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful

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:
2026-07-28 22:34:44 +10:00
parent 274c480b09
commit 8f356346eb
32 changed files with 2119 additions and 357 deletions
+117 -24
View File
@@ -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"}