4ee6c1f4cd
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
189 lines
6.1 KiB
Go
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)
|
|
}
|
|
}
|