Files
bootapi/internal/catalog/catalog.go
T
unkinben 8f356346eb
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
Address PR review: PXE gate + callback, git-sync templates, distro catalog, k8s targets, http+https
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
2026-07-28 22:34:44 +10:00

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
}