274c480b09
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
162 lines
5.1 KiB
Go
162 lines
5.1 KiB
Go
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)
|
|
}
|
|
}
|