Seed bootapi templates + distro catalog
ci/woodpecker/pr/validate Pipeline failed
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/push/pre-commit Pipeline was successful
ci/woodpecker/push/validate Pipeline failed

Initial content for bootapi's git-synced template set: the kickstart and iPXE
templates and the distro catalog (almalinux9, fedora) ported from bootapi's
embedded defaults, plus CI that validates every template/catalog renders for
every distro (bootapi validate) and shellchecks the %post blocks.

Claude-Session: https://claude.ai/code/session_015ur3i7D2azsMAWTSVABApv
This commit is contained in:
2026-07-28 23:10:35 +10:00
parent 769c40e830
commit 24fd6b3ffa
14 changed files with 491 additions and 1 deletions
+15
View File
@@ -0,0 +1,15 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-added-large-files
- id: check-merge-conflict
- repo: https://github.com/adrienverge/yamllint
rev: v1.35.1
hooks:
- id: yamllint
args: [-c, .yamllint.yaml]
+18
View File
@@ -0,0 +1,18 @@
when:
- event: [pull_request, push]
steps:
- name: pre-commit
image: git.unkin.net/unkin/almalinux9-gobuilder:20260606
commands:
- uvx pre-commit run --all-files
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 512Mi
cpu: 1
limits:
memory: 2Gi
cpu: 2
+44
View File
@@ -0,0 +1,44 @@
when:
- event: [pull_request, push]
steps:
# Render every kickstart + iPXE script for every catalog distro against a
# fixture host, failing on any parse/resolve error or unresolved value. Reuses
# the real bootapi engine via its `validate` subcommand (fetched @main).
- name: validate
image: golang:1.25
environment:
GOFLAGS: -buildvcs=false
GOPRIVATE: git.unkin.net
GONOSUMCHECK: "1"
GOSUMDB: "off"
GOPROXY: direct
commands:
- go run git.unkin.net/unkin/bootapi/cmd/bootapi@main validate .
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 512Mi
cpu: 1
limits:
memory: 2Gi
cpu: 2
# Best-effort shellcheck of the %post blocks (template actions stripped first).
- name: shellcheck
image: koalaman/shellcheck-alpine:stable
commands:
- apk add --no-cache bash
- bash ci/shellcheck-post.sh
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 128Mi
cpu: 100m
limits:
memory: 512Mi
cpu: 500m
+8
View File
@@ -0,0 +1,8 @@
---
extends: relaxed
rules:
line-length: disable
document-start: disable
comments:
min-spaces-from-content: 1
+41 -1
View File
@@ -1,3 +1,43 @@
# bootapi-templates
Live kickstart/iPXE templates + distro catalog for bootapi. Pulled by bootapi via git-sync; CI validates templates render for every distro.
Live kickstart / iPXE templates and the **distro catalog** for
[bootapi](https://git.unkin.net/unkin/bootapi). bootapi git-syncs this repo
(every ~3 minutes, like argocd) and hot-swaps its template set on change, so OS
and template updates ship by merging here — no bootapi rebuild or redeploy. If
this repo is unreachable, bootapi falls back to its embedded copy of these files.
## Layout
```
kickstart/<name>.ks.tmpl # kickstart templates (Go text/template)
ipxe/<name>.ipxe.tmpl # iPXE scripts: boot, fallback-local, fallback-shell
catalog/<name>.yaml # distro catalog: host -> boot images + kickstart
catalog/README.md # catalog field reference + debian/talos path
ci/shellcheck-post.sh # %post shellcheck helper (used by CI)
```
The data model available to templates and the catalog field reference live in
the bootapi docs: [data-model](https://git.unkin.net/unkin/bootapi/src/branch/main/docs/data-model.md),
[template-authoring](https://git.unkin.net/unkin/bootapi/src/branch/main/docs/template-authoring.md),
and [catalog/README.md](catalog/README.md) here.
## CI (required before merge)
- **validate** — `bootapi validate .` renders every kickstart + iPXE script for
every catalog distro against a fixture host and fails on any parse/resolve
error or unresolved `<no value>`. Plus `shellcheck` (error severity) of the
`%post` blocks.
- **pre-commit** — yamllint + whitespace/EOF checks.
Validate locally before pushing:
```bash
go run git.unkin.net/unkin/bootapi/cmd/bootapi@main validate .
bash ci/shellcheck-post.sh # needs shellcheck
```
## Adding a distro
Add `catalog/<name>.yaml` + `kickstart/<name>.ks.tmpl` (and, if the OS needs a
mirror bootapi can't reach, a terraform-artifactapi remote). See
[catalog/README.md](catalog/README.md) — including the debian/talos path.
+56
View File
@@ -0,0 +1,56 @@
# Distro catalog
One YAML file per bootable OS. bootapi selects an entry for a NetBox host
(platform slug / OS family / `provision_template` override) and uses it to build
the iPXE kernel/initrd URLs and pick the kickstart template. Adding an OS is a
YAML + template change here — **no bootapi code change**.
## Fields
| Field | Required | Meaning |
|-------|----------|---------|
| `name` | yes | catalog key; also what a `provision_template` override matches |
| `match.platforms` | one of platforms/family | exact NetBox platform slugs |
| `match.family` | one of platforms/family | NetBox platform family (matches any version) |
| `kickstart` | yes | kickstart template name (`kickstart/<name>.ks.tmpl`) |
| `kernel_url` | yes | Go-template for the installer kernel URL |
| `initrd_url` | yes | Go-template for the installer initrd URL |
| `version_default` | no | version used when the platform slug carries none |
| `kernel_args` | no | extra iPXE kernel args |
| `vars` | no | named Go-template strings exposed to templates as `.DistroVars.<key>` |
`kernel_url`, `initrd_url` and `vars` values are rendered with:
`{{.ArtifactBase}}` (bootapi's `BOOTAPI_ARTIFACT_BASE_URL`), `{{.Version}}`,
`{{.Arch}}`, `{{.Hostname}}`, `{{.Platform}}`, `{{.OSFamily}}`.
## Shipped entries
- **almalinux9** — artifactapi `almalinux` remote, tree
`almalinux/<ver>/BaseOS/<arch>/os/`.
- **fedora** — artifactapi `fedora` remote, tree
`fedora/releases/<ver>/Everything/<arch>/os/`.
## Adding another distro (the intended path)
Add `catalog/<name>.yaml` + `kickstart/<name>.ks.tmpl`. If the OS lives on a
mirror bootapi can't already reach, add an artifactapi remote first
(terraform-artifactapi `config/remote_rpm/<name>.yaml`). CI (`bootapi validate`)
will render the new distro against a fixture host and fail the PR if anything is
unresolved.
### Debian / Talos — not implemented, different artifact shapes
These need their own catalog fields/template because their PXE artifacts differ
from the RHEL-family `images/pxeboot/{vmlinuz,initrd.img}` layout:
- **Debian**: netboot installer under
`dists/<release>/main/installer-<arch>/current/images/netboot/` with
`linux` + `initrd.gz`, and preseed (not kickstart). A `debian` entry would set
`kernel_url`/`initrd_url` to those paths and reference a `debian.preseed.tmpl`
template (and bootapi would serve it from `/ks` unchanged — it's just text).
- **Talos**: immutable OS booted from factory images (`vmlinuz` + `initramfs.xz`
from the Talos image factory / a mirrored remote), configured by a machine
config, not a kickstart. A `talos` entry would point `kernel_url`/`initrd_url`
at the factory artifacts and carry the config URL via `kernel_args`/`vars`.
Both are deferred; this note records the shape so the follow-up is mechanical.
+18
View File
@@ -0,0 +1,18 @@
# Distro catalog entry: AlmaLinux 9.
# Boot images are proxied through the artifactapi "almalinux" remote. Host ->
# distro selection is NetBox-driven (platform slug almalinux9, or the almalinux
# family, or a provision_template override naming "almalinux9").
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
- net.ifnames=0
vars:
# Version-level mirror base; the kickstart appends BaseOS/AppStream under it.
mirror: "{{.ArtifactBase}}/almalinux/{{.Version}}"
+16
View File
@@ -0,0 +1,16 @@
# Distro catalog entry: Fedora (family-level, matches any fedoraNN platform).
# Boot images are proxied through the artifactapi "fedora" remote, whose tree
# lives under releases/<ver>/Everything/<arch>/os/.
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"
kernel_args:
- inst.text
- net.ifnames=0
vars:
# Install-tree root; the kickstart appends <arch>/os/ under it.
mirror: "{{.ArtifactBase}}/fedora/releases/{{.Version}}/Everything"
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
#
# Best-effort shellcheck of the %post ... %end blocks in the kickstart templates.
# The bodies are Go text/templates, so we first replace every {{ ... }} action
# with a shell-safe placeholder, then extract each %post block into a script and
# run shellcheck at "error" severity (template placeholders make lower-severity
# style warnings meaningless). Fails the build on any shellcheck error.
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "${ROOT_DIR}"
tmp="$(mktemp -d)"
trap 'rm -rf "${tmp}"' EXIT
rc=0
shopt -s nullglob
for ks in kickstart/*.ks.tmpl; do
# Split into %post-scriptlets; strip {{...}} -> "PH"; give each a shebang.
awk '
/^%post/ { inpost=1; n++; next }
/^%end/ { inpost=0; next }
inpost { print > ("'"${tmp}"'/post-" n ".sh") }
' "${ks}"
for f in "${tmp}"/post-*.sh; do
[ -e "${f}" ] || continue
# Neutralize Go template actions and prepend a shebang.
sed -i 's/{{[^}]*}}/PH/g' "${f}"
printf '#!/usr/bin/env bash\n%s' "$(cat "${f}")" > "${f}.final"
echo "shellcheck (error severity): ${ks} -> $(basename "${f}")"
if ! shellcheck -S error "${f}.final"; then
rc=1
fi
rm -f "${f}" "${f}.final"
done
done
if [ "${rc}" -eq 0 ]; then
echo "shellcheck: no errors in %post blocks"
fi
exit "${rc}"
+20
View File
@@ -0,0 +1,20 @@
{{- /*
iPXE boot script for a known, PXE-enabled host. Chains the OS installer
kernel+initrd (from the distro catalog) and points inst.ks= back at bootapi's
/ks/<host> over plain HTTP, so an installer with no internal-CA trust can fetch
it. Mirrors how Cobbler generated a per-MAC gPXE script carrying inst.ks=.
KernelURL/InitrdURL/RepoURL come from the selected catalog entry (artifactapi
remote); KernelArgs are the catalog's extra args. KickstartURL uses
BOOTAPI_BASE_URL (http://).
*/ -}}
#!ipxe
echo bootapi: provisioning {{ .FQDN }} ({{ .Platform }})
{{ if and .KernelURL .InitrdURL -}}
kernel {{ .KernelURL }} initrd=initrd.img{{ if .RepoURL }} inst.repo={{ .RepoURL }}{{ end }} inst.ks={{ .KickstartURL }} ip=dhcp{{ range .KernelArgs }} {{ . }}{{ end }}
initrd {{ .InitrdURL }}
boot
{{- else -}}
echo bootapi: no boot images resolved for {{ .Platform }} (no catalog entry / BOOTAPI_BOOT_BASE_URL); booting local disk
sanboot --no-describe --drive 0x80 || exit
{{- end }}
+10
View File
@@ -0,0 +1,10 @@
{{- /*
Safe default for an UNKNOWN MAC (NetBox has no matching device). We deliberately
do NOT start an installer for a machine we can't identify — that could wipe a
production box that PXE-booted by accident. Instead we boot from local disk, so
an already-installed host just continues, and a brand-new host loops back to PXE
on its next attempt (by which point NetBox should know it).
*/ -}}
#!ipxe
echo bootapi: unknown MAC ${net0/mac}; not provisioning. Booting local disk.
sanboot --no-describe --drive 0x80 || exit
+10
View File
@@ -0,0 +1,10 @@
{{- /*
Debug fallback for an unknown MAC (opt in via BOOTAPI_UNKNOWN_MAC_FALLBACK=shell).
Drops to an interactive iPXE shell instead of booting anything, so an operator
racking a new box can inspect ${net0/mac} and register it in NetBox. Not the
default because it halts the boot and is unsafe for an accidental PXE of a prod
host.
*/ -}}
#!ipxe
echo bootapi: unknown MAC ${net0/mac}; dropping to iPXE shell for debugging.
shell
+113
View File
@@ -0,0 +1,113 @@
{{- /*
AlmaLinux 9 kickstart, ported from the Cobbler default.ks contract.
Rendered by bootapi from NetBox data + render-time secrets + the distro catalog.
Install source comes from the artifactapi almalinux remote (via the catalog
mirror var). The %post installs the Puppet agent and points it at the k8s
puppetserver (puppet.k8s.syd1.au.unkin.net / puppetca.k8s...), writes the
puppet-initial PUPPETCA_URL env file, then posts back to bootapi so pxe_enabled
flips off (Cobbler's netboot_enabled flow).
Data model: see docs/data-model.md. `.RootPasswordHash` and `.ProvisionToken`
come from Vault/env at render time, never from NetBox.
*/ -}}
{{- $mirror := .DistroVars.mirror -}}
#version=RHEL9
# Rendered by bootapi for {{ .FQDN }} (platform {{ .Platform }}, role {{ default "none" .Role }})
text
eula --agreed
firstboot --disable
reboot
# --- install source (artifactapi almalinux remote, from the distro catalog) ---
url --url={{ $mirror }}/BaseOS/{{ .Arch }}/os/
repo --name=AppStream --baseurl={{ $mirror }}/AppStream/{{ .Arch }}/os/
# --- localization ---
keyboard --xlayouts='us'
lang en_AU.UTF-8
timezone Australia/Sydney --utc
# --- security ---
{{ if .RootPasswordHash -}}
rootpw --iscrypted {{ .RootPasswordHash }}
{{- else -}}
rootpw --lock
{{- end }}
selinux --enforcing
firewall --enabled --service=ssh
authselect select sssd with-mkhomedir --force
# --- networking (static, from NetBox) ---
{{- $primary := .PrimaryInterface }}
{{- range .Interfaces }}
{{- if .IP }}
network --bootproto=static --device={{ .MAC }} --ip={{ .IP }} --netmask={{ .Netmask }}{{ if .Gateway }} --gateway={{ .Gateway }}{{ end }}{{ range $.Nameservers }} --nameserver={{ . }}{{ end }}{{ if and $primary (eq .MAC $primary.MAC) }} --hostname={{ $.FQDN }}{{ end }} --activate --onboot=on --noipv6
{{- end }}
{{- end }}
# --- storage ---
ignoredisk --only-use=sda
clearpart --all --initlabel --drives=sda
bootloader --location=mbr --boot-drive=sda --append="crashkernel=auto"
autopart --type=lvm --nohome
# --- packages ---
%packages --ignoremissing --excludedocs
@^minimal-environment
openssh-server
chrony
vim-minimal
tmux
git
-iwl*-firmware
%end
# --- bootstrap: puppet (k8s) + end-of-install callback ---
%post --log=/root/bootapi-post.log
set -x
# chrony: keep time sane before any cert work.
systemctl enable chronyd
{{ if .SSHAuthorizedKeys -}}
# root authorized_keys (from render-time config, not NetBox).
install -d -m0700 /root/.ssh
cat > /root/.ssh/authorized_keys <<'EOF'
{{ range .SSHAuthorizedKeys }}{{ . }}
{{ end }}EOF
chmod 0600 /root/.ssh/authorized_keys
{{- end }}
# Install the Puppet 8 agent from the puppet platform repo.
rpm -q puppet-agent >/dev/null 2>&1 || \
dnf install -y https://yum.puppet.com/puppet8-release-el-9.noarch.rpm
dnf install -y puppet-agent
# Point the agent at the k8s puppetserver / CA.
PUPPET_BIN=/opt/puppetlabs/bin/puppet
"$PUPPET_BIN" config set --section main certname "{{ .FQDN }}"
"$PUPPET_BIN" config set --section main server "{{ .PuppetServer }}"
"$PUPPET_BIN" config set --section main ca_server "{{ .PuppetCAServer }}"
"$PUPPET_BIN" config set --section main report_server "{{ .PuppetServer }}"
"$PUPPET_BIN" config set --section main environment production
# puppet-initial bootstrap unit reads PUPPETCA_URL from this EnvironmentFile.
install -d -m0755 /etc/sysconfig
cat > /etc/sysconfig/puppet-initial <<'EOF'
PUPPETCA_URL={{ .PuppetCAURL }}
EOF
# Enable the agent; first boot triggers firstrun (autosign handles the CSR).
systemctl enable puppet
{{ if and .ProvisionToken .CallbackURL -}}
# Tell bootapi the install is done so it clears pxe_enabled in NetBox and the
# next PXE boots local disk. Runs over plain HTTP (no internal CA trust yet);
# the token authenticates the call. Non-fatal if it fails (the local-disk
# fallback still protects a re-provisioned host on the following boot).
curl -fsS -m 15 -X POST \
-H "Authorization: Bearer {{ .ProvisionToken }}" \
"{{ .CallbackURL }}" || echo "bootapi: provisioned callback failed (non-fatal)"
{{- end }}
%end
+80
View File
@@ -0,0 +1,80 @@
{{- /*
Fedora kickstart (family-level template: matches any "fedoraNN" platform slug
via the catalog family match). Kept close to the AlmaLinux template so the two
stay comparable; the differences are the install-tree layout (releases/.../
Everything) and the puppet release RPM. Install source + boot images come from
the artifactapi fedora remote via the distro catalog.
*/ -}}
{{- $mirror := .DistroVars.mirror -}}
#version=F{{ default "" .OSVersion }}
# Rendered by bootapi for {{ .FQDN }} (platform {{ .Platform }})
text
firstboot --disable
reboot
# --- install source (artifactapi fedora remote, from the distro catalog) ---
url --url={{ $mirror }}/{{ .Arch }}/os/
keyboard --xlayouts='us'
lang en_AU.UTF-8
timezone Australia/Sydney --utc
{{ if .RootPasswordHash -}}
rootpw --iscrypted {{ .RootPasswordHash }}
{{- else -}}
rootpw --lock
{{- end }}
selinux --enforcing
firewall --enabled --service=ssh
# --- networking (static, from NetBox) ---
{{- $primary := .PrimaryInterface }}
{{- range .Interfaces }}
{{- if .IP }}
network --bootproto=static --device={{ .MAC }} --ip={{ .IP }} --netmask={{ .Netmask }}{{ if .Gateway }} --gateway={{ .Gateway }}{{ end }}{{ range $.Nameservers }} --nameserver={{ . }}{{ end }}{{ if and $primary (eq .MAC $primary.MAC) }} --hostname={{ $.FQDN }}{{ end }} --activate --onboot=on --noipv6
{{- end }}
{{- end }}
# --- storage ---
ignoredisk --only-use=sda
clearpart --all --initlabel --drives=sda
bootloader --location=mbr --boot-drive=sda
autopart --type=lvm --nohome
%packages --ignoremissing
@^minimal-environment
openssh-server
chrony
git
%end
%post --log=/root/bootapi-post.log
set -x
systemctl enable chronyd sshd
{{ if .SSHAuthorizedKeys -}}
install -d -m0700 /root/.ssh
cat > /root/.ssh/authorized_keys <<'EOF'
{{ range .SSHAuthorizedKeys }}{{ . }}
{{ end }}EOF
chmod 0600 /root/.ssh/authorized_keys
{{- end }}
dnf install -y "https://yum.puppet.com/puppet8-release-fedora-{{ default "40" .OSVersion }}.noarch.rpm" || true
dnf install -y puppet-agent
PUPPET_BIN=/opt/puppetlabs/bin/puppet
"$PUPPET_BIN" config set --section main certname "{{ .FQDN }}"
"$PUPPET_BIN" config set --section main server "{{ .PuppetServer }}"
"$PUPPET_BIN" config set --section main ca_server "{{ .PuppetCAServer }}"
install -d -m0755 /etc/sysconfig
cat > /etc/sysconfig/puppet-initial <<'EOF'
PUPPETCA_URL={{ .PuppetCAURL }}
EOF
systemctl enable puppet
{{ if and .ProvisionToken .CallbackURL -}}
curl -fsS -m 15 -X POST \
-H "Authorization: Bearer {{ .ProvisionToken }}" \
"{{ .CallbackURL }}" || echo "bootapi: provisioned callback failed (non-fatal)"
{{- end }}
%end