Files
bootapi/internal/render/render.go
T
unkinben 8f356346eb
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
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
2026-07-28 22:34:44 +10:00

426 lines
13 KiB
Go

// 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, 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"
"path/filepath"
"strings"
"sync/atomic"
"text/template"
"git.unkin.net/unkin/bootapi/internal/catalog"
"git.unkin.net/unkin/bootapi/internal/model"
)
// Data is the exact, documented value passed to every kickstart/iPXE template.
// It is intentionally flat: template authors get one clear namespace. See
// docs/data-model.md.
type Data struct {
// --- identity (from NetBox) ---
Hostname string
Domain string
FQDN string
Platform string // NetBox platform slug, e.g. "almalinux9"
OSFamily string // "almalinux", "fedora", ...
OSVersion string // "9", "42", ...
Arch string // "x86_64"
Role string // NetBox device role slug
// --- network (from NetBox) ---
Interfaces []model.Interface
PrimaryInterface *model.Interface
PrimaryIP string
Nameservers []string // resolved: host value, else site default
// --- secrets (from Vault/env at render time, never NetBox) ---
RootPasswordHash string
SSHAuthorizedKeys []string
// --- infra pointers (render-time config) ---
PuppetServer string
PuppetCAServer string
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
}
// RenderConfig carries the render-time infra values merged into each Data.
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
SSHAuthorizedKeys []string
DefaultTemplate string
}
const (
ksExt = ".ks.tmpl"
ipxeExt = ".ipxe.tmpl"
)
// 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)
ksNames := map[string]bool{}
catFiles := map[string][]byte{}
if err := walkSet(embedded, ks, ipxe, ksNames, catFiles, true); err != nil {
return nil, fmt.Errorf("parse embedded templates: %w", err)
}
if override != nil {
if err := walkSet(override, ks, ipxe, ksNames, catFiles, false); err != nil {
return nil, fmt.Errorf("parse override templates: %w", err)
}
}
cat, err := catalog.Parse(catFiles)
if err != nil {
return nil, err
}
return &Set{ks: ks, ipxe: ipxe, ksSet: ksNames, cat: cat}, nil
}
// 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, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
walked = true
b, err := fs.ReadFile(fsys, path)
if err != nil {
return err
}
base := filepath.Base(path)
switch {
case strings.HasSuffix(base, ksExt):
name := strings.TrimSuffix(base, ksExt)
if _, err := ks.New(name).Parse(string(b)); err != nil {
return fmt.Errorf("%s: %w", path, err)
}
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
})
if err != nil {
return err
}
if mustExist && !walked {
return fmt.Errorf("no templates found")
}
return nil
}
// 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 != "" && set.ksSet[cand] {
return cand, true
}
}
return e.cfg.DefaultTemplate, set.ksSet[e.cfg.DefaultTemplate]
}
// 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
}
domain := h.Domain
if domain == "" {
domain = e.cfg.DefaultDomain
}
fqdn := h.Hostname
if domain != "" {
fqdn = h.Hostname + "." + domain
}
root := h.RootPasswordHash
if root == "" {
root = e.cfg.RootPasswordHash
}
keys := h.SSHAuthorizedKeys
if len(keys) == 0 {
keys = e.cfg.SSHAuthorizedKeys
}
ksURL := ""
if e.cfg.BaseURL != "" {
ksURL = e.cfg.BaseURL + "/ks/" + h.Hostname
}
cbURL := ""
if e.cfg.CallbackBaseURL != "" {
cbURL = e.cfg.CallbackBaseURL + "/provisioned/" + h.Hostname
}
return Data{
Hostname: h.Hostname,
Domain: domain,
FQDN: fqdn,
Platform: h.Platform,
OSFamily: h.OSFamily,
OSVersion: h.OSVersion,
Arch: h.Arch,
Role: h.Role,
Interfaces: h.Interfaces,
PrimaryInterface: h.PrimaryInterface(),
PrimaryIP: h.PrimaryIP,
Nameservers: ns,
RootPasswordHash: root,
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,
}
}
// 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) {
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 := 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
}
// IPXEData is the value passed to iPXE templates.
type IPXEData struct {
Data
// 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) {
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"
}
id.RepoURL = strings.TrimSuffix(id.KernelURL, "/images/pxeboot/vmlinuz")
return e.execIPXE(set, "boot", id)
}
// RenderFallback renders a fallback iPXE script ("local" or "shell").
func (e *Engine) RenderFallback(kind string) ([]byte, error) {
return e.execIPXE(e.cur.Load(), "fallback-"+kind, IPXEData{})
}
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 := 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,
"upper": strings.ToUpper,
"lower": strings.ToLower,
"default": func(def, v string) string { // {{ default "x" .Maybe }}
if v == "" {
return def
}
return v
},
}
}