Files
bootapi/internal/render/render_test.go
T
unkinben 4ee6c1f4cd
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
Add inst.ks.sendmac to the iPXE boot script
Cobbler's per-MAC gPXE script passed inst.ks.sendmac, making anaconda send
its interface MACs as X-RHN-Provisioning-MAC-N headers when fetching the
kickstart. bootapi's iPXE script omitted it, so the boot script was not a
full drop-in for Cobbler's gpxe output.

- Emit inst.ks.sendmac immediately before inst.ks= in the boot iPXE template,
  matching Cobbler ordering.
- Assert the arg is present in the rendered catalog iPXE test.

Claude-Session: https://claude.ai/code/session_01JUoARVdmhxKQHyyyp1pxeT
2026-08-06 22:19:04 +10:00

189 lines
6.1 KiB
Go

package render
import (
"io/fs"
"os"
"path/filepath"
"strings"
"testing"
"git.unkin.net/unkin/bootapi/internal/model"
"git.unkin.net/unkin/bootapi/templates"
)
const artifactBase = "https://artifactapi.example.net/api/v1/remote"
func testCfg() RenderConfig {
return RenderConfig{
PuppetServer: "puppet.k8s.syd1.au.unkin.net",
PuppetCAServer: "puppetca.k8s.syd1.au.unkin.net",
PuppetCAURL: "puppetca.k8s.syd1.au.unkin.net",
BaseURL: "http://bootapi.example.net",
CallbackBaseURL: "http://bootapi.example.net",
ArtifactBase: artifactBase,
ProvisionToken: "prov-secret",
DefaultDomain: "main.unkin.net",
DefaultNS: []string{"198.18.200.7"},
RootPasswordHash: "$6$rounds=4096$abc$deadbeef",
SSHAuthorizedKeys: []string{"ssh-ed25519 AAAAC3xxx root@ops"},
DefaultTemplate: "almalinux9",
}
}
func testEngine(t *testing.T, override fs.FS) *Engine {
t.Helper()
set, err := BuildSet(templates.FS, override)
if err != nil {
t.Fatalf("BuildSet: %v", err)
}
return NewEngine(testCfg(), set)
}
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 -> skipped in network stanza
},
}
}
func TestRenderKickstartAlma(t *testing.T) {
e := testEngine(t, nil)
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")
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=198.18.200.7 --hostname=web01.syd1.au.unkin.net")
// install source comes from the catalog mirror (artifactapi almalinux remote).
mustContain(t, ks, "url --url="+artifactBase+"/almalinux/9/BaseOS/x86_64/os/")
mustContain(t, ks, "repo --name=AppStream --baseurl="+artifactBase+"/almalinux/9/AppStream/x86_64/os/")
// puppet points at the k8s server/CA.
mustContain(t, ks, `config set --section main server "puppet.k8s.syd1.au.unkin.net"`)
mustContain(t, ks, `config set --section main ca_server "puppetca.k8s.syd1.au.unkin.net"`)
// puppet-initial env file.
mustContain(t, ks, "PUPPETCA_URL=puppetca.k8s.syd1.au.unkin.net")
// end-of-install callback with the provision token.
mustContain(t, ks, `-H "Authorization: Bearer prov-secret"`)
mustContain(t, ks, `"http://bootapi.example.net/provisioned/web01"`)
mustContain(t, ks, "ssh-ed25519 AAAAC3xxx root@ops")
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) {
cfg := testCfg()
cfg.RootPasswordHash = ""
set, err := BuildSet(templates.FS, nil)
if err != nil {
t.Fatal(err)
}
out, _, err := NewEngine(cfg, set).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, nil)
cases := []struct {
host *model.Host
want string
}{
{&model.Host{TemplateOverride: "fedora", Platform: "almalinux9"}, "fedora"}, // override wins (catalog name)
{&model.Host{Platform: "almalinux9", OSFamily: "almalinux"}, "almalinux9"}, // platform
{&model.Host{Platform: "fedora42", OSFamily: "fedora"}, "fedora"}, // family fallback (catalog)
{&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 TestRenderIPXECatalog(t *testing.T) {
e := testEngine(t, nil)
out, err := e.RenderIPXE(almaHost())
if err != nil {
t.Fatalf("RenderIPXE: %v", err)
}
s := string(out)
mustContain(t, s, "#!ipxe")
mustContain(t, s, "kernel "+artifactBase+"/almalinux/9/BaseOS/x86_64/os/images/pxeboot/vmlinuz")
mustContain(t, s, "initrd "+artifactBase+"/almalinux/9/BaseOS/x86_64/os/images/pxeboot/initrd.img")
mustContain(t, s, "inst.repo="+artifactBase+"/almalinux/9/BaseOS/x86_64/os")
mustContain(t, s, "inst.ks.sendmac inst.ks=http://bootapi.example.net/ks/web01")
mustContain(t, s, "inst.text") // catalog kernel arg
mustContain(t, s, "net.ifnames=0")
}
func TestRenderIPXEFedoraCatalog(t *testing.T) {
e := testEngine(t, nil)
h := &model.Host{Hostname: "f1", Platform: "fedora41", OSFamily: "fedora", OSVersion: "41", Arch: "x86_64"}
out, err := e.RenderIPXE(h)
if err != nil {
t.Fatal(err)
}
mustContain(t, string(out), "kernel "+artifactBase+"/fedora/releases/41/Everything/x86_64/os/images/pxeboot/vmlinuz")
}
func TestRenderFallback(t *testing.T) {
e := testEngine(t, nil)
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, os.DirFS(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)
}
}