8f356346eb
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
228 lines
6.6 KiB
Go
228 lines
6.6 KiB
Go
// 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.<key>. 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
|
|
}
|