Address PR review: PXE gate + callback, git-sync templates, distro catalog, k8s targets, http+https
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful

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:
2026-07-28 22:34:44 +10:00
parent 274c480b09
commit 8f356346eb
32 changed files with 2119 additions and 357 deletions
+35 -18
View File
@@ -23,11 +23,15 @@ against. It is assembled in `internal/render.dataFor` from a NetBox device
| `.Nameservers` | `[]string` | NetBox CF `nameservers`, else `BOOTAPI_NAMESERVERS` | |
| `.RootPasswordHash` | string | **render-time** (`BOOTAPI_ROOT_PASSWORD_HASH[_FILE]`) | crypt(3) hash; empty ⇒ lock root. **Never** from NetBox — see [security.md](security.md) |
| `.SSHAuthorizedKeys` | `[]string` | **render-time** (`BOOTAPI_SSH_AUTHORIZED_KEYS`) | |
| `.PuppetServer` | string | `BOOTAPI_PUPPET_SERVER` | default `puppet.query.consul` |
| `.PuppetCAServer` | string | `BOOTAPI_PUPPET_CA_SERVER` | default `puppetca.query.consul` |
| `.BaseURL` | string | `BOOTAPI_BASE_URL` | bootapi's own URL |
| `.BootBaseURL` | string | `BOOTAPI_BOOT_BASE_URL` | OS install-tree base |
| `.PuppetServer` | string | `BOOTAPI_PUPPET_SERVER` | default `puppet.k8s.syd1.au.unkin.net` |
| `.PuppetCAServer` | string | `BOOTAPI_PUPPET_CA_SERVER` | default `puppetca.k8s.syd1.au.unkin.net` |
| `.PuppetCAURL` | string | `BOOTAPI_PUPPET_CA_URL` | written to `/etc/sysconfig/puppet-initial` as `PUPPETCA_URL` |
| `.BaseURL` | string | `BOOTAPI_BASE_URL` | bootapi's own **http** URL |
| `.KickstartURL` | string | derived | `BaseURL/ks/Hostname` |
| `.CallbackURL` | string | derived | `CallbackBaseURL/provisioned/Hostname` |
| `.ProvisionToken` | string | **render-time** (`BOOTAPI_PROVISION_TOKEN[_FILE]`) | bearer token the `%post` callback sends; empty ⇒ callback snippet omitted |
| `.DistroVars` | `map[string]string` | selected catalog entry's evaluated `vars` | e.g. `.DistroVars.mirror` (install-tree base); empty when no catalog entry matched |
| `.BootBaseURL` | string | `BOOTAPI_BOOT_BASE_URL` | legacy OS-tree base; empty when catalog-driven |
| `.Custom` | `map[string]any` | **all** NetBox custom fields, verbatim | escape hatch for site-specific knobs without a code change |
### `Interface`
@@ -49,8 +53,10 @@ Rendered with everything above **plus**:
| Field | Type | Notes |
|-------|------|-------|
| `.KernelURL` | string | `BootBaseURL/images/pxeboot/vmlinuz` (empty if `BootBaseURL` unset) |
| `.InitrdURL` | string | `BootBaseURL/images/pxeboot/initrd.img` |
| `.KernelURL` | string | from the selected catalog entry's `kernel_url` (else legacy `BootBaseURL/images/pxeboot/vmlinuz`) |
| `.InitrdURL` | string | catalog `initrd_url` (else legacy path) |
| `.RepoURL` | string | OS install-tree root (`KernelURL` minus `images/pxeboot/vmlinuz`); passed as `inst.repo=` |
| `.KernelArgs` | `[]string` | catalog entry's extra kernel args |
The fallback templates (`fallback-local`, `fallback-shell`) are rendered with an
empty value — they take no host data by design.
@@ -60,18 +66,29 @@ empty value — they take no host data by design.
Define these on the *device* (or, where noted, the *IP address*) in NetBox.
All are optional; sensible fallbacks apply.
| Custom field | On | Effect |
|--------------|----|--------|
| `domain` | device | DNS domain; overrides `BOOTAPI_DOMAIN` |
| `gateway` | device / IP address | default gateway (IP-level wins) |
| `nameservers` | device | comma-separated resolvers; overrides `BOOTAPI_NAMESERVERS` |
| `provision_template` | device | force a specific template name (see below) |
| Custom field | On | Type | Effect |
|--------------|----|------|--------|
| `domain` | device | text | DNS domain; overrides `BOOTAPI_DOMAIN` |
| `gateway` | device / IP address | text | default gateway (IP-level wins) |
| `nameservers` | device | text | comma-separated resolvers; overrides `BOOTAPI_NAMESERVERS` |
| `provision_template` | device | text | force a specific catalog entry / template name |
| `pxe_enabled` | device | boolean | gate network install (Cobbler's `netboot_enabled`). Unset ⇒ treated as enabled. Set `false` (or let the callback clear it) to boot local disk instead of re-installing. |
## Template selection precedence
## Distro selection and the catalog
`SelectKickstart` picks the first template name that exists, in order:
Host → distro is resolved through the **distro catalog** (`catalog/*.yaml` in the
templates repo / embedded defaults). Each entry names a kickstart template, the
kernel/initrd URL templates (artifactapi remotes) and extra kernel args. See
[template-authoring.md](template-authoring.md#the-distro-catalog).
1. `provision_template` custom field (exact template name)
2. `.Platform` slug (e.g. `almalinux9`)
3. `.OSFamily` (e.g. `almalinux`, or `fedora`)
4. `BOOTAPI_DEFAULT_TEMPLATE` (default `almalinux9`)
Selection precedence (both catalog `Select` and the kickstart-name fallback):
1. `provision_template` custom field — exact catalog entry / template name.
2. `.Platform` slug (e.g. `almalinux9`) matched against a catalog entry's
`match.platforms`, else a template of that name.
3. `.OSFamily` (e.g. `fedora`) matched against `match.family`, else a template
of that name.
4. `BOOTAPI_DEFAULT_TEMPLATE` (default `almalinux9`).
The version substituted into the catalog URLs is `.OSVersion` (the numeric
suffix of the platform slug), falling back to the entry's `version_default`.
+47 -19
View File
@@ -22,38 +22,66 @@ Create `apps/base/bootapi/` following the argocd-apps `AGENTS.md` pattern:
`default`, SA `default` (copy netbox's `vaultauth.yaml`).
3. **VaultStaticSecret** → k8s Secret `bootapi-secrets`, from Vault kv path
`kubernetes/namespace/bootapi/default/bootapi-secrets` with keys:
- `netbox_token` — a **dedicated, read-only** NetBox API token for bootapi
(create a `bootapi` NetBox user/token via terraform-netbox rather than
reusing the seeded superuser token at
`kv/kubernetes/namespace/netbox/default/netbox-superuser`).
- `netbox_token` — a **dedicated** NetBox API token for bootapi. It needs
**read on devices/interfaces/ip-addresses AND write on the device
`pxe_enabled` custom field** (the provisioned callback PATCHes it — see
[security.md](security.md#netbox-write-scope)). Create a `bootapi` NetBox
user/token via terraform-netbox rather than reusing the seeded superuser
token at `kv/kubernetes/namespace/netbox/default/netbox-superuser`.
- `provision_token` — the shared bearer token guarding `POST /provisioned`
(also embedded in rendered kickstarts). Generate a random value.
- `root_password_hash` — crypt(3) hash for the installed root account
(the successor to Cobbler's eyaml `default_password_crypted`).
- `ssh_authorized_keys` — optional, newline-separated.
4. **ConfigMap** `bootapi-templates` (optional) — override `*.ks.tmpl` /
`*.ipxe.tmpl`, mounted at `BOOTAPI_TEMPLATE_DIR=/etc/bootapi/templates`. Omit
to use the embedded defaults. Annotate the Deployment with
`reloader.stakater.com/auto: "true"` so template edits roll the pods.
4. **Templates**: prefer git-sync — set `BOOTAPI_TEMPLATE_GIT_URL` to
`https://git.unkin.net/unkin/bootapi-templates.git` (public; no token needed)
and bootapi pulls it every `BOOTAPI_TEMPLATE_GIT_INTERVAL` (default 3m). No
ConfigMap or pod restart is needed to change templates — merge to the repo's
`main` and bootapi reloads within the interval (last-good kept on a bad push).
The embedded defaults remain the fallback if the repo is unreachable. (A
`BOOTAPI_TEMPLATE_DIR` ConfigMap is still supported for air-gapped installs.)
5. **Deployment** — image above, env from `config.example.env`, secret keys wired
as `BOOTAPI_NETBOX_TOKEN_FILE`/`BOOTAPI_ROOT_PASSWORD_HASH_FILE` (mount the
Secret) or `...FROM secretKeyRef`. Least-privilege securityContext
(`runAsNonRoot`, `drop: [all]`). Baseline resources: requests `512Mi`/`1`,
limits `2Gi`/`2` cpu.
6. **Service** `bootapi` (ClusterIP, port 80 → 8000) plus a **LoadBalancer** (or
Gateway HTTPRoute) reachable by PXE clients at a stable address/hostname —
this is what DHCP points at. Reuse the Vault-issued TLS the Cobbler vhost used
if you terminate TLS at a gateway; note that iPXE fetches are plain HTTP, so a
plain HTTP listener on the PXE VLAN is required either way.
as `BOOTAPI_NETBOX_TOKEN_FILE` / `BOOTAPI_PROVISION_TOKEN_FILE` /
`BOOTAPI_ROOT_PASSWORD_HASH_FILE` (mount the Secret). Least-privilege
securityContext (`runAsNonRoot`, `drop: [all]`). Baseline resources: requests
`512Mi`/`1`, limits `2Gi`/`2` cpu. The pod needs `git` on PATH for template
sync (the distroless image includes only the static binary — either add a git
layer, use an initContainer that seeds the checkout, or fall back to a
ConfigMap; simplest is a small alpine+git base for this service).
6. **Service + exposure**: see the Gateway section below.
7. Register in `argocd/applicationsets/platform.yaml` (`apps/overlays/*/bootapi`)
and the platform AppProject destinations.
### Gateway: HTTP and HTTPS
PXE installers do **not** trust the internal CA, so the boot path must be served
over **plain HTTP**. Unlike the estate default, the bootapi HTTPRoute must **not
blanket-301 HTTP→HTTPS**:
- A **plain-HTTP** listener/HTTPRoute (or a LoadBalancer Service on port 80→8000)
reachable by PXE clients at a stable address/hostname on the PXE VLAN — this is
the `BOOTAPI_BASE_URL` DHCP/iPXE points at. No redirect.
- Optionally an **HTTPS** HTTPRoute for humans/tooling that do trust the CA
(bootapi can serve TLS directly via `BOOTAPI_TLS_*`, or terminate at the
gateway). This is additive; it must not replace or redirect the HTTP boot path.
The end-of-kickstart callback (`POST /provisioned`) runs over the same plain-HTTP
base by default (the token authenticates it; the install has no CA trust yet). If
you install the internal CA early in `%post`, you may set
`BOOTAPI_CALLBACK_BASE_URL` to the HTTPS URL instead.
### Cross-repo dependencies (per estate conventions)
- **terraform-git**: `unkin/bootapi-templates` repo (this PR's sibling) holds the
live template set + distro catalog + validation CI.
- **argocd-apps**: add a `serviceaccount_*` under `apps/base/woodpecker/` if the
bootapi pipelines need a dedicated SA (they use `default` today).
- **terraform-vault**: add the k8s auth role + kv policy granting the `bootapi`
namespace read on `kv/kubernetes/namespace/bootapi/default/*`.
- **terraform-netbox**: create the read-only `bootapi` NetBox token and seed it
(plus `root_password_hash`) into the Vault kv path above.
- **terraform-netbox**: create the `bootapi` NetBox token (read + write on the
`pxe_enabled` device custom field) and seed it, `provision_token` and
`root_password_hash` into the Vault kv path above. Also define the `pxe_enabled`
boolean custom field on the Device model.
## DHCP change (the cutover)
+63 -31
View File
@@ -1,60 +1,89 @@
# bootapi HTTP endpoints
bootapi speaks plain HTTP. It is fronted by the same Vault-issued TLS the Cobbler
server used; the booting firmware reaches it at the DHCP `next-server` (see
[deployment.md](deployment.md)).
## HTTP and HTTPS — the boot path is plain HTTP by design
bootapi always serves the boot path (`/ipxe`, `/boot/ipxe`, `/ks`) over **plain
HTTP** on `BOOTAPI_LISTEN_ADDR`. A PXE installer environment has no internal-CA
trust, so an HTTPS-only boot URL (with our private CA cert) would fail the TLS
handshake. iPXE and the kickstart therefore use `http://` URLs (from
`BOOTAPI_BASE_URL`).
Optionally bootapi *also* serves HTTPS in parallel (`BOOTAPI_TLS_LISTEN_ADDR` +
cert/key), for clients that do trust the CA. The Kubernetes exposure must **not**
blanket-301 HTTP→HTTPS for the boot endpoints — see
[deployment.md](deployment.md#gateway-http-and-https).
## The PXE flow
```
DHCP ── next-server + filename (ipxe.efi / undionly.kpxe) ──▶ firmware loads iPXE
iPXE ── GET /ipxe/<mac> ───────────────────────────────────▶ bootapi renders a boot script
boot ── kernel + initrd + inst.ks=<BASE_URL>/ks/<host> ────▶ Anaconda fetches the kickstart
KS ── GET /ks/<host> ────────────────────────────────────▶ bootapi renders the kickstart
iPXE ── GET http://<base>/ipxe/<mac> ─────────────────────▶ bootapi renders a boot script
boot ── kernel + initrd + inst.ks=http://<base>/ks/<host> ─▶ Anaconda fetches the kickstart
KS ── GET http://<base>/ks/<host> ──────────────────────▶ bootapi renders the kickstart
post ── POST http://<base>/provisioned/<host> (token) ────▶ bootapi clears pxe_enabled in NetBox
```
This mirrors Cobbler, which chained iPXE to `/cblr/svc/op/gpxe/mac/<mac>` and
served a per-system script carrying `inst.ks=`.
This mirrors Cobbler, which chained iPXE to `/cblr/svc/op/gpxe/mac/<mac>`, served
a per-system script carrying `inst.ks=`, and cleared `netboot_enabled` at the end
of the install.
## Endpoints
| Method | Path | Purpose |
|--------|------|---------|
| GET | `/ipxe/{mac}` | iPXE boot script for the host owning `{mac}`. `{mac}` may use `:`/`-`/`.` separators or be bare hex; a trailing `.ipxe` is stripped. |
| GET | `/boot/ipxe?mac=...` | Query-string alias of `/ipxe/{mac}` (some firmware finds this shape easier to template). |
| GET | `/boot/ipxe?mac=...` | Query-string alias of `/ipxe/{mac}`. |
| GET | `/ks/{ident}` | Rendered kickstart. `{ident}` is a MAC (auto-detected) or a hostname; trailing `.ks`/`.cfg` is stripped. |
| POST | `/provisioned/{ident}` | End-of-kickstart callback; clears `pxe_enabled` in NetBox. **Token-guarded** (`Authorization: Bearer <BOOTAPI_PROVISION_TOKEN>`). |
| GET | `/healthz` | Liveness: always `200 ok`. |
| GET | `/readyz` | Readiness: `200` once templates parsed. Does **not** probe NetBox (a NetBox outage still lets iPXE serve the safe fallback). |
| GET | `/readyz` | Readiness: `200` once templates parsed. Does **not** probe NetBox. |
| GET | `/metrics` | Prometheus metrics (see below). |
## Host identification
A booting host is identified by the **MAC** of the NIC it PXE-booted from
(`/ipxe/{mac}`), which bootapi resolves via NetBox
`GET /api/dcim/interfaces/?mac_address=<mac>` → device → primary IP, platform,
role, interfaces. `/ks/{ident}` additionally accepts a **hostname** (NetBox
device name), for hand-testing and for installers that template the hostname
into the kickstart URL.
(`/ipxe/{mac}`), resolved via NetBox `GET /api/dcim/interfaces/?mac_address=<mac>`
→ device → primary IP, platform, role, interfaces. `/ks/{ident}` and
`/provisioned/{ident}` also accept a **hostname** (NetBox device name).
## Error behavior (important, and deliberate)
## Per-host PXE-enable gate (`pxe_enabled`)
The two endpoints fail **differently** on an unknown host, because the cost of a
`/ipxe/{mac}` checks the device's `pxe_enabled` NetBox custom field (Cobbler's
`netboot_enabled`):
- **unset or `true`** → normal installer boot script.
- **`false`** → the safe **local-boot** fallback, *even for a known host*, so a
machine that has already been provisioned does not re-install on its next PXE.
The `/provisioned/{ident}` callback (called from the kickstart `%post`) sets the
field to `false` when the install finishes; so a host installs once, then gates
itself off. Flip it back to `true` in NetBox to re-image.
## Error behavior (deliberate)
The boot endpoints fail **differently** on an unknown host, because the cost of a
wrong answer differs:
- **`/ipxe/{mac}` never returns 404.** iPXE needs a syntactically valid script or
the boot chain simply errors. An unknown MAC — or *any* NetBox error — returns
HTTP 200 with the **fallback script** selected by `BOOTAPI_UNKNOWN_MAC_FALLBACK`:
- `local` (default): `sanboot` the local disk. Safe: a machine that PXE-booted
by accident (or a NetBox blip) just boots its installed OS; a genuinely new
machine loops back to PXE next time, by which point NetBox should know it. We
deliberately do **not** start an installer for a machine we can't identify —
that could wipe a production box.
- `shell`: drop to an interactive iPXE shell so an operator racking a new box
can read `${net0/mac}` and register it. Opt-in; unsafe as a default because
it halts the boot.
- **`/ks/{ident}` returns 404** for an unknown host (and 502 on a NetBox error).
By the time Anaconda fetches the kickstart it has already committed to
installing; a clear failure is safer than serving an empty or wrong kickstart.
- **`/ipxe/{mac}` never returns 404.** iPXE needs a syntactically valid script.
An unknown MAC — or *any* NetBox error, or a gated host — returns HTTP 200 with
the **fallback script** selected by `BOOTAPI_UNKNOWN_MAC_FALLBACK`:
- `local` (default): `sanboot` the local disk. Safe: an accidental PXE (or a
NetBox blip) boots the installed OS; a genuinely new machine loops back to PXE
next time. We never start an installer for a machine we can't identify.
- `shell`: interactive iPXE shell for an operator to read `${net0/mac}` and
register it. Opt-in; unsafe as a default because it halts the boot.
- **`/ks/{ident}` returns 404** for an unknown host (502 on a NetBox error). By
the time Anaconda fetches the kickstart it has committed to installing; a clear
failure beats an empty/wrong kickstart.
## The provisioned callback
`POST /provisioned/{ident}` requires the shared token in an `Authorization:
Bearer` (or bare `token`) header. Responses: `204` on success, `401` on a
bad/missing token, `404` for an unknown host, `503` when no
`BOOTAPI_PROVISION_TOKEN` is configured (fail closed), `502` on a NetBox write
failure. The default kickstart templates call it from `%post` over plain HTTP
(the token authenticates the call; no CA trust needed at install time).
## Metrics
@@ -65,4 +94,7 @@ All on `/metrics`, prefix `bootapi_`:
- `bootapi_netbox_lookups_total{field,result}` — field = `mac|name`, result = `ok|notfound|error`.
- `bootapi_netbox_lookup_duration_seconds{field}` — histogram.
- `bootapi_netbox_cache_hits_total` / `bootapi_netbox_cache_misses_total`.
- `bootapi_provisioned_total{result}` — result = `ok|unauthorized|notfound|error|disabled`.
- `bootapi_ipxe_gated_total` — known hosts served local-boot because `pxe_enabled=false`.
- `bootapi_template_sync_total` / `bootapi_template_sync_failures_total` / `bootapi_template_generation` — template git-sync (see [template-authoring.md](template-authoring.md)).
- standard Go/process collectors.
+25
View File
@@ -15,6 +15,7 @@ render time from Vault/env.**
| template selection knobs (`provision_template`, `nameservers`, `gateway`) | NetBox custom fields | `.Custom` / typed fields |
| **root password hash** | Vault → `BOOTAPI_ROOT_PASSWORD_HASH[_FILE]` | `.RootPasswordHash` |
| **SSH authorized keys** | Vault → `BOOTAPI_SSH_AUTHORIZED_KEYS` | `.SSHAuthorizedKeys` |
| **provision token** | Vault → `BOOTAPI_PROVISION_TOKEN[_FILE]` | `.ProvisionToken` |
| puppet CA/server names | env (not secret) | `.PuppetServer` / `.PuppetCAServer` |
`BOOTAPI_ROOT_PASSWORD_HASH_FILE` and `BOOTAPI_NETBOX_TOKEN_FILE` let the values
@@ -38,6 +39,30 @@ secret, never in the NetBox/inventory layer.
the puppetmaster autosigns it based on source subnet + `*.main.unkin.net`
(unchanged from Cobbler). So the kickstart carries no puppet secret.
## The provisioned callback token
`POST /provisioned/{ident}` (which flips `pxe_enabled` off in NetBox) is guarded
by `BOOTAPI_PROVISION_TOKEN`. The default kickstart `%post` calls it with that
token in an `Authorization: Bearer` header, so **the token is embedded in every
rendered kickstart** — treat it as a provisioning secret (same exposure class as
the root hash: visible to anything on the provisioning VLAN). It only authorizes
clearing a boot gate, not reading data. Rotate it in Vault as normal; empty
disables the callback (fail closed). The call runs over plain HTTP by default
because `%post` has no internal-CA trust yet; the token — not TLS — is what
authenticates it.
## NetBox write scope
bootapi performs exactly one NetBox write: `PATCH /api/dcim/devices/{id}/` setting
`custom_fields.pxe_enabled=false` from the provisioned callback. Its NetBox token
therefore needs **write on the device `pxe_enabled` custom field** in addition to
read on devices/interfaces/ip-addresses. Scope the `bootapi` NetBox
role/permission to just that (a NetBox object-permission constrained to
`dcim.device` with the `pxe_enabled` field) rather than granting broad write.
This is a deliberate, minimal escalation from the read-only design; it is called
out here and in the deployment doc so the token is provisioned with the right
(and only the right) scope.
## Follow-up: per-template Vault lookups
Today all render-time secrets are process-wide env/files (one root hash, one key
+51 -5
View File
@@ -4,11 +4,22 @@ bootapi ships an embedded default set and lets you override or extend it.
## Where templates live
- **Embedded defaults**: `templates/kickstart/*.ks.tmpl` and
`templates/ipxe/*.ipxe.tmpl`, compiled into the binary (`templates/embed.go`).
- **Overrides**: any directory pointed to by `BOOTAPI_TEMPLATE_DIR`. Files there
with the same base name **replace** the embedded one; new names **add** to the
set. In Kubernetes this is a ConfigMap mount (see [deployment.md](deployment.md)).
- **Embedded defaults**: `templates/kickstart/*.ks.tmpl`,
`templates/ipxe/*.ipxe.tmpl` and `templates/catalog/*.yaml`, compiled into the
binary (`templates/embed.go`). These are the always-available startup fallback.
- **Template git repo** (preferred in prod): `BOOTAPI_TEMPLATE_GIT_URL`. bootapi
clones it at startup and re-pulls every `BOOTAPI_TEMPLATE_GIT_INTERVAL`
(default 3m, like argocd), atomically swapping the loaded set on change. A
parse failure keeps the **last-good** set and is only logged + counted
(`bootapi_template_sync_failures_total`), so a bad push can't take bootapi
down. If the repo is unreachable at startup, bootapi runs on the embedded
defaults. The repo is `unkin/bootapi-templates` (seeded from these embedded
files) and has its own CI validating templates + catalog.
- **Override directory**: `BOOTAPI_TEMPLATE_DIR` (a ConfigMap mount), used only
when no git URL is set. Files there override embedded ones by base name.
In all cases the embedded defaults are the base layer; the git repo / override
dir is layered on top, replacing files of the same base name and adding new ones.
## Naming
@@ -23,6 +34,41 @@ Reserved iPXE names bootapi renders directly:
- `boot` — the per-host boot script (`/ipxe/{mac}` for a known host).
- `fallback-local`, `fallback-shell` — unknown-MAC fallbacks.
## The distro catalog
`catalog/*.yaml` describes each bootable OS, so adding a distro is a YAML +
template change (and, if needed, a new artifactapi remote) — **no bootapi code
change**. One file per distro:
```yaml
name: almalinux9 # catalog key; also what provision_template matches
match:
platforms: [almalinux9] # exact NetBox platform slugs
family: almalinux # OR an OS family (matches almalinux8/9/...)
kickstart: almalinux9 # kickstart template name to render
version_default: "9" # used when the platform slug carries no version
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: # arbitrary templated strings -> .DistroVars.<key>
mirror: "{{.ArtifactBase}}/almalinux/{{.Version}}"
```
`kernel_url`, `initrd_url` and each `vars` value are Go templates rendered with
`{{.ArtifactBase}}` (`BOOTAPI_ARTIFACT_BASE_URL`), `{{.Version}}`, `{{.Arch}}`,
`{{.Hostname}}`, `{{.Platform}}`, `{{.OSFamily}}`. The kickstart template reads
`.DistroVars.mirror` to build its `url`/`repo` lines, so the install-tree layout
lives entirely in the catalog. bootapi derives `inst.repo` for iPXE by trimming
`/images/pxeboot/vmlinuz` off `kernel_url`.
Shipped entries: `almalinux9` (artifactapi `almalinux` remote) and `fedora`
(`fedora` remote). **debian / talos** are documented but not implemented — their
artifact shapes differ (Debian netboot `linux`+`initrd.gz` under
`dists/<rel>/main/installer-<arch>/current/images/netboot/`; Talos ships factory
`vmlinuz`+`initramfs.xz` images) so they need their own catalog fields/template
and possibly a new artifactapi remote. See the catalog README in the templates
repo for the intended path.
## Engine and functions
Standard Go `text/template`. Available funcs: `join`, `upper`, `lower`,