Address PR review: PXE gate + callback, git-sync templates, distro catalog, k8s targets, http+https
Implements the six review comments on PR #1: - Per-host PXE-enable gate: read NetBox pxe_enabled custom field; a known host with it false gets the safe local-boot script (Cobbler netboot_enabled). Add a token-guarded POST /provisioned/{ident} callback that clears pxe_enabled in NetBox, plus a %post snippet in the default kickstarts that calls it. - Templates from a git repo: bootapi clones a templates repo and re-pulls every BOOTAPI_TEMPLATE_GIT_INTERVAL (default 3m), atomically swapping the template set (last-good kept on parse failure; embedded defaults are the startup fallback). Metrics for syncs/failures/generation. - Distro catalog (catalog/*.yaml): NetBox host -> boot images/kickstart, so adding an OS is a YAML + template change. Ships almalinux + fedora entries (artifactapi remotes); debian/talos path documented. - Boot images from the artifactapi almalinux/fedora remotes via the catalog. - Bind resolvers, puppet server/CA and PUPPETCA_URL env file now target the k8s services (198.18.200.7; puppet(ca).k8s.syd1.au.unkin.net). - Boot path served over plain HTTP (installers lack CA trust) with an optional parallel HTTPS listener; docs say do not 301 the boot endpoints. New packages: internal/catalog, internal/gitsync. NetBox client gains a pxe_enabled write (token needs that scope - noted in docs). `bootapi validate` subcommand validates a template/catalog set for the templates-repo CI. 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:
@@ -1,6 +1,7 @@
|
||||
package render
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -10,23 +11,32 @@ import (
|
||||
"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",
|
||||
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",
|
||||
BootBaseURL: "http://mirror.example.net/almalinux/9",
|
||||
CallbackBaseURL: "http://bootapi.example.net",
|
||||
ArtifactBase: artifactBase,
|
||||
ProvisionToken: "prov-secret",
|
||||
DefaultDomain: "main.unkin.net",
|
||||
DefaultNS: []string{"10.0.0.1"},
|
||||
DefaultNS: []string{"198.18.200.7"},
|
||||
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 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 {
|
||||
@@ -42,13 +52,13 @@ func almaHost() *model.Host {
|
||||
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
|
||||
{Name: "eth1", MAC: "aa:bb:cc:00:11:33"}, // no IP -> skipped in network stanza
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderKickstartAlma(t *testing.T) {
|
||||
e := testEngine(t, "")
|
||||
e := testEngine(t, nil)
|
||||
out, name, err := e.RenderKickstart(almaHost())
|
||||
if err != nil {
|
||||
t.Fatalf("RenderKickstart: %v", err)
|
||||
@@ -59,29 +69,33 @@ func TestRenderKickstartAlma(t *testing.T) {
|
||||
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, "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")
|
||||
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"})
|
||||
cfg := testCfg()
|
||||
cfg.RootPasswordHash = ""
|
||||
set, err := BuildSet(templates.FS, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, _, err := e.RenderKickstart(almaHost())
|
||||
out, _, err := NewEngine(cfg, set).RenderKickstart(almaHost())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -93,14 +107,14 @@ func TestRenderKickstartLockedRoot(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSelectKickstartPrecedence(t *testing.T) {
|
||||
e := testEngine(t, "")
|
||||
e := testEngine(t, nil)
|
||||
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{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 {
|
||||
@@ -111,21 +125,34 @@ func TestSelectKickstartPrecedence(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderIPXE(t *testing.T) {
|
||||
e := testEngine(t, "")
|
||||
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 http://mirror.example.net/almalinux/9/images/pxeboot/vmlinuz")
|
||||
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=http://bootapi.example.net/ks/web01")
|
||||
mustContain(t, s, "initrd http://mirror.example.net/almalinux/9/images/pxeboot/initrd.img")
|
||||
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, "")
|
||||
e := testEngine(t, nil)
|
||||
local, err := e.RenderFallback("local")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -143,7 +170,7 @@ func TestOverrideDirWins(t *testing.T) {
|
||||
if err := os.WriteFile(filepath.Join(dir, "almalinux9.ks.tmpl"), []byte("OVERRIDDEN {{ .Hostname }}\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e := testEngine(t, dir)
|
||||
e := testEngine(t, os.DirFS(dir))
|
||||
out, _, err := e.RenderKickstart(almaHost())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
Reference in New Issue
Block a user