274c480b09
bootapi replaces Cobbler's PXE/kickstart side. It resolves a PXE-booting host
from NetBox (by MAC or hostname), renders an iPXE boot script and a kickstart
from Go text/templates, and serves them over HTTP. The ENC half already moved to
encapi; this covers the provisioning/boot half.
What's here:
- cmd/bootapi + internal/{config,model,netbox,render,server}; embedded default
templates under templates/ (AlmaLinux 9 + Fedora kickstarts, iPXE boot +
unknown-MAC fallbacks) ported from Cobbler's boot/bootstrap contract.
- NetBox client (v4.x API) behind a Resolver interface with a short-TTL cache;
tested against httptest fixtures using real NetBox JSON shapes.
- chi HTTP server: /ipxe/{mac}, /boot/ipxe?mac=, /ks/{ident}, healthz/readyz,
Prometheus /metrics. Unknown MAC -> safe fallback iPXE (200), unknown KS -> 404.
- Secrets (root pw hash, ssh keys) injected at render time from env/Vault, never
NetBox. Config is env-based per estate convention.
- Makefile (build/test/lint/docker + patch/minor/major), Dockerfile (distroless),
.woodpecker (pre-commit, golangci-lint v2 + go test -race, docker build on PR;
image push + Gitea binary release on v* tag), docs/ and example config.
go build/vet clean, go test -race green, golangci-lint v2 clean, pre-commit clean.
Claude-Session: https://claude.ai/code/session_015ur3i7D2azsMAWTSVABApv
269 lines
8.0 KiB
Go
269 lines
8.0 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 (ported from Cobbler's kickstarts) optionally layered with an override
|
|
// directory (a Kubernetes ConfigMap mount in production).
|
|
package render
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"text/template"
|
|
|
|
"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
|
|
BaseURL string // bootapi's own base URL
|
|
BootBaseURL string // OS install-tree base URL
|
|
KickstartURL string // absolute URL a booting host fetches its KS from
|
|
|
|
// --- 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
|
|
BaseURL string
|
|
BootBaseURL string
|
|
DefaultDomain string
|
|
DefaultNS []string
|
|
RootPasswordHash string
|
|
SSHAuthorizedKeys []string
|
|
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) {
|
|
funcs := funcMap()
|
|
ks := template.New("kickstart").Funcs(funcs)
|
|
ipxe := template.New("ipxe").Funcs(funcs)
|
|
set := map[string]bool{}
|
|
|
|
if err := parseTree(ks, ipxe, set, embedded, ".", 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)
|
|
}
|
|
}
|
|
return &Engine{ks: ks, ipxe: ipxe, cfg: cfg, ksSet: set}, 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 {
|
|
walked := false
|
|
err := fs.WalkDir(fsys, root, func(path string, d fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
walked = true
|
|
if d.IsDir() {
|
|
return nil
|
|
}
|
|
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)
|
|
}
|
|
set[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)
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if mustExist && !walked {
|
|
return fmt.Errorf("no templates found under %q", root)
|
|
}
|
|
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.
|
|
func (e *Engine) SelectKickstart(h *model.Host) (string, bool) {
|
|
for _, cand := range []string{h.TemplateOverride, h.Platform, h.OSFamily, e.cfg.DefaultTemplate} {
|
|
if cand != "" && e.ksSet[cand] {
|
|
return cand, true
|
|
}
|
|
}
|
|
return e.cfg.DefaultTemplate, e.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 {
|
|
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 = strings.TrimRight(e.cfg.BaseURL, "/") + "/ks/" + 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,
|
|
BaseURL: e.cfg.BaseURL,
|
|
BootBaseURL: e.cfg.BootBaseURL,
|
|
KickstartURL: ksURL,
|
|
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) {
|
|
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)
|
|
}
|
|
var buf bytes.Buffer
|
|
if err := e.ks.ExecuteTemplate(&buf, name, e.dataFor(h)); 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; empty when BootBaseURL
|
|
// is unset, in which case the template should fall back to a static path.
|
|
KernelURL string
|
|
InitrdURL 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, "/")
|
|
id.KernelURL = tree + "/images/pxeboot/vmlinuz"
|
|
id.InitrdURL = tree + "/images/pxeboot/initrd.img"
|
|
}
|
|
return e.execIPXE("boot", id)
|
|
}
|
|
|
|
// RenderFallback renders a fallback iPXE script ("local" or "shell") for an
|
|
// unknown MAC. See docs/endpoints.md for the safety rationale.
|
|
func (e *Engine) RenderFallback(kind string) ([]byte, error) {
|
|
name := "fallback-" + kind
|
|
return e.execIPXE(name, IPXEData{})
|
|
}
|
|
|
|
func (e *Engine) execIPXE(name string, d IPXEData) ([]byte, error) {
|
|
if e.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 {
|
|
return nil, fmt.Errorf("render ipxe %q: %w", name, err)
|
|
}
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
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
|
|
},
|
|
}
|
|
}
|