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
This commit is contained in:
@@ -0,0 +1,227 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.unkin.net/unkin/bootapi/internal/model"
|
||||
)
|
||||
|
||||
const almaYAML = `
|
||||
name: almalinux9
|
||||
match:
|
||||
platforms: [almalinux9]
|
||||
family: almalinux
|
||||
kickstart: almalinux9
|
||||
version_default: "9"
|
||||
kernel_url: "{{.ArtifactBase}}/almalinux/{{.Version}}/BaseOS/{{.Arch}}/os/images/pxeboot/vmlinuz"
|
||||
initrd_url: "{{.ArtifactBase}}/almalinux/{{.Version}}/BaseOS/{{.Arch}}/os/images/pxeboot/initrd.img"
|
||||
kernel_args: [inst.text]
|
||||
vars:
|
||||
mirror: "{{.ArtifactBase}}/almalinux/{{.Version}}"
|
||||
`
|
||||
|
||||
const fedoraYAML = `
|
||||
name: fedora
|
||||
match:
|
||||
family: fedora
|
||||
kickstart: fedora
|
||||
version_default: "41"
|
||||
kernel_url: "{{.ArtifactBase}}/fedora/releases/{{.Version}}/Everything/{{.Arch}}/os/images/pxeboot/vmlinuz"
|
||||
initrd_url: "{{.ArtifactBase}}/fedora/releases/{{.Version}}/Everything/{{.Arch}}/os/images/pxeboot/initrd.img"
|
||||
`
|
||||
|
||||
func testCatalog(t *testing.T) *Catalog {
|
||||
t.Helper()
|
||||
c, err := Parse(map[string][]byte{
|
||||
"almalinux9.yaml": []byte(almaYAML),
|
||||
"fedora.yaml": []byte(fedoraYAML),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func TestSelect(t *testing.T) {
|
||||
c := testCatalog(t)
|
||||
cases := []struct {
|
||||
host *model.Host
|
||||
want string
|
||||
ok bool
|
||||
}{
|
||||
{&model.Host{Platform: "almalinux9", OSFamily: "almalinux"}, "almalinux9", true}, // exact platform
|
||||
{&model.Host{Platform: "fedora42", OSFamily: "fedora"}, "fedora", true}, // family
|
||||
{&model.Host{Platform: "almalinux9", TemplateOverride: "fedora"}, "fedora", true}, // override wins
|
||||
{&model.Host{Platform: "debian12", OSFamily: "debian"}, "", false}, // no match
|
||||
}
|
||||
for _, tc := range cases {
|
||||
d, ok := c.Select(tc.host)
|
||||
if ok != tc.ok {
|
||||
t.Errorf("Select(%+v) ok=%v, want %v", tc.host, ok, tc.ok)
|
||||
continue
|
||||
}
|
||||
if ok && d.Name != tc.want {
|
||||
t.Errorf("Select(%+v) = %q, want %q", tc.host, d.Name, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve(t *testing.T) {
|
||||
c := testCatalog(t)
|
||||
h := &model.Host{Platform: "almalinux9", OSFamily: "almalinux", OSVersion: "9", Arch: "x86_64"}
|
||||
d, ok := c.Select(h)
|
||||
if !ok {
|
||||
t.Fatal("expected a match")
|
||||
}
|
||||
r, err := d.Resolve(h, "https://af/api/v1/remote")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r.KernelURL != "https://af/api/v1/remote/almalinux/9/BaseOS/x86_64/os/images/pxeboot/vmlinuz" {
|
||||
t.Errorf("kernel = %q", r.KernelURL)
|
||||
}
|
||||
if r.Vars["mirror"] != "https://af/api/v1/remote/almalinux/9" {
|
||||
t.Errorf("mirror = %q", r.Vars["mirror"])
|
||||
}
|
||||
if len(r.KernelArgs) != 1 || r.KernelArgs[0] != "inst.text" {
|
||||
t.Errorf("kernel_args = %v", r.KernelArgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveVersionDefault(t *testing.T) {
|
||||
c := testCatalog(t)
|
||||
// Host with no OSVersion falls back to the catalog's version_default.
|
||||
h := &model.Host{Platform: "fedora", OSFamily: "fedora", Arch: "x86_64"}
|
||||
d, _ := c.Select(h)
|
||||
r, err := d.Resolve(h, "https://af")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(r.KernelURL, "/releases/41/") {
|
||||
t.Errorf("expected version_default 41 in %q", r.KernelURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseValidation(t *testing.T) {
|
||||
bad := map[string]string{
|
||||
"no-kernel": "name: x\nmatch: {platforms: [x]}\nkickstart: x\ninitrd_url: y",
|
||||
"no-match": "name: x\nkickstart: x\nkernel_url: k\ninitrd_url: i",
|
||||
"no-name": "kickstart: x\nmatch: {family: x}\nkernel_url: k\ninitrd_url: i",
|
||||
"bad-template": "name: x\nmatch: {family: x}\nkickstart: x\nkernel_url: \"{{ .Nope\"\ninitrd_url: i",
|
||||
}
|
||||
for name, y := range bad {
|
||||
if _, err := Parse(map[string][]byte{name + ".yaml": []byte(y)}); err == nil {
|
||||
t.Errorf("%s: expected a validation error, got nil", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNames(t *testing.T) {
|
||||
c := testCatalog(t)
|
||||
got := strings.Join(c.Names(), ",")
|
||||
if got != "almalinux9,fedora" {
|
||||
t.Errorf("Names() = %q", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user