// Package catalog is the distro catalog: a set of YAML descriptors (one per // bootable OS) that map a NetBox host to its boot images, kernel args and // kickstart template. Catalog files live in the templates git repo (or the // embedded defaults), so adding Fedora/Debian/Talos later is a YAML + template // change with no bootapi code change. Host -> distro selection stays // NetBox-driven (platform slug / family / provision_template override). package catalog import ( "bytes" "fmt" "sort" "strings" "text/template" "gopkg.in/yaml.v3" "git.unkin.net/unkin/bootapi/internal/model" ) // Distro is one catalog entry (one YAML file). type Distro struct { // Name is the catalog key, also what a provision_template override matches. Name string `yaml:"name"` // Match decides which hosts this distro applies to. Match Match `yaml:"match"` // Kickstart is the kickstart template name to render for this distro. Kickstart string `yaml:"kickstart"` // KernelURL / InitrdURL are Go-template strings rendered with Ctx (they may // reference {{.ArtifactBase}}, {{.Version}}, {{.Arch}}). KernelURL string `yaml:"kernel_url"` InitrdURL string `yaml:"initrd_url"` // KernelArgs are extra iPXE kernel arguments appended verbatim. KernelArgs []string `yaml:"kernel_args"` // VersionDefault is used when the host's platform slug carries no version. VersionDefault string `yaml:"version_default"` // Vars are arbitrary named Go-template strings (rendered with Ctx) exposed // to kickstart/iPXE templates as .DistroVars.. This is how a template // gets e.g. the install-tree mirror base without per-distro Go code. Vars map[string]string `yaml:"vars"` kernelTmpl *template.Template initrdTmpl *template.Template varTmpls map[string]*template.Template } // Match selects hosts for a Distro. type Match struct { // Platforms are exact NetBox platform slugs, e.g. ["almalinux9"]. Platforms []string `yaml:"platforms"` // Family is a NetBox platform family, e.g. "fedora" (matches fedora42 etc). Family string `yaml:"family"` } // Ctx is the value catalog URL/var templates are rendered against. type Ctx struct { ArtifactBase string Version string Arch string Hostname string Platform string OSFamily string } // Resolved is a Distro with its templated fields evaluated for a specific host. type Resolved struct { Name string Kickstart string KernelURL string InitrdURL string KernelArgs []string Vars map[string]string } // Catalog is the parsed, validated set of distros. type Catalog struct { distros []*Distro } // Parse builds a Catalog from named YAML documents (filename -> contents), // validating each and compiling its templates. It is deterministic: distros are // sorted by name so selection is stable regardless of map iteration order. func Parse(files map[string][]byte) (*Catalog, error) { var distros []*Distro names := make([]string, 0, len(files)) for f := range files { names = append(names, f) } sort.Strings(names) for _, f := range names { d := &Distro{} if err := yaml.Unmarshal(files[f], d); err != nil { return nil, fmt.Errorf("catalog %s: %w", f, err) } if err := d.compile(); err != nil { return nil, fmt.Errorf("catalog %s: %w", f, err) } distros = append(distros, d) } sort.Slice(distros, func(i, j int) bool { return distros[i].Name < distros[j].Name }) return &Catalog{distros: distros}, nil } func (d *Distro) compile() error { if d.Name == "" { return fmt.Errorf("missing name") } if d.Kickstart == "" { return fmt.Errorf("%s: missing kickstart", d.Name) } if d.KernelURL == "" || d.InitrdURL == "" { return fmt.Errorf("%s: kernel_url and initrd_url are required", d.Name) } if len(d.Match.Platforms) == 0 && d.Match.Family == "" { return fmt.Errorf("%s: match needs at least one platform or a family", d.Name) } var err error if d.kernelTmpl, err = template.New("kernel").Parse(d.KernelURL); err != nil { return fmt.Errorf("%s: kernel_url: %w", d.Name, err) } if d.initrdTmpl, err = template.New("initrd").Parse(d.InitrdURL); err != nil { return fmt.Errorf("%s: initrd_url: %w", d.Name, err) } d.varTmpls = map[string]*template.Template{} for k, v := range d.Vars { t, err := template.New(k).Parse(v) if err != nil { return fmt.Errorf("%s: var %q: %w", d.Name, k, err) } d.varTmpls[k] = t } return nil } // All returns the catalog's distros (sorted by name). func (c *Catalog) All() []*Distro { return c.distros } // Names returns the catalog distro names (sorted); handy for tests/logging. func (c *Catalog) Names() []string { out := make([]string, len(c.distros)) for i, d := range c.distros { out[i] = d.Name } return out } // Select returns the distro for a host, following precedence: // 1. provision_template override that names a distro exactly, // 2. exact platform-slug match, // 3. OS-family match. // // It reports false when nothing matches (caller falls back to legacy behavior). func (c *Catalog) Select(h *model.Host) (*Distro, bool) { if h.TemplateOverride != "" { for _, d := range c.distros { if d.Name == h.TemplateOverride { return d, true } } } for _, d := range c.distros { for _, p := range d.Match.Platforms { if p == h.Platform && h.Platform != "" { return d, true } } } for _, d := range c.distros { if d.Match.Family != "" && d.Match.Family == h.OSFamily { return d, true } } return nil, false } // Resolve evaluates a distro's templated fields for a host against artifactBase. func (d *Distro) Resolve(h *model.Host, artifactBase string) (*Resolved, error) { version := h.OSVersion if version == "" { version = d.VersionDefault } arch := h.Arch if arch == "" { arch = "x86_64" } ctx := Ctx{ ArtifactBase: strings.TrimRight(artifactBase, "/"), Version: version, Arch: arch, Hostname: h.Hostname, Platform: h.Platform, OSFamily: h.OSFamily, } kernel, err := exec(d.kernelTmpl, ctx) if err != nil { return nil, fmt.Errorf("%s kernel_url: %w", d.Name, err) } initrd, err := exec(d.initrdTmpl, ctx) if err != nil { return nil, fmt.Errorf("%s initrd_url: %w", d.Name, err) } vars := map[string]string{} for k, t := range d.varTmpls { v, err := exec(t, ctx) if err != nil { return nil, fmt.Errorf("%s var %q: %w", d.Name, k, err) } vars[k] = v } return &Resolved{ Name: d.Name, Kickstart: d.Kickstart, KernelURL: kernel, InitrdURL: initrd, KernelArgs: d.KernelArgs, Vars: vars, }, nil } func exec(t *template.Template, ctx Ctx) (string, error) { var buf bytes.Buffer if err := t.Execute(&buf, ctx); err != nil { return "", err } return buf.String(), nil }