// 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 `) 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 "" // 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, ""); 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 }, } }