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) } }