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:
+214
-57
@@ -1,18 +1,20 @@
|
||||
// Package render turns a resolved model.Host into a kickstart file or an iPXE
|
||||
// boot script using Go text/template. Templates come from an embedded default
|
||||
// set (ported from Cobbler's kickstarts) optionally layered with an override
|
||||
// directory (a Kubernetes ConfigMap mount in production).
|
||||
// set, optionally overlaid with an override source (a ConfigMap directory or a
|
||||
// git-synced templates repo). The active template Set is swappable at runtime so
|
||||
// the git-sync loop can atomically reload without dropping requests.
|
||||
package render
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"text/template"
|
||||
|
||||
"git.unkin.net/unkin/bootapi/internal/catalog"
|
||||
"git.unkin.net/unkin/bootapi/internal/model"
|
||||
)
|
||||
|
||||
@@ -43,9 +45,17 @@ type Data struct {
|
||||
// --- infra pointers (render-time config) ---
|
||||
PuppetServer string
|
||||
PuppetCAServer string
|
||||
BaseURL string // bootapi's own base URL
|
||||
BootBaseURL string // OS install-tree base URL
|
||||
PuppetCAURL string // written to the puppet-initial PUPPETCA_URL env file
|
||||
BaseURL string // bootapi's own (http) base URL
|
||||
BootBaseURL string // legacy OS install-tree base (empty when catalog-driven)
|
||||
KickstartURL string // absolute URL a booting host fetches its KS from
|
||||
CallbackURL string // absolute URL the %post posts to when install finishes
|
||||
ProvisionToken string // bearer token for the callback (embedded in the KS)
|
||||
|
||||
// --- distro catalog ---
|
||||
// DistroVars are the selected catalog entry's evaluated vars (e.g. mirror
|
||||
// base). Empty when no catalog entry matched.
|
||||
DistroVars map[string]string
|
||||
|
||||
// --- escape hatch: every NetBox custom field, verbatim ---
|
||||
Custom map[string]any
|
||||
@@ -55,8 +65,12 @@ type Data struct {
|
||||
type RenderConfig struct {
|
||||
PuppetServer string
|
||||
PuppetCAServer string
|
||||
PuppetCAURL string
|
||||
BaseURL string
|
||||
CallbackBaseURL string
|
||||
ArtifactBase string
|
||||
BootBaseURL string
|
||||
ProvisionToken string
|
||||
DefaultDomain string
|
||||
DefaultNS []string
|
||||
RootPasswordHash string
|
||||
@@ -64,50 +78,55 @@ type RenderConfig struct {
|
||||
DefaultTemplate string
|
||||
}
|
||||
|
||||
// Engine holds parsed templates and render-time defaults.
|
||||
type Engine struct {
|
||||
ks *template.Template // kickstart templates, named "<key>"
|
||||
ipxe *template.Template // ipxe templates, named "<key>"
|
||||
cfg RenderConfig
|
||||
ksSet map[string]bool // which kickstart template names exist
|
||||
}
|
||||
|
||||
const (
|
||||
ksExt = ".ks.tmpl"
|
||||
ipxeExt = ".ipxe.tmpl"
|
||||
)
|
||||
|
||||
// NewEngine parses the embedded defaults, then overlays overrideDir when
|
||||
// non-empty (files there win over embedded ones of the same name).
|
||||
func NewEngine(embedded fs.FS, overrideDir string, cfg RenderConfig) (*Engine, error) {
|
||||
// Set is an immutable, parsed collection of templates + the distro catalog.
|
||||
type Set struct {
|
||||
ks *template.Template
|
||||
ipxe *template.Template
|
||||
ksSet map[string]bool
|
||||
cat *catalog.Catalog
|
||||
}
|
||||
|
||||
// BuildSet parses the embedded default sources, then overlays override (a
|
||||
// directory or git working tree) when non-nil, with override files winning by
|
||||
// base name. It parses *.ks.tmpl, *.ipxe.tmpl and catalog/*.yaml.
|
||||
func BuildSet(embedded fs.FS, override fs.FS) (*Set, error) {
|
||||
funcs := funcMap()
|
||||
ks := template.New("kickstart").Funcs(funcs)
|
||||
ipxe := template.New("ipxe").Funcs(funcs)
|
||||
set := map[string]bool{}
|
||||
ksNames := map[string]bool{}
|
||||
catFiles := map[string][]byte{}
|
||||
|
||||
if err := parseTree(ks, ipxe, set, embedded, ".", true); err != nil {
|
||||
if err := walkSet(embedded, ks, ipxe, ksNames, catFiles, true); err != nil {
|
||||
return nil, fmt.Errorf("parse embedded templates: %w", err)
|
||||
}
|
||||
if overrideDir != "" {
|
||||
if err := parseTree(ks, ipxe, set, os.DirFS(overrideDir), ".", false); err != nil {
|
||||
return nil, fmt.Errorf("parse override templates in %q: %w", overrideDir, err)
|
||||
if override != nil {
|
||||
if err := walkSet(override, ks, ipxe, ksNames, catFiles, false); err != nil {
|
||||
return nil, fmt.Errorf("parse override templates: %w", err)
|
||||
}
|
||||
}
|
||||
return &Engine{ks: ks, ipxe: ipxe, cfg: cfg, ksSet: set}, nil
|
||||
cat, err := catalog.Parse(catFiles)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Set{ks: ks, ipxe: ipxe, ksSet: ksNames, cat: cat}, nil
|
||||
}
|
||||
|
||||
// parseTree walks fsys under root, registering *.ks.tmpl into ks and
|
||||
// *.ipxe.tmpl into ipxe under their base name (extension stripped).
|
||||
func parseTree(ks, ipxe *template.Template, set map[string]bool, fsys fs.FS, root string, mustExist bool) error {
|
||||
// walkSet walks fsys registering templates and collecting catalog YAML.
|
||||
func walkSet(fsys fs.FS, ks, ipxe *template.Template, ksNames map[string]bool, catFiles map[string][]byte, mustExist bool) error {
|
||||
walked := false
|
||||
err := fs.WalkDir(fsys, root, func(path string, d fs.DirEntry, err error) error {
|
||||
err := fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
walked = true
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
walked = true
|
||||
b, err := fs.ReadFile(fsys, path)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -119,12 +138,14 @@ func parseTree(ks, ipxe *template.Template, set map[string]bool, fsys fs.FS, roo
|
||||
if _, err := ks.New(name).Parse(string(b)); err != nil {
|
||||
return fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
set[name] = true
|
||||
ksNames[name] = true
|
||||
case strings.HasSuffix(base, ipxeExt):
|
||||
name := strings.TrimSuffix(base, ipxeExt)
|
||||
if _, err := ipxe.New(name).Parse(string(b)); err != nil {
|
||||
return fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
case (strings.HasSuffix(base, ".yaml") || strings.HasSuffix(base, ".yml")) && strings.Contains(path, "catalog"):
|
||||
catFiles[base] = b
|
||||
}
|
||||
return nil
|
||||
})
|
||||
@@ -132,25 +153,59 @@ func parseTree(ks, ipxe *template.Template, set map[string]bool, fsys fs.FS, roo
|
||||
return err
|
||||
}
|
||||
if mustExist && !walked {
|
||||
return fmt.Errorf("no templates found under %q", root)
|
||||
return fmt.Errorf("no templates found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SelectKickstart returns the template name chosen for host, following the
|
||||
// documented precedence: custom-field override → platform slug → OS family →
|
||||
// configured default. It reports whether a concrete template was found.
|
||||
// Engine holds render-time config and the current (swappable) template Set.
|
||||
type Engine struct {
|
||||
cfg RenderConfig
|
||||
cur atomic.Pointer[Set]
|
||||
}
|
||||
|
||||
// NewEngine builds an Engine over an initial Set.
|
||||
func NewEngine(cfg RenderConfig, initial *Set) *Engine {
|
||||
e := &Engine{cfg: cfg}
|
||||
e.cur.Store(initial)
|
||||
return e
|
||||
}
|
||||
|
||||
// Swap atomically replaces the active template Set (used by git-sync on reload).
|
||||
func (e *Engine) Swap(s *Set) { e.cur.Store(s) }
|
||||
|
||||
// Current returns the active Set.
|
||||
func (e *Engine) Current() *Set { return e.cur.Load() }
|
||||
|
||||
// resolve returns the catalog entry for host (evaluated against artifactBase),
|
||||
// or nil when no catalog entry matches.
|
||||
func (e *Engine) resolve(set *Set, h *model.Host) (*catalog.Resolved, error) {
|
||||
d, ok := set.cat.Select(h)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
return d.Resolve(h, e.cfg.ArtifactBase)
|
||||
}
|
||||
|
||||
// SelectKickstart returns the kickstart template name for host: the catalog
|
||||
// entry's kickstart if one matches, else the legacy precedence
|
||||
// (override → platform → family → default). Reports whether it exists.
|
||||
func (e *Engine) SelectKickstart(h *model.Host) (string, bool) {
|
||||
set := e.cur.Load()
|
||||
if d, ok := set.cat.Select(h); ok && set.ksSet[d.Kickstart] {
|
||||
return d.Kickstart, true
|
||||
}
|
||||
for _, cand := range []string{h.TemplateOverride, h.Platform, h.OSFamily, e.cfg.DefaultTemplate} {
|
||||
if cand != "" && e.ksSet[cand] {
|
||||
if cand != "" && set.ksSet[cand] {
|
||||
return cand, true
|
||||
}
|
||||
}
|
||||
return e.cfg.DefaultTemplate, e.ksSet[e.cfg.DefaultTemplate]
|
||||
return e.cfg.DefaultTemplate, set.ksSet[e.cfg.DefaultTemplate]
|
||||
}
|
||||
|
||||
// dataFor builds the flat Data view for a host, merging render-time config.
|
||||
func (e *Engine) dataFor(h *model.Host) Data {
|
||||
// dataFor builds the flat Data view for a host, merging render-time config and
|
||||
// the selected catalog entry's vars.
|
||||
func (e *Engine) dataFor(h *model.Host, vars map[string]string) Data {
|
||||
ns := h.Nameservers
|
||||
if len(ns) == 0 {
|
||||
ns = e.cfg.DefaultNS
|
||||
@@ -173,7 +228,11 @@ func (e *Engine) dataFor(h *model.Host) Data {
|
||||
}
|
||||
ksURL := ""
|
||||
if e.cfg.BaseURL != "" {
|
||||
ksURL = strings.TrimRight(e.cfg.BaseURL, "/") + "/ks/" + h.Hostname
|
||||
ksURL = e.cfg.BaseURL + "/ks/" + h.Hostname
|
||||
}
|
||||
cbURL := ""
|
||||
if e.cfg.CallbackBaseURL != "" {
|
||||
cbURL = e.cfg.CallbackBaseURL + "/provisioned/" + h.Hostname
|
||||
}
|
||||
return Data{
|
||||
Hostname: h.Hostname,
|
||||
@@ -192,9 +251,13 @@ func (e *Engine) dataFor(h *model.Host) Data {
|
||||
SSHAuthorizedKeys: keys,
|
||||
PuppetServer: e.cfg.PuppetServer,
|
||||
PuppetCAServer: e.cfg.PuppetCAServer,
|
||||
PuppetCAURL: e.cfg.PuppetCAURL,
|
||||
BaseURL: e.cfg.BaseURL,
|
||||
BootBaseURL: e.cfg.BootBaseURL,
|
||||
KickstartURL: ksURL,
|
||||
CallbackURL: cbURL,
|
||||
ProvisionToken: e.cfg.ProvisionToken,
|
||||
DistroVars: vars,
|
||||
Custom: h.Custom,
|
||||
}
|
||||
}
|
||||
@@ -202,12 +265,27 @@ func (e *Engine) dataFor(h *model.Host) Data {
|
||||
// RenderKickstart renders the selected kickstart template for host. It returns
|
||||
// the rendered bytes and the template name used.
|
||||
func (e *Engine) RenderKickstart(h *model.Host) ([]byte, string, error) {
|
||||
name, ok := e.SelectKickstart(h)
|
||||
if !ok {
|
||||
return nil, name, fmt.Errorf("no kickstart template for host %q (tried override/platform/family/default %q)", h.Hostname, name)
|
||||
set := e.cur.Load()
|
||||
resolved, err := e.resolve(set, h)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
var vars map[string]string
|
||||
name := ""
|
||||
if resolved != nil {
|
||||
vars = resolved.Vars
|
||||
if set.ksSet[resolved.Kickstart] {
|
||||
name = resolved.Kickstart
|
||||
}
|
||||
}
|
||||
if name == "" {
|
||||
var ok bool
|
||||
if name, ok = e.SelectKickstart(h); !ok {
|
||||
return nil, name, fmt.Errorf("no kickstart template for host %q (catalog + override/platform/family/default %q)", h.Hostname, name)
|
||||
}
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := e.ks.ExecuteTemplate(&buf, name, e.dataFor(h)); err != nil {
|
||||
if err := set.ks.ExecuteTemplate(&buf, name, e.dataFor(h, vars)); err != nil {
|
||||
return nil, name, fmt.Errorf("render kickstart %q: %w", name, err)
|
||||
}
|
||||
return buf.Bytes(), name, nil
|
||||
@@ -216,43 +294,122 @@ func (e *Engine) RenderKickstart(h *model.Host) ([]byte, string, error) {
|
||||
// IPXEData is the value passed to iPXE templates.
|
||||
type IPXEData struct {
|
||||
Data
|
||||
// KernelURL/InitrdURL point at the OS install tree; empty when BootBaseURL
|
||||
// is unset, in which case the template should fall back to a static path.
|
||||
KernelURL string
|
||||
InitrdURL string
|
||||
// KernelURL/InitrdURL point at the OS install tree (from the catalog, else
|
||||
// the legacy BootBaseURL). Empty when neither is configured, in which case
|
||||
// the template falls back to local boot.
|
||||
KernelURL string
|
||||
InitrdURL string
|
||||
KernelArgs []string
|
||||
// RepoURL is the OS install-tree root (KernelURL minus images/pxeboot/vmlinuz),
|
||||
// passed to anaconda as inst.repo=.
|
||||
RepoURL string
|
||||
}
|
||||
|
||||
// RenderIPXE renders the "boot" iPXE script that chains kernel+initrd with
|
||||
// inst.ks= pointing back at bootapi.
|
||||
func (e *Engine) RenderIPXE(h *model.Host) ([]byte, error) {
|
||||
d := e.dataFor(h)
|
||||
id := IPXEData{Data: d}
|
||||
if d.BootBaseURL != "" {
|
||||
tree := strings.TrimRight(d.BootBaseURL, "/")
|
||||
set := e.cur.Load()
|
||||
resolved, err := e.resolve(set, h)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var vars map[string]string
|
||||
if resolved != nil {
|
||||
vars = resolved.Vars
|
||||
}
|
||||
id := IPXEData{Data: e.dataFor(h, vars)}
|
||||
switch {
|
||||
case resolved != nil:
|
||||
id.KernelURL = resolved.KernelURL
|
||||
id.InitrdURL = resolved.InitrdURL
|
||||
id.KernelArgs = resolved.KernelArgs
|
||||
case e.cfg.BootBaseURL != "": // legacy fallback
|
||||
tree := strings.TrimRight(e.cfg.BootBaseURL, "/")
|
||||
id.KernelURL = tree + "/images/pxeboot/vmlinuz"
|
||||
id.InitrdURL = tree + "/images/pxeboot/initrd.img"
|
||||
}
|
||||
return e.execIPXE("boot", id)
|
||||
id.RepoURL = strings.TrimSuffix(id.KernelURL, "/images/pxeboot/vmlinuz")
|
||||
return e.execIPXE(set, "boot", id)
|
||||
}
|
||||
|
||||
// RenderFallback renders a fallback iPXE script ("local" or "shell") for an
|
||||
// unknown MAC. See docs/endpoints.md for the safety rationale.
|
||||
// RenderFallback renders a fallback iPXE script ("local" or "shell").
|
||||
func (e *Engine) RenderFallback(kind string) ([]byte, error) {
|
||||
name := "fallback-" + kind
|
||||
return e.execIPXE(name, IPXEData{})
|
||||
return e.execIPXE(e.cur.Load(), "fallback-"+kind, IPXEData{})
|
||||
}
|
||||
|
||||
func (e *Engine) execIPXE(name string, d IPXEData) ([]byte, error) {
|
||||
if e.ipxe.Lookup(name) == nil {
|
||||
func (e *Engine) execIPXE(set *Set, name string, d IPXEData) ([]byte, error) {
|
||||
if set.ipxe.Lookup(name) == nil {
|
||||
return nil, fmt.Errorf("no iPXE template %q", name)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := e.ipxe.ExecuteTemplate(&buf, name, d); err != nil {
|
||||
if err := set.ipxe.ExecuteTemplate(&buf, name, d); err != nil {
|
||||
return nil, fmt.Errorf("render ipxe %q: %w", name, err)
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// Validate renders every catalog distro's kickstart and iPXE script against a
|
||||
// representative fixture host, checking that each parses, resolves and leaves no
|
||||
// unresolved template values. It is used by the templates-repo CI
|
||||
// (`bootapi validate <dir>`) to reject a bad template/catalog before it ships.
|
||||
func (e *Engine) Validate() error {
|
||||
set := e.cur.Load()
|
||||
distros := set.cat.All()
|
||||
if len(distros) == 0 {
|
||||
return fmt.Errorf("catalog is empty: no distros to validate")
|
||||
}
|
||||
var errs []string
|
||||
for _, d := range distros {
|
||||
h := fixtureHost(d)
|
||||
ks, name, err := e.RenderKickstart(h)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Sprintf("%s: kickstart: %v", d.Name, err))
|
||||
} else if bad := unresolved(ks); bad != "" {
|
||||
errs = append(errs, fmt.Sprintf("%s: kickstart %q has unresolved value near %q", d.Name, name, bad))
|
||||
}
|
||||
ipxe, err := e.RenderIPXE(h)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Sprintf("%s: ipxe: %v", d.Name, err))
|
||||
} else if bad := unresolved(ipxe); bad != "" {
|
||||
errs = append(errs, fmt.Sprintf("%s: ipxe has unresolved value near %q", d.Name, bad))
|
||||
}
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
return fmt.Errorf("catalog validation failed:\n - %s", strings.Join(errs, "\n - "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// fixtureHost builds a representative host that selects distro d (via an exact
|
||||
// override) with a plausible version/network, for validation rendering.
|
||||
func fixtureHost(d *catalog.Distro) *model.Host {
|
||||
version := d.VersionDefault
|
||||
platform := d.Name
|
||||
if len(d.Match.Platforms) > 0 {
|
||||
platform = d.Match.Platforms[0]
|
||||
}
|
||||
return &model.Host{
|
||||
Hostname: "fixture", Domain: "example.net", Platform: platform,
|
||||
OSFamily: d.Match.Family, OSVersion: version, Arch: "x86_64",
|
||||
TemplateOverride: d.Name, PrimaryIP: "10.0.0.10",
|
||||
Interfaces: []model.Interface{{
|
||||
Name: "eth0", MAC: "aa:bb:cc:00:11:22", IP: "10.0.0.10",
|
||||
PrefixLen: 24, Netmask: "255.255.255.0", Gateway: "10.0.0.1", Primary: true,
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
// unresolved returns the surrounding text of the first Go-template "<no value>"
|
||||
// marker, or "" if none — a cheap check that the data model covered the template.
|
||||
func unresolved(b []byte) string {
|
||||
s := string(b)
|
||||
if i := strings.Index(s, "<no value>"); i >= 0 {
|
||||
start := max(0, i-30)
|
||||
return s[start : i+10]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func funcMap() template.FuncMap {
|
||||
return template.FuncMap{
|
||||
"join": strings.Join,
|
||||
|
||||
@@ -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