Initial bootapi: NetBox-driven PXE/kickstart boot service
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful

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
This commit is contained in:
2026-07-28 17:20:06 +10:00
parent 98e69d2fcb
commit 274c480b09
37 changed files with 3290 additions and 1 deletions
+268
View File
@@ -0,0 +1,268 @@
// Package render turns a resolved model.Host into a kickstart file or an iPXE
// boot script using Go text/template. Templates come from an embedded default
// set (ported from Cobbler's kickstarts) optionally layered with an override
// directory (a Kubernetes ConfigMap mount in production).
package render
import (
"bytes"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
"text/template"
"git.unkin.net/unkin/bootapi/internal/model"
)
// Data is the exact, documented value passed to every kickstart/iPXE template.
// It is intentionally flat: template authors get one clear namespace. See
// docs/data-model.md.
type Data struct {
// --- identity (from NetBox) ---
Hostname string
Domain string
FQDN string
Platform string // NetBox platform slug, e.g. "almalinux9"
OSFamily string // "almalinux", "fedora", ...
OSVersion string // "9", "42", ...
Arch string // "x86_64"
Role string // NetBox device role slug
// --- network (from NetBox) ---
Interfaces []model.Interface
PrimaryInterface *model.Interface
PrimaryIP string
Nameservers []string // resolved: host value, else site default
// --- secrets (from Vault/env at render time, never NetBox) ---
RootPasswordHash string
SSHAuthorizedKeys []string
// --- infra pointers (render-time config) ---
PuppetServer string
PuppetCAServer string
BaseURL string // bootapi's own base URL
BootBaseURL string // OS install-tree base URL
KickstartURL string // absolute URL a booting host fetches its KS from
// --- escape hatch: every NetBox custom field, verbatim ---
Custom map[string]any
}
// RenderConfig carries the render-time infra values merged into each Data.
type RenderConfig struct {
PuppetServer string
PuppetCAServer string
BaseURL string
BootBaseURL string
DefaultDomain string
DefaultNS []string
RootPasswordHash string
SSHAuthorizedKeys []string
DefaultTemplate string
}
// Engine holds parsed templates and render-time defaults.
type Engine struct {
ks *template.Template // kickstart templates, named "<key>"
ipxe *template.Template // ipxe templates, named "<key>"
cfg RenderConfig
ksSet map[string]bool // which kickstart template names exist
}
const (
ksExt = ".ks.tmpl"
ipxeExt = ".ipxe.tmpl"
)
// NewEngine parses the embedded defaults, then overlays overrideDir when
// non-empty (files there win over embedded ones of the same name).
func NewEngine(embedded fs.FS, overrideDir string, cfg RenderConfig) (*Engine, error) {
funcs := funcMap()
ks := template.New("kickstart").Funcs(funcs)
ipxe := template.New("ipxe").Funcs(funcs)
set := map[string]bool{}
if err := parseTree(ks, ipxe, set, embedded, ".", true); err != nil {
return nil, fmt.Errorf("parse embedded templates: %w", err)
}
if overrideDir != "" {
if err := parseTree(ks, ipxe, set, os.DirFS(overrideDir), ".", false); err != nil {
return nil, fmt.Errorf("parse override templates in %q: %w", overrideDir, err)
}
}
return &Engine{ks: ks, ipxe: ipxe, cfg: cfg, ksSet: set}, nil
}
// parseTree walks fsys under root, registering *.ks.tmpl into ks and
// *.ipxe.tmpl into ipxe under their base name (extension stripped).
func parseTree(ks, ipxe *template.Template, set map[string]bool, fsys fs.FS, root string, mustExist bool) error {
walked := false
err := fs.WalkDir(fsys, root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
walked = true
if d.IsDir() {
return nil
}
b, err := fs.ReadFile(fsys, path)
if err != nil {
return err
}
base := filepath.Base(path)
switch {
case strings.HasSuffix(base, ksExt):
name := strings.TrimSuffix(base, ksExt)
if _, err := ks.New(name).Parse(string(b)); err != nil {
return fmt.Errorf("%s: %w", path, err)
}
set[name] = true
case strings.HasSuffix(base, ipxeExt):
name := strings.TrimSuffix(base, ipxeExt)
if _, err := ipxe.New(name).Parse(string(b)); err != nil {
return fmt.Errorf("%s: %w", path, err)
}
}
return nil
})
if err != nil {
return err
}
if mustExist && !walked {
return fmt.Errorf("no templates found under %q", root)
}
return nil
}
// SelectKickstart returns the template name chosen for host, following the
// documented precedence: custom-field override → platform slug → OS family →
// configured default. It reports whether a concrete template was found.
func (e *Engine) SelectKickstart(h *model.Host) (string, bool) {
for _, cand := range []string{h.TemplateOverride, h.Platform, h.OSFamily, e.cfg.DefaultTemplate} {
if cand != "" && e.ksSet[cand] {
return cand, true
}
}
return e.cfg.DefaultTemplate, e.ksSet[e.cfg.DefaultTemplate]
}
// dataFor builds the flat Data view for a host, merging render-time config.
func (e *Engine) dataFor(h *model.Host) Data {
ns := h.Nameservers
if len(ns) == 0 {
ns = e.cfg.DefaultNS
}
domain := h.Domain
if domain == "" {
domain = e.cfg.DefaultDomain
}
fqdn := h.Hostname
if domain != "" {
fqdn = h.Hostname + "." + domain
}
root := h.RootPasswordHash
if root == "" {
root = e.cfg.RootPasswordHash
}
keys := h.SSHAuthorizedKeys
if len(keys) == 0 {
keys = e.cfg.SSHAuthorizedKeys
}
ksURL := ""
if e.cfg.BaseURL != "" {
ksURL = strings.TrimRight(e.cfg.BaseURL, "/") + "/ks/" + h.Hostname
}
return Data{
Hostname: h.Hostname,
Domain: domain,
FQDN: fqdn,
Platform: h.Platform,
OSFamily: h.OSFamily,
OSVersion: h.OSVersion,
Arch: h.Arch,
Role: h.Role,
Interfaces: h.Interfaces,
PrimaryInterface: h.PrimaryInterface(),
PrimaryIP: h.PrimaryIP,
Nameservers: ns,
RootPasswordHash: root,
SSHAuthorizedKeys: keys,
PuppetServer: e.cfg.PuppetServer,
PuppetCAServer: e.cfg.PuppetCAServer,
BaseURL: e.cfg.BaseURL,
BootBaseURL: e.cfg.BootBaseURL,
KickstartURL: ksURL,
Custom: h.Custom,
}
}
// RenderKickstart renders the selected kickstart template for host. It returns
// the rendered bytes and the template name used.
func (e *Engine) RenderKickstart(h *model.Host) ([]byte, string, error) {
name, ok := e.SelectKickstart(h)
if !ok {
return nil, name, fmt.Errorf("no kickstart template for host %q (tried override/platform/family/default %q)", h.Hostname, name)
}
var buf bytes.Buffer
if err := e.ks.ExecuteTemplate(&buf, name, e.dataFor(h)); err != nil {
return nil, name, fmt.Errorf("render kickstart %q: %w", name, err)
}
return buf.Bytes(), name, nil
}
// IPXEData is the value passed to iPXE templates.
type IPXEData struct {
Data
// KernelURL/InitrdURL point at the OS install tree; empty when BootBaseURL
// is unset, in which case the template should fall back to a static path.
KernelURL string
InitrdURL string
}
// RenderIPXE renders the "boot" iPXE script that chains kernel+initrd with
// inst.ks= pointing back at bootapi.
func (e *Engine) RenderIPXE(h *model.Host) ([]byte, error) {
d := e.dataFor(h)
id := IPXEData{Data: d}
if d.BootBaseURL != "" {
tree := strings.TrimRight(d.BootBaseURL, "/")
id.KernelURL = tree + "/images/pxeboot/vmlinuz"
id.InitrdURL = tree + "/images/pxeboot/initrd.img"
}
return e.execIPXE("boot", id)
}
// RenderFallback renders a fallback iPXE script ("local" or "shell") for an
// unknown MAC. See docs/endpoints.md for the safety rationale.
func (e *Engine) RenderFallback(kind string) ([]byte, error) {
name := "fallback-" + kind
return e.execIPXE(name, IPXEData{})
}
func (e *Engine) execIPXE(name string, d IPXEData) ([]byte, error) {
if e.ipxe.Lookup(name) == nil {
return nil, fmt.Errorf("no iPXE template %q", name)
}
var buf bytes.Buffer
if err := e.ipxe.ExecuteTemplate(&buf, name, d); err != nil {
return nil, fmt.Errorf("render ipxe %q: %w", name, err)
}
return buf.Bytes(), nil
}
func funcMap() template.FuncMap {
return template.FuncMap{
"join": strings.Join,
"upper": strings.ToUpper,
"lower": strings.ToLower,
"default": func(def, v string) string { // {{ default "x" .Maybe }}
if v == "" {
return def
}
return v
},
}
}
+161
View File
@@ -0,0 +1,161 @@
package render
import (
"os"
"path/filepath"
"strings"
"testing"
"git.unkin.net/unkin/bootapi/internal/model"
"git.unkin.net/unkin/bootapi/templates"
)
func testEngine(t *testing.T, override string) *Engine {
t.Helper()
e, err := NewEngine(templates.FS, override, 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",
DefaultNS: []string{"10.0.0.1"},
RootPasswordHash: "$6$rounds=4096$abc$deadbeef",
SSHAuthorizedKeys: []string{"ssh-ed25519 AAAAC3xxx root@ops"},
DefaultTemplate: "almalinux9",
})
if err != nil {
t.Fatalf("NewEngine: %v", err)
}
return e
}
func almaHost() *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",
Role: "kubernetes-worker",
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", VLAN: 100, Primary: true},
{Name: "eth1", MAC: "aa:bb:cc:00:11:33"}, // no IP -> must be skipped in network stanza
},
}
}
func TestRenderKickstartAlma(t *testing.T) {
e := testEngine(t, "")
out, name, err := e.RenderKickstart(almaHost())
if err != nil {
t.Fatalf("RenderKickstart: %v", err)
}
if name != "almalinux9" {
t.Errorf("selected template = %q, want almalinux9", name)
}
ks := string(out)
mustContain(t, ks, "rootpw --iscrypted $6$rounds=4096$abc$deadbeef")
// The primary interface must produce a full static network line incl hostname.
mustContain(t, ks, "network --bootproto=static --device=aa:bb:cc:00:11:22 --ip=10.0.1.20 --netmask=255.255.255.0 --gateway=10.0.1.254 --nameserver=10.0.0.1 --hostname=web01.syd1.au.unkin.net")
mustContain(t, ks, `"$PUPPET_BIN" config set --section main server "puppet.query.consul"`)
mustContain(t, ks, `config set --section main ca_server "puppetca.query.consul"`)
mustContain(t, ks, "url --url=http://mirror.example.net/almalinux/9/BaseOS/x86_64/os/")
mustContain(t, ks, "ssh-ed25519 AAAAC3xxx root@ops")
mustContain(t, ks, "dnf install -y puppet-agent")
mustContain(t, ks, "%packages")
mustContain(t, ks, "%post")
// eth1 has no IP, so it must NOT appear as a network device line.
if strings.Contains(ks, "--device=aa:bb:cc:00:11:33") {
t.Error("interface without an IP leaked into a network stanza")
}
}
func TestRenderKickstartLockedRoot(t *testing.T) {
// With no root hash configured, the account must be locked, not blank.
e, err := NewEngine(templates.FS, "", RenderConfig{DefaultTemplate: "almalinux9", BootBaseURL: "http://m/9"})
if err != nil {
t.Fatal(err)
}
out, _, err := e.RenderKickstart(almaHost())
if err != nil {
t.Fatal(err)
}
ks := string(out)
mustContain(t, ks, "rootpw --lock")
if strings.Contains(ks, "--iscrypted") {
t.Error("expected locked root, got an --iscrypted line")
}
}
func TestSelectKickstartPrecedence(t *testing.T) {
e := testEngine(t, "")
cases := []struct {
host *model.Host
want string
}{
{&model.Host{TemplateOverride: "fedora", Platform: "almalinux9"}, "fedora"}, // override wins
{&model.Host{Platform: "almalinux9"}, "almalinux9"}, // platform
{&model.Host{Platform: "fedora42", OSFamily: "fedora"}, "fedora"}, // family fallback
{&model.Host{Platform: "unknownos"}, "almalinux9"}, // default
}
for _, c := range cases {
got, ok := e.SelectKickstart(c.host)
if !ok || got != c.want {
t.Errorf("SelectKickstart(%+v) = (%q,%v), want %q", c.host, got, ok, c.want)
}
}
}
func TestRenderIPXE(t *testing.T) {
e := testEngine(t, "")
out, err := e.RenderIPXE(almaHost())
if err != nil {
t.Fatalf("RenderIPXE: %v", err)
}
s := string(out)
mustContain(t, s, "#!ipxe")
mustContain(t, s, "kernel http://mirror.example.net/almalinux/9/images/pxeboot/vmlinuz")
mustContain(t, s, "inst.ks=http://bootapi.example.net/ks/web01")
mustContain(t, s, "initrd http://mirror.example.net/almalinux/9/images/pxeboot/initrd.img")
}
func TestRenderFallback(t *testing.T) {
e := testEngine(t, "")
local, err := e.RenderFallback("local")
if err != nil {
t.Fatal(err)
}
mustContain(t, string(local), "sanboot")
shell, err := e.RenderFallback("shell")
if err != nil {
t.Fatal(err)
}
mustContain(t, string(shell), "shell")
}
func TestOverrideDirWins(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "almalinux9.ks.tmpl"), []byte("OVERRIDDEN {{ .Hostname }}\n"), 0o600); err != nil {
t.Fatal(err)
}
e := testEngine(t, dir)
out, _, err := e.RenderKickstart(almaHost())
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(string(out), "OVERRIDDEN web01") {
t.Errorf("override not applied: %q", string(out))
}
}
func mustContain(t *testing.T, haystack, needle string) {
t.Helper()
if !strings.Contains(haystack, needle) {
t.Errorf("output missing %q\n--- output ---\n%s", needle, haystack)
}
}