// 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 "" ipxe *template.Template // ipxe templates, named "" 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 }, } }