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
+22 -7
View File
@@ -32,11 +32,24 @@ serves it. Templates are embedded defaults, overridable from a directory
|------|---------| |------|---------|
| `GET /ipxe/{mac}` · `GET /boot/ipxe?mac=` | iPXE boot script | | `GET /ipxe/{mac}` · `GET /boot/ipxe?mac=` | iPXE boot script |
| `GET /ks/{ident}` | rendered kickstart (MAC or hostname) | | `GET /ks/{ident}` | rendered kickstart (MAC or hostname) |
| `POST /provisioned/{ident}` | end-of-kickstart callback (token) → clears `pxe_enabled` in NetBox |
| `GET /healthz` · `/readyz` · `/metrics` | health + Prometheus | | `GET /healthz` · `/readyz` · `/metrics` | health + Prometheus |
Unknown MAC → iPXE gets a **safe fallback** (local-disk boot, HTTP 200), never a The boot path is served over **plain HTTP** (PXE installers have no internal-CA
404. Unknown kickstart host → **404** (fail loud once installing). Full rationale trust); HTTPS is offered in parallel. Unknown MAC → iPXE gets a **safe fallback**
in [docs/endpoints.md](docs/endpoints.md). (local-disk boot, HTTP 200), never a 404; a host with `pxe_enabled=false` gets the
same fallback so it won't re-install. Unknown kickstart host → **404**. Full
rationale in [docs/endpoints.md](docs/endpoints.md).
## Multi-distro + live templates
- **Distro catalog** (`catalog/*.yaml`): each OS maps a NetBox platform/family to
its boot images (artifactapi remotes), kernel args and kickstart template.
Adding Fedora/Debian/Talos is a YAML + template change, no code change. Ships
`almalinux9` + `fedora`.
- **Template git-sync**: bootapi pulls the `bootapi-templates` repo every 3m
(like argocd) and hot-swaps the template set (last-good kept on a bad push);
embedded defaults are the startup fallback.
## Documentation ## Documentation
@@ -71,10 +84,12 @@ release. Cut a release with `make patch|minor|major` (tags + pushes).
``` ```
cmd/bootapi/ main cmd/bootapi/ main
internal/config/ env config internal/config/ env config
internal/model/ Host/Interface data model internal/model/ Host/Interface data model (incl. pxe_enabled gate)
internal/netbox/ NetBox client (+ TTL cache), behind a Resolver interface internal/netbox/ NetBox client (reads + pxe_enabled write) + TTL cache, behind an interface
internal/render/ text/template engine, selection, embedded-defaults loader internal/catalog/ distro catalog: NetBox host -> boot images/kickstart
internal/render/ text/template engine (swappable Set), selection, loader
internal/gitsync/ periodic git pull + atomic template reload (last-good)
internal/server/ chi HTTP handlers + Prometheus metrics internal/server/ chi HTTP handlers + Prometheus metrics
templates/ embedded default kickstart + iPXE templates templates/ embedded defaults: kickstart, iPXE, catalog/*.yaml
docs/ see above docs/ see above
``` ```
+104 -8
View File
@@ -5,12 +5,15 @@ package main
import ( import (
"context" "context"
"fmt"
"log/slog" "log/slog"
"os" "os"
"os/signal" "os/signal"
"path/filepath"
"syscall" "syscall"
"git.unkin.net/unkin/bootapi/internal/config" "git.unkin.net/unkin/bootapi/internal/config"
"git.unkin.net/unkin/bootapi/internal/gitsync"
"git.unkin.net/unkin/bootapi/internal/netbox" "git.unkin.net/unkin/bootapi/internal/netbox"
"git.unkin.net/unkin/bootapi/internal/render" "git.unkin.net/unkin/bootapi/internal/render"
"git.unkin.net/unkin/bootapi/internal/server" "git.unkin.net/unkin/bootapi/internal/server"
@@ -20,6 +23,16 @@ import (
var version = "dev" var version = "dev"
func main() { func main() {
// `bootapi validate [dir]` checks a template/catalog set (used by the
// bootapi-templates repo CI) and exits without starting the server.
if len(os.Args) > 1 && os.Args[1] == "validate" {
dir := "."
if len(os.Args) > 2 {
dir = os.Args[2]
}
os.Exit(runValidate(dir))
}
slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, nil))) slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, nil)))
slog.Info("starting bootapi", "version", version) slog.Info("starting bootapi", "version", version)
@@ -34,22 +47,37 @@ func main() {
if cfg.NetBoxToken == "" { if cfg.NetBoxToken == "" {
slog.Warn("no NetBox token set (BOOTAPI_NETBOX_TOKEN/_FILE); NetBox reads will likely be denied") slog.Warn("no NetBox token set (BOOTAPI_NETBOX_TOKEN/_FILE); NetBox reads will likely be denied")
} }
if cfg.ProvisionToken == "" {
slog.Warn("no BOOTAPI_PROVISION_TOKEN set; the /provisioned callback is disabled (pxe_enabled will not auto-clear)")
}
engine, err := render.NewEngine(templates.FS, cfg.TemplateDir, render.RenderConfig{ rcfg := render.RenderConfig{
PuppetServer: cfg.PuppetServer, PuppetServer: cfg.PuppetServer,
PuppetCAServer: cfg.PuppetCAServer, PuppetCAServer: cfg.PuppetCAServer,
PuppetCAURL: cfg.PuppetCAURL,
BaseURL: cfg.BaseURL, BaseURL: cfg.BaseURL,
CallbackBaseURL: cfg.CallbackBaseURL,
ArtifactBase: cfg.ArtifactBaseURL,
BootBaseURL: cfg.BootBaseURL, BootBaseURL: cfg.BootBaseURL,
ProvisionToken: cfg.ProvisionToken,
DefaultDomain: cfg.Domain, DefaultDomain: cfg.Domain,
DefaultNS: cfg.Nameservers, DefaultNS: cfg.Nameservers,
RootPasswordHash: cfg.RootPasswordHash, RootPasswordHash: cfg.RootPasswordHash,
SSHAuthorizedKeys: cfg.SSHAuthorizedKeys, SSHAuthorizedKeys: cfg.SSHAuthorizedKeys,
DefaultTemplate: cfg.DefaultTemplate, DefaultTemplate: cfg.DefaultTemplate,
}) }
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
engine, syncer, err := buildEngine(ctx, cfg, rcfg)
if err != nil { if err != nil {
slog.Error("load templates", "err", err) slog.Error("load templates", "err", err)
os.Exit(1) os.Exit(1)
} }
if syncer != nil {
go syncer.Run(ctx)
}
nb := netbox.New(netbox.Options{ nb := netbox.New(netbox.Options{
BaseURL: cfg.NetBoxURL, BaseURL: cfg.NetBoxURL,
@@ -59,18 +87,86 @@ func main() {
}) })
cache := netbox.NewCache(nb, cfg.CacheTTL) cache := netbox.NewCache(nb, cfg.CacheTTL)
srv := server.New(server.Options{ opts := server.Options{
Resolver: cache, NetBox: cache,
Engine: engine, Engine: engine,
Cache: cache, Cache: cache,
UnknownMACFallback: cfg.UnknownMACFallback, UnknownMACFallback: cfg.UnknownMACFallback,
}) ProvisionToken: cfg.ProvisionToken,
TLSAddr: cfg.TLSListenAddr,
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) TLSCertFile: cfg.TLSCertFile,
defer stop() TLSKeyFile: cfg.TLSKeyFile,
}
if syncer != nil {
opts.GitStats = syncer
}
srv := server.New(opts)
if err := srv.ListenAndServe(ctx, cfg.ListenAddr); err != nil { if err := srv.ListenAndServe(ctx, cfg.ListenAddr); err != nil {
slog.Error("server", "err", err) slog.Error("server", "err", err)
os.Exit(1) os.Exit(1)
} }
} }
// runValidate loads a template/catalog dir over the embedded defaults and
// renders every catalog distro, returning a process exit code.
func runValidate(dir string) int {
set, err := render.BuildSet(templates.FS, os.DirFS(dir))
if err != nil {
fmt.Fprintf(os.Stderr, "bootapi validate: %v\n", err)
return 1
}
// Fixture render-time config: concrete enough that every field resolves.
eng := render.NewEngine(render.RenderConfig{
PuppetServer: "puppet.k8s.syd1.au.unkin.net", PuppetCAServer: "puppetca.k8s.syd1.au.unkin.net",
PuppetCAURL: "puppetca.k8s.syd1.au.unkin.net",
BaseURL: "http://bootapi.example.net", CallbackBaseURL: "http://bootapi.example.net",
ArtifactBase: "https://artifactapi.example.net/api/v1/remote", ProvisionToken: "validate-token",
DefaultDomain: "example.net", DefaultNS: []string{"10.0.0.1"},
RootPasswordHash: "$6$fixture$hash", DefaultTemplate: "almalinux9",
}, set)
if err := eng.Validate(); err != nil {
fmt.Fprintf(os.Stderr, "bootapi validate: %v\n", err)
return 1
}
fmt.Printf("bootapi validate: OK (%s + embedded defaults)\n", dir)
return 0
}
// buildEngine constructs the render Engine and, when a templates git repo is
// configured, a Syncer that reloads it periodically. Precedence: git repo →
// local override dir → embedded defaults only. Git/dir failures degrade to the
// embedded defaults rather than failing startup.
func buildEngine(ctx context.Context, cfg *config.Config, rcfg render.RenderConfig) (*render.Engine, *gitsync.Syncer, error) {
switch {
case cfg.TemplateGitURL != "":
syncer := gitsync.New(gitsync.Options{
URL: cfg.TemplateGitURL,
Branch: cfg.TemplateGitBranch,
Token: cfg.TemplateGitToken,
Interval: cfg.TemplateGitInterval,
WorkDir: filepath.Join(os.TempDir(), "bootapi-templates"),
}, templates.FS)
set, gerr := syncer.Bootstrap(ctx)
if gerr != nil {
slog.Warn("template git bootstrap degraded to embedded defaults", "err", gerr)
}
engine := render.NewEngine(rcfg, set)
syncer.SetEngine(engine)
return engine, syncer, nil
case cfg.TemplateDir != "":
set, err := render.BuildSet(templates.FS, os.DirFS(cfg.TemplateDir))
if err != nil {
return nil, nil, err
}
return render.NewEngine(rcfg, set), nil, nil
default:
set, err := render.BuildSet(templates.FS, nil)
if err != nil {
return nil, nil, err
}
return render.NewEngine(rcfg, set), nil, nil
}
}
+47 -21
View File
@@ -1,45 +1,71 @@
# bootapi configuration (environment variables). # bootapi configuration (environment variables).
# #
# bootapi is configured entirely from the environment (12-factor style, same as # bootapi is configured entirely from the environment (12-factor, like encapi).
# encapi). In Kubernetes these come from the Deployment env + a Vault-sourced # In Kubernetes these come from the Deployment env + a Vault-sourced Secret and
# Secret (see docs/deployment.md). Locally, `env $(grep -v '^#' config.example.env | xargs) ./bin/bootapi`. # a templates ConfigMap/git repo (see docs/deployment.md). Locally:
# env $(grep -v '^#' config.example.env | xargs) ./bin/bootapi
# --- HTTP --- # --- HTTP (boot path is ALWAYS plain HTTP: PXE installers have no CA trust) ---
BOOTAPI_LISTEN_ADDR=:8000 BOOTAPI_LISTEN_ADDR=:8000
# Optional parallel HTTPS listener for clients that DO trust the internal CA.
# The boot path still works over plain HTTP; do not 301 HTTP->HTTPS (see docs).
# BOOTAPI_TLS_LISTEN_ADDR=:8443
# BOOTAPI_TLS_CERT_FILE=/etc/bootapi/tls/tls.crt
# BOOTAPI_TLS_KEY_FILE=/etc/bootapi/tls/tls.key
# --- NetBox (source of truth for host -> boot data) --- # --- NetBox (source of truth for host -> boot data) ---
BOOTAPI_NETBOX_URL=https://netbox.k8s.syd1.au.unkin.net BOOTAPI_NETBOX_URL=https://netbox.k8s.syd1.au.unkin.net
# Provide the token inline OR (preferred in k8s) via a file mounted from Vault: # Provide the token inline OR (preferred in k8s) via a Vault-mounted file.
# NOTE: the token needs WRITE scope on the device pxe_enabled custom field for
# the /provisioned callback (see docs/security.md).
BOOTAPI_NETBOX_TOKEN= BOOTAPI_NETBOX_TOKEN=
# BOOTAPI_NETBOX_TOKEN_FILE=/var/run/secrets/netbox/api_token # BOOTAPI_NETBOX_TOKEN_FILE=/var/run/secrets/netbox/api_token
BOOTAPI_NETBOX_TIMEOUT=5s BOOTAPI_NETBOX_TIMEOUT=5s
BOOTAPI_NETBOX_INSECURE=false BOOTAPI_NETBOX_INSECURE=false
# --- caching --- # --- caching (short: a re-provisioned host must pick up changes next boot) ---
# Short by design: a re-provisioned host must pick up NetBox changes on its next
# boot. Set 0 to disable.
BOOTAPI_CACHE_TTL=30s BOOTAPI_CACHE_TTL=30s
# --- templates --- # --- templates: git-sync (preferred) OR a local override dir OR embedded ---
# Optional override directory (a ConfigMap mount in k8s); files here win over # Pull a templates repo every interval (default 3m, like argocd); a parse
# the embedded defaults. Leave empty to use only the built-in templates. # failure keeps the last-good set. Embedded defaults are the startup fallback.
# BOOTAPI_TEMPLATE_DIR=/etc/bootapi/templates BOOTAPI_TEMPLATE_GIT_URL=https://git.unkin.net/unkin/bootapi-templates.git
# Template used when NetBox provides no platform/role/override selection key. BOOTAPI_TEMPLATE_GIT_BRANCH=main
BOOTAPI_TEMPLATE_GIT_INTERVAL=3m
# BOOTAPI_TEMPLATE_GIT_TOKEN= # only for a private templates repo
# BOOTAPI_TEMPLATE_DIR=/etc/bootapi/templates # used only when GIT_URL is unset
BOOTAPI_DEFAULT_TEMPLATE=almalinux9 BOOTAPI_DEFAULT_TEMPLATE=almalinux9
# --- URLs baked into rendered output --- # --- URLs baked into rendered output ---
# bootapi's own externally-reachable base URL (goes into the iPXE inst.ks=). # bootapi's own PLAIN-HTTP base (goes into iPXE inst.ks= and /ks URLs). Must be
# reachable without CA trust.
BOOTAPI_BASE_URL=http://bootapi.k8s.syd1.au.unkin.net BOOTAPI_BASE_URL=http://bootapi.k8s.syd1.au.unkin.net
# Base URL of the OS install trees (kernel/initrd + inst.repo). # Base the end-of-kickstart callback posts to; defaults to BOOTAPI_BASE_URL
BOOTAPI_BOOT_BASE_URL=http://mirror.k8s.syd1.au.unkin.net/almalinux/9 # (plain HTTP, works before the internal CA is installed).
# BOOTAPI_CALLBACK_BASE_URL=http://bootapi.k8s.syd1.au.unkin.net
# artifactapi remote base the distro catalog builds kernel/initrd URLs from.
BOOTAPI_ARTIFACT_BASE_URL=https://artifactapi.k8s.syd1.au.unkin.net/api/v1/remote
# Legacy fallback OS-tree base, used only if no catalog entry matches. Normally
# empty (the distro catalog drives boot images).
# BOOTAPI_BOOT_BASE_URL=
# --- puppet bootstrap targets (baked into kickstart %post) --- # --- end-of-kickstart callback token (guards POST /provisioned) ---
BOOTAPI_PUPPET_SERVER=puppet.query.consul # Empty disables the callback (pxe_enabled will not auto-clear). Embedded in the
BOOTAPI_PUPPET_CA_SERVER=puppetca.query.consul # rendered kickstart, so treat as a provisioning secret (docs/security.md).
BOOTAPI_PROVISION_TOKEN=
# BOOTAPI_PROVISION_TOKEN_FILE=/var/run/secrets/bootapi/provision_token
# --- network defaults (used when NetBox does not record them per-device) --- # --- puppet bootstrap targets (k8s puppetserver; baked into kickstart %post) ---
BOOTAPI_PUPPET_SERVER=puppet.k8s.syd1.au.unkin.net
BOOTAPI_PUPPET_CA_SERVER=puppetca.k8s.syd1.au.unkin.net
# Written to /etc/sysconfig/puppet-initial as PUPPETCA_URL (read by the
# puppet-initial RPM's systemd bootstrap unit).
BOOTAPI_PUPPET_CA_URL=puppetca.k8s.syd1.au.unkin.net
# --- network defaults (used when NetBox records none per-device) ---
BOOTAPI_DOMAIN=main.unkin.net BOOTAPI_DOMAIN=main.unkin.net
BOOTAPI_NAMESERVERS=198.18.19.19 # k8s bind-resolvers LoadBalancer (replaces the legacy VM resolvers).
BOOTAPI_NAMESERVERS=198.18.200.7
# --- render-time secrets (NEVER stored in NetBox; from Vault in k8s) --- # --- render-time secrets (NEVER stored in NetBox; from Vault in k8s) ---
# crypt(3) hash for the root account. Empty => root account locked. # crypt(3) hash for the root account. Empty => root account locked.
+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` | | | `.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) | | `.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`) | | | `.SSHAuthorizedKeys` | `[]string` | **render-time** (`BOOTAPI_SSH_AUTHORIZED_KEYS`) | |
| `.PuppetServer` | string | `BOOTAPI_PUPPET_SERVER` | default `puppet.query.consul` | | `.PuppetServer` | string | `BOOTAPI_PUPPET_SERVER` | default `puppet.k8s.syd1.au.unkin.net` |
| `.PuppetCAServer` | string | `BOOTAPI_PUPPET_CA_SERVER` | default `puppetca.query.consul` | | `.PuppetCAServer` | string | `BOOTAPI_PUPPET_CA_SERVER` | default `puppetca.k8s.syd1.au.unkin.net` |
| `.BaseURL` | string | `BOOTAPI_BASE_URL` | bootapi's own URL | | `.PuppetCAURL` | string | `BOOTAPI_PUPPET_CA_URL` | written to `/etc/sysconfig/puppet-initial` as `PUPPETCA_URL` |
| `.BootBaseURL` | string | `BOOTAPI_BOOT_BASE_URL` | OS install-tree base | | `.BaseURL` | string | `BOOTAPI_BASE_URL` | bootapi's own **http** URL |
| `.KickstartURL` | string | derived | `BaseURL/ks/Hostname` | | `.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 | | `.Custom` | `map[string]any` | **all** NetBox custom fields, verbatim | escape hatch for site-specific knobs without a code change |
### `Interface` ### `Interface`
@@ -49,8 +53,10 @@ Rendered with everything above **plus**:
| Field | Type | Notes | | Field | Type | Notes |
|-------|------|-------| |-------|------|-------|
| `.KernelURL` | string | `BootBaseURL/images/pxeboot/vmlinuz` (empty if `BootBaseURL` unset) | | `.KernelURL` | string | from the selected catalog entry's `kernel_url` (else legacy `BootBaseURL/images/pxeboot/vmlinuz`) |
| `.InitrdURL` | string | `BootBaseURL/images/pxeboot/initrd.img` | | `.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 The fallback templates (`fallback-local`, `fallback-shell`) are rendered with an
empty value — they take no host data by design. 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. Define these on the *device* (or, where noted, the *IP address*) in NetBox.
All are optional; sensible fallbacks apply. All are optional; sensible fallbacks apply.
| Custom field | On | Effect | | Custom field | On | Type | Effect |
|--------------|----|--------| |--------------|----|------|--------|
| `domain` | device | DNS domain; overrides `BOOTAPI_DOMAIN` | | `domain` | device | text | DNS domain; overrides `BOOTAPI_DOMAIN` |
| `gateway` | device / IP address | default gateway (IP-level wins) | | `gateway` | device / IP address | text | default gateway (IP-level wins) |
| `nameservers` | device | comma-separated resolvers; overrides `BOOTAPI_NAMESERVERS` | | `nameservers` | device | text | comma-separated resolvers; overrides `BOOTAPI_NAMESERVERS` |
| `provision_template` | device | force a specific template name (see below) | | `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) Selection precedence (both catalog `Select` and the kickstart-name fallback):
2. `.Platform` slug (e.g. `almalinux9`)
3. `.OSFamily` (e.g. `almalinux`, or `fedora`) 1. `provision_template` custom field — exact catalog entry / template name.
4. `BOOTAPI_DEFAULT_TEMPLATE` (default `almalinux9`) 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`). `default`, SA `default` (copy netbox's `vaultauth.yaml`).
3. **VaultStaticSecret** → k8s Secret `bootapi-secrets`, from Vault kv path 3. **VaultStaticSecret** → k8s Secret `bootapi-secrets`, from Vault kv path
`kubernetes/namespace/bootapi/default/bootapi-secrets` with keys: `kubernetes/namespace/bootapi/default/bootapi-secrets` with keys:
- `netbox_token` — a **dedicated, read-only** NetBox API token for bootapi - `netbox_token` — a **dedicated** NetBox API token for bootapi. It needs
(create a `bootapi` NetBox user/token via terraform-netbox rather than **read on devices/interfaces/ip-addresses AND write on the device
reusing the seeded superuser token at `pxe_enabled` custom field** (the provisioned callback PATCHes it — see
`kv/kubernetes/namespace/netbox/default/netbox-superuser`). [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 - `root_password_hash` — crypt(3) hash for the installed root account
(the successor to Cobbler's eyaml `default_password_crypted`). (the successor to Cobbler's eyaml `default_password_crypted`).
- `ssh_authorized_keys` — optional, newline-separated. - `ssh_authorized_keys` — optional, newline-separated.
4. **ConfigMap** `bootapi-templates` (optional) — override `*.ks.tmpl` / 4. **Templates**: prefer git-sync — set `BOOTAPI_TEMPLATE_GIT_URL` to
`*.ipxe.tmpl`, mounted at `BOOTAPI_TEMPLATE_DIR=/etc/bootapi/templates`. Omit `https://git.unkin.net/unkin/bootapi-templates.git` (public; no token needed)
to use the embedded defaults. Annotate the Deployment with and bootapi pulls it every `BOOTAPI_TEMPLATE_GIT_INTERVAL` (default 3m). No
`reloader.stakater.com/auto: "true"` so template edits roll the pods. 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 5. **Deployment** — image above, env from `config.example.env`, secret keys wired
as `BOOTAPI_NETBOX_TOKEN_FILE`/`BOOTAPI_ROOT_PASSWORD_HASH_FILE` (mount the as `BOOTAPI_NETBOX_TOKEN_FILE` / `BOOTAPI_PROVISION_TOKEN_FILE` /
Secret) or `...FROM secretKeyRef`. Least-privilege securityContext `BOOTAPI_ROOT_PASSWORD_HASH_FILE` (mount the Secret). Least-privilege
(`runAsNonRoot`, `drop: [all]`). Baseline resources: requests `512Mi`/`1`, securityContext (`runAsNonRoot`, `drop: [all]`). Baseline resources: requests
limits `2Gi`/`2` cpu. `512Mi`/`1`, limits `2Gi`/`2` cpu. The pod needs `git` on PATH for template
6. **Service** `bootapi` (ClusterIP, port 80 → 8000) plus a **LoadBalancer** (or sync (the distroless image includes only the static binary — either add a git
Gateway HTTPRoute) reachable by PXE clients at a stable address/hostname — layer, use an initContainer that seeds the checkout, or fall back to a
this is what DHCP points at. Reuse the Vault-issued TLS the Cobbler vhost used ConfigMap; simplest is a small alpine+git base for this service).
if you terminate TLS at a gateway; note that iPXE fetches are plain HTTP, so a 6. **Service + exposure**: see the Gateway section below.
plain HTTP listener on the PXE VLAN is required either way.
7. Register in `argocd/applicationsets/platform.yaml` (`apps/overlays/*/bootapi`) 7. Register in `argocd/applicationsets/platform.yaml` (`apps/overlays/*/bootapi`)
and the platform AppProject destinations. 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) ### 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 - **argocd-apps**: add a `serviceaccount_*` under `apps/base/woodpecker/` if the
bootapi pipelines need a dedicated SA (they use `default` today). bootapi pipelines need a dedicated SA (they use `default` today).
- **terraform-vault**: add the k8s auth role + kv policy granting the `bootapi` - **terraform-vault**: add the k8s auth role + kv policy granting the `bootapi`
namespace read on `kv/kubernetes/namespace/bootapi/default/*`. namespace read on `kv/kubernetes/namespace/bootapi/default/*`.
- **terraform-netbox**: create the read-only `bootapi` NetBox token and seed it - **terraform-netbox**: create the `bootapi` NetBox token (read + write on the
(plus `root_password_hash`) into the Vault kv path above. `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) ## DHCP change (the cutover)
+63 -31
View File
@@ -1,60 +1,89 @@
# bootapi HTTP endpoints # bootapi HTTP endpoints
bootapi speaks plain HTTP. It is fronted by the same Vault-issued TLS the Cobbler ## HTTP and HTTPS — the boot path is plain HTTP by design
server used; the booting firmware reaches it at the DHCP `next-server` (see
[deployment.md](deployment.md)). 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 ## The PXE flow
``` ```
DHCP ── next-server + filename (ipxe.efi / undionly.kpxe) ──▶ firmware loads iPXE DHCP ── next-server + filename (ipxe.efi / undionly.kpxe) ──▶ firmware loads iPXE
iPXE ── GET /ipxe/<mac> ───────────────────────────────────▶ bootapi renders a boot script iPXE ── GET http://<base>/ipxe/<mac> ─────────────────────▶ bootapi renders a boot script
boot ── kernel + initrd + inst.ks=<BASE_URL>/ks/<host> ────▶ Anaconda fetches the kickstart boot ── kernel + initrd + inst.ks=http://<base>/ks/<host> ─▶ Anaconda fetches the kickstart
KS ── GET /ks/<host> ────────────────────────────────────▶ bootapi renders 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 This mirrors Cobbler, which chained iPXE to `/cblr/svc/op/gpxe/mac/<mac>`, served
served a per-system script carrying `inst.ks=`. a per-system script carrying `inst.ks=`, and cleared `netboot_enabled` at the end
of the install.
## Endpoints ## Endpoints
| Method | Path | Purpose | | 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 | `/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. | | 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 | `/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). | | GET | `/metrics` | Prometheus metrics (see below). |
## Host identification ## Host identification
A booting host is identified by the **MAC** of the NIC it PXE-booted from A booting host is identified by the **MAC** of the NIC it PXE-booted from
(`/ipxe/{mac}`), which bootapi resolves via NetBox (`/ipxe/{mac}`), resolved via NetBox `GET /api/dcim/interfaces/?mac_address=<mac>`
`GET /api/dcim/interfaces/?mac_address=<mac>` → device → primary IP, platform, → device → primary IP, platform, role, interfaces. `/ks/{ident}` and
role, interfaces. `/ks/{ident}` additionally accepts a **hostname** (NetBox `/provisioned/{ident}` also accept a **hostname** (NetBox device name).
device name), for hand-testing and for installers that template the hostname
into the kickstart URL.
## 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: wrong answer differs:
- **`/ipxe/{mac}` never returns 404.** iPXE needs a syntactically valid script or - **`/ipxe/{mac}` never returns 404.** iPXE needs a syntactically valid script.
the boot chain simply errors. An unknown MAC — or *any* NetBox error — returns An unknown MAC — or *any* NetBox error, or a gated host — returns HTTP 200 with
HTTP 200 with the **fallback script** selected by `BOOTAPI_UNKNOWN_MAC_FALLBACK`: the **fallback script** selected by `BOOTAPI_UNKNOWN_MAC_FALLBACK`:
- `local` (default): `sanboot` the local disk. Safe: a machine that PXE-booted - `local` (default): `sanboot` the local disk. Safe: an accidental PXE (or a
by accident (or a NetBox blip) just boots its installed OS; a genuinely new NetBox blip) boots the installed OS; a genuinely new machine loops back to PXE
machine loops back to PXE next time, by which point NetBox should know it. We next time. We never start an installer for a machine we can't identify.
deliberately do **not** start an installer for a machine we can't identify — - `shell`: interactive iPXE shell for an operator to read `${net0/mac}` and
that could wipe a production box. register it. Opt-in; unsafe as a default because it halts the boot.
- `shell`: drop to an interactive iPXE shell so an operator racking a new box - **`/ks/{ident}` returns 404** for an unknown host (502 on a NetBox error). By
can read `${net0/mac}` and register it. Opt-in; unsafe as a default because the time Anaconda fetches the kickstart it has committed to installing; a clear
it halts the boot. failure beats an empty/wrong kickstart.
- **`/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 ## The provisioned callback
installing; a clear failure is safer than serving an empty or wrong kickstart.
`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 ## 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_lookups_total{field,result}` — field = `mac|name`, result = `ok|notfound|error`.
- `bootapi_netbox_lookup_duration_seconds{field}` — histogram. - `bootapi_netbox_lookup_duration_seconds{field}` — histogram.
- `bootapi_netbox_cache_hits_total` / `bootapi_netbox_cache_misses_total`. - `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. - 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 | | template selection knobs (`provision_template`, `nameservers`, `gateway`) | NetBox custom fields | `.Custom` / typed fields |
| **root password hash** | Vault → `BOOTAPI_ROOT_PASSWORD_HASH[_FILE]` | `.RootPasswordHash` | | **root password hash** | Vault → `BOOTAPI_ROOT_PASSWORD_HASH[_FILE]` | `.RootPasswordHash` |
| **SSH authorized keys** | Vault → `BOOTAPI_SSH_AUTHORIZED_KEYS` | `.SSHAuthorizedKeys` | | **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` | | puppet CA/server names | env (not secret) | `.PuppetServer` / `.PuppetCAServer` |
`BOOTAPI_ROOT_PASSWORD_HASH_FILE` and `BOOTAPI_NETBOX_TOKEN_FILE` let the values `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` the puppetmaster autosigns it based on source subnet + `*.main.unkin.net`
(unchanged from Cobbler). So the kickstart carries no puppet secret. (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 ## Follow-up: per-template Vault lookups
Today all render-time secrets are process-wide env/files (one root hash, one key 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 ## Where templates live
- **Embedded defaults**: `templates/kickstart/*.ks.tmpl` and - **Embedded defaults**: `templates/kickstart/*.ks.tmpl`,
`templates/ipxe/*.ipxe.tmpl`, compiled into the binary (`templates/embed.go`). `templates/ipxe/*.ipxe.tmpl` and `templates/catalog/*.yaml`, compiled into the
- **Overrides**: any directory pointed to by `BOOTAPI_TEMPLATE_DIR`. Files there binary (`templates/embed.go`). These are the always-available startup fallback.
with the same base name **replace** the embedded one; new names **add** to the - **Template git repo** (preferred in prod): `BOOTAPI_TEMPLATE_GIT_URL`. bootapi
set. In Kubernetes this is a ConfigMap mount (see [deployment.md](deployment.md)). 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 ## Naming
@@ -23,6 +34,41 @@ Reserved iPXE names bootapi renders directly:
- `boot` — the per-host boot script (`/ipxe/{mac}` for a known host). - `boot` — the per-host boot script (`/ipxe/{mac}` for a known host).
- `fallback-local`, `fallback-shell` — unknown-MAC fallbacks. - `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 ## Engine and functions
Standard Go `text/template`. Available funcs: `join`, `upper`, `lower`, Standard Go `text/template`. Available funcs: `join`, `upper`, `lower`,
+2
View File
@@ -5,12 +5,14 @@ go 1.25
require ( require (
github.com/go-chi/chi/v5 v5.3.0 github.com/go-chi/chi/v5 v5.3.0
github.com/prometheus/client_golang v1.23.0 github.com/prometheus/client_golang v1.23.0
gopkg.in/yaml.v3 v3.0.1
) )
require ( require (
github.com/beorn7/perks v1.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/kr/text v0.2.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.65.0 // indirect github.com/prometheus/common v0.65.0 // indirect
+10
View File
@@ -2,6 +2,7 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM= github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
@@ -10,6 +11,10 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
@@ -24,6 +29,8 @@ github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2
github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
@@ -32,5 +39,8 @@ golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+227
View File
@@ -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
}
+126
View File
@@ -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)
}
}
+124 -57
View File
@@ -11,67 +11,99 @@ import (
// Config is the fully-resolved server configuration. // Config is the fully-resolved server configuration.
type Config struct { type Config struct {
// ListenAddr is the HTTP bind address, e.g. ":8000". // ListenAddr is the plain-HTTP bind address, e.g. ":8000". The boot path
// (iPXE + kickstart) is always served here so installers with no internal
// CA trust can reach it.
ListenAddr string ListenAddr string
// TLSListenAddr, when set with TLSCertFile/TLSKeyFile, additionally serves
// HTTPS. Boot endpoints work on both; the plain-HTTP listener is mandatory,
// HTTPS is opt-in (see docs/endpoints.md).
TLSListenAddr string
TLSCertFile string
TLSKeyFile string
// NetBoxURL is the base URL of the NetBox API, // NetBoxURL is the base URL of the NetBox API,
// e.g. "https://netbox.k8s.syd1.au.unkin.net". // e.g. "https://netbox.k8s.syd1.au.unkin.net".
NetBoxURL string NetBoxURL string
// NetBoxToken is the NetBox API token. Prefer NetBoxTokenFile in k8s. // NetBoxToken is the NetBox API token. Prefer NetBoxTokenFile in k8s. Needs
// WRITE scope on the device pxe_enabled custom field for the callback.
NetBoxToken string NetBoxToken string
// NetBoxTimeout bounds each NetBox HTTP request. // NetBoxTimeout bounds each NetBox HTTP request.
NetBoxTimeout time.Duration NetBoxTimeout time.Duration
// NetBoxInsecure disables TLS verification against NetBox (dev only). // NetBoxInsecure disables TLS verification against NetBox (dev only).
NetBoxInsecure bool NetBoxInsecure bool
// CacheTTL is how long a resolved host is cached in memory. Short by // CacheTTL is how long a resolved host is cached in memory.
// design: NetBox is the source of truth and a machine's provisioning data
// can change between boots.
CacheTTL time.Duration CacheTTL time.Duration
// TemplateDir, when set, is a directory of override templates layered on // TemplateDir, when set, is a directory of override templates layered on
// top of the embedded defaults (a Kubernetes ConfigMap mount in prod). // top of the embedded defaults (a ConfigMap mount). Ignored when a template
// git repo is configured.
TemplateDir string TemplateDir string
// DefaultTemplate is the kickstart template used when NetBox provides no // DefaultTemplate is the kickstart template used when no catalog/platform
// platform/role/override selection key. // selection key matches.
DefaultTemplate string DefaultTemplate string
// BaseURL is bootapi's own externally-reachable base URL, baked into the // --- template git-sync (preferred over TemplateDir) ---
// iPXE script's inst.ks= and repo URLs so a booting host calls back here. // TemplateGitURL, when set, makes bootapi clone a templates repo and re-pull
// e.g. "http://bootapi.k8s.syd1.au.unkin.net". // it every TemplateGitInterval, atomically swapping the loaded set on change
BaseURL string // and keeping the last-good set on a parse failure.
TemplateGitURL string
TemplateGitBranch string
TemplateGitInterval time.Duration
// TemplateGitToken is an optional token for a private templates repo,
// injected into the HTTPS clone URL. Empty for a public repo.
TemplateGitToken string
// BootBaseURL is the base URL of the OS install trees (kernel/initrd + // BaseURL is the http:// base PXE clients use to reach bootapi. It is baked
// inst.repo), e.g. "http://mirror.k8s.syd1.au.unkin.net/almalinux". // into the iPXE inst.ks= and /ks URLs, so it MUST be reachable without CA
// trust (plain HTTP). e.g. "http://bootapi.k8s.syd1.au.unkin.net".
BaseURL string
// CallbackBaseURL is the base the end-of-kickstart callback uses. Defaults
// to BaseURL (plain HTTP, works before the internal CA is installed). Set to
// an https:// URL only if the kickstart installs the internal CA before the
// callback runs.
CallbackBaseURL string
// ArtifactBaseURL is the artifactapi remote base the distro catalog builds
// kernel/initrd URLs from,
// e.g. "https://artifactapi.k8s.syd1.au.unkin.net/api/v1/remote".
ArtifactBaseURL string
// BootBaseURL is a legacy fallback OS-tree base used only when no catalog
// entry matches a host. Normally empty (the catalog drives boot images).
BootBaseURL string BootBaseURL string
// ProvisionToken guards POST /provisioned. Empty disables the callback
// endpoint (fail closed). Prefer ProvisionTokenFile in k8s.
ProvisionToken string
// PuppetServer / PuppetCAServer are baked into kickstart %post so the // PuppetServer / PuppetCAServer are baked into kickstart %post so the
// freshly-installed host checks in to the right place. // freshly-installed host checks in to the k8s puppetserver.
PuppetServer string PuppetServer string
PuppetCAServer string PuppetCAServer string
// PuppetCAURL is written to the puppet-initial EnvironmentFile as
// PUPPETCA_URL (consumed by that RPM's systemd bootstrap unit).
PuppetCAURL string
// Domain is the default DNS domain applied when NetBox does not record one // Domain is the default DNS domain applied when NetBox records none.
// for a device.
Domain string Domain string
// Nameservers is the default resolver list applied when NetBox records none.
// Nameservers is the default resolver list applied when NetBox records
// none for a device.
Nameservers []string Nameservers []string
// RootPasswordHash is a crypt(3) hash injected into kickstarts at render // RootPasswordHash is a crypt(3) hash injected into kickstarts at render
// time (sourced from Vault in k8s). Empty locks the root account. // time (Vault in k8s). Empty locks the root account.
RootPasswordHash string RootPasswordHash string
// SSHAuthorizedKeys are public keys installed for root at render time. // SSHAuthorizedKeys are public keys installed for root at render time.
SSHAuthorizedKeys []string SSHAuthorizedKeys []string
// UnknownMACFallback selects what the iPXE endpoint returns for a MAC that // UnknownMACFallback selects the iPXE script for an unknown MAC: "local"
// NetBox does not know: "local" (chain to local disk, the safe default) or // (boot local disk, safe default) or "shell" (iPXE shell for debugging).
// "shell" (drop to an iPXE shell for debugging). See docs/endpoints.md.
UnknownMACFallback string UnknownMACFallback string
} }
// Load reads configuration from the environment, applying defaults, and reads a // Load reads configuration from the environment, applying defaults. *_FILE
// token file when BOOTAPI_NETBOX_TOKEN_FILE is set (Vault-mounted secret). // variants (Vault-mounted secrets) win over their inline counterparts.
func Load() (*Config, error) { func Load() (*Config, error) {
cacheTTL, err := time.ParseDuration(getenv("BOOTAPI_CACHE_TTL", "30s")) cacheTTL, err := time.ParseDuration(getenv("BOOTAPI_CACHE_TTL", "30s"))
if err != nil { if err != nil {
@@ -81,14 +113,22 @@ func Load() (*Config, error) {
if err != nil { if err != nil {
return nil, fmt.Errorf("invalid BOOTAPI_NETBOX_TIMEOUT: %w", err) return nil, fmt.Errorf("invalid BOOTAPI_NETBOX_TIMEOUT: %w", err)
} }
gitInterval, err := time.ParseDuration(getenv("BOOTAPI_TEMPLATE_GIT_INTERVAL", "3m"))
if err != nil {
return nil, fmt.Errorf("invalid BOOTAPI_TEMPLATE_GIT_INTERVAL: %w", err)
}
token := os.Getenv("BOOTAPI_NETBOX_TOKEN") token, err := readSecret("BOOTAPI_NETBOX_TOKEN")
if tf := os.Getenv("BOOTAPI_NETBOX_TOKEN_FILE"); tf != "" { if err != nil {
b, err := os.ReadFile(tf) return nil, err
if err != nil { }
return nil, fmt.Errorf("read BOOTAPI_NETBOX_TOKEN_FILE %q: %w", tf, err) rootHash, err := readSecret("BOOTAPI_ROOT_PASSWORD_HASH")
} if err != nil {
token = strings.TrimSpace(string(b)) return nil, err
}
provToken, err := readSecret("BOOTAPI_PROVISION_TOKEN")
if err != nil {
return nil, err
} }
fallback := getenv("BOOTAPI_UNKNOWN_MAC_FALLBACK", "local") fallback := getenv("BOOTAPI_UNKNOWN_MAC_FALLBACK", "local")
@@ -96,36 +136,63 @@ func Load() (*Config, error) {
return nil, fmt.Errorf("invalid BOOTAPI_UNKNOWN_MAC_FALLBACK %q: want \"local\" or \"shell\"", fallback) return nil, fmt.Errorf("invalid BOOTAPI_UNKNOWN_MAC_FALLBACK %q: want \"local\" or \"shell\"", fallback)
} }
rootHash := os.Getenv("BOOTAPI_ROOT_PASSWORD_HASH") baseURL := strings.TrimRight(os.Getenv("BOOTAPI_BASE_URL"), "/")
if rf := os.Getenv("BOOTAPI_ROOT_PASSWORD_HASH_FILE"); rf != "" { callbackBase := strings.TrimRight(os.Getenv("BOOTAPI_CALLBACK_BASE_URL"), "/")
b, err := os.ReadFile(rf) if callbackBase == "" {
if err != nil { callbackBase = baseURL
return nil, fmt.Errorf("read BOOTAPI_ROOT_PASSWORD_HASH_FILE %q: %w", rf, err) }
}
rootHash = strings.TrimSpace(string(b)) ns := splitList(os.Getenv("BOOTAPI_NAMESERVERS"))
if len(ns) == 0 {
ns = []string{"198.18.200.7"} // k8s bind-resolvers LB
} }
return &Config{ return &Config{
ListenAddr: getenv("BOOTAPI_LISTEN_ADDR", ":8000"), ListenAddr: getenv("BOOTAPI_LISTEN_ADDR", ":8000"),
NetBoxURL: strings.TrimRight(os.Getenv("BOOTAPI_NETBOX_URL"), "/"), TLSListenAddr: getenv("BOOTAPI_TLS_LISTEN_ADDR", ""),
NetBoxToken: token, TLSCertFile: os.Getenv("BOOTAPI_TLS_CERT_FILE"),
NetBoxTimeout: nbTimeout, TLSKeyFile: os.Getenv("BOOTAPI_TLS_KEY_FILE"),
NetBoxInsecure: getenv("BOOTAPI_NETBOX_INSECURE", "false") == "true", NetBoxURL: strings.TrimRight(os.Getenv("BOOTAPI_NETBOX_URL"), "/"),
CacheTTL: cacheTTL, NetBoxToken: token,
TemplateDir: os.Getenv("BOOTAPI_TEMPLATE_DIR"), NetBoxTimeout: nbTimeout,
DefaultTemplate: getenv("BOOTAPI_DEFAULT_TEMPLATE", "almalinux9"), NetBoxInsecure: getenv("BOOTAPI_NETBOX_INSECURE", "false") == "true",
BaseURL: strings.TrimRight(os.Getenv("BOOTAPI_BASE_URL"), "/"), CacheTTL: cacheTTL,
BootBaseURL: strings.TrimRight(os.Getenv("BOOTAPI_BOOT_BASE_URL"), "/"), TemplateDir: os.Getenv("BOOTAPI_TEMPLATE_DIR"),
PuppetServer: getenv("BOOTAPI_PUPPET_SERVER", "puppet.query.consul"), DefaultTemplate: getenv("BOOTAPI_DEFAULT_TEMPLATE", "almalinux9"),
PuppetCAServer: getenv("BOOTAPI_PUPPET_CA_SERVER", "puppetca.query.consul"), TemplateGitURL: strings.TrimRight(os.Getenv("BOOTAPI_TEMPLATE_GIT_URL"), "/"),
Domain: getenv("BOOTAPI_DOMAIN", "main.unkin.net"), TemplateGitBranch: getenv("BOOTAPI_TEMPLATE_GIT_BRANCH", "main"),
Nameservers: splitList(os.Getenv("BOOTAPI_NAMESERVERS")), TemplateGitInterval: gitInterval,
RootPasswordHash: rootHash, TemplateGitToken: os.Getenv("BOOTAPI_TEMPLATE_GIT_TOKEN"),
SSHAuthorizedKeys: splitLines(os.Getenv("BOOTAPI_SSH_AUTHORIZED_KEYS")), BaseURL: baseURL,
UnknownMACFallback: fallback, CallbackBaseURL: callbackBase,
ArtifactBaseURL: strings.TrimRight(getenv("BOOTAPI_ARTIFACT_BASE_URL", "https://artifactapi.k8s.syd1.au.unkin.net/api/v1/remote"), "/"),
BootBaseURL: strings.TrimRight(os.Getenv("BOOTAPI_BOOT_BASE_URL"), "/"),
ProvisionToken: provToken,
PuppetServer: getenv("BOOTAPI_PUPPET_SERVER", "puppet.k8s.syd1.au.unkin.net"),
PuppetCAServer: getenv("BOOTAPI_PUPPET_CA_SERVER", "puppetca.k8s.syd1.au.unkin.net"),
PuppetCAURL: getenv("BOOTAPI_PUPPET_CA_URL", "puppetca.k8s.syd1.au.unkin.net"),
Domain: getenv("BOOTAPI_DOMAIN", "main.unkin.net"),
Nameservers: ns,
RootPasswordHash: rootHash,
SSHAuthorizedKeys: splitLines(os.Getenv("BOOTAPI_SSH_AUTHORIZED_KEYS")),
UnknownMACFallback: fallback,
}, nil }, nil
} }
// readSecret returns the value of env key, or the trimmed contents of the file
// named by key+"_FILE" when that is set (the file wins).
func readSecret(key string) (string, error) {
v := os.Getenv(key)
if f := os.Getenv(key + "_FILE"); f != "" {
b, err := os.ReadFile(f)
if err != nil {
return "", fmt.Errorf("read %s_FILE %q: %w", key, f, err)
}
v = strings.TrimSpace(string(b))
}
return v, nil
}
func getenv(key, def string) string { func getenv(key, def string) string {
if v := os.Getenv(key); v != "" { if v := os.Getenv(key); v != "" {
return v return v
+42 -1
View File
@@ -22,12 +22,53 @@ func TestLoadDefaults(t *testing.T) {
if c.DefaultTemplate != "almalinux9" { if c.DefaultTemplate != "almalinux9" {
t.Errorf("DefaultTemplate = %q", c.DefaultTemplate) t.Errorf("DefaultTemplate = %q", c.DefaultTemplate)
} }
if c.PuppetServer != "puppet.query.consul" || c.PuppetCAServer != "puppetca.query.consul" { if c.PuppetServer != "puppet.k8s.syd1.au.unkin.net" || c.PuppetCAServer != "puppetca.k8s.syd1.au.unkin.net" {
t.Errorf("puppet servers = %q / %q", c.PuppetServer, c.PuppetCAServer) t.Errorf("puppet servers = %q / %q", c.PuppetServer, c.PuppetCAServer)
} }
if c.PuppetCAURL != "puppetca.k8s.syd1.au.unkin.net" {
t.Errorf("PuppetCAURL = %q", c.PuppetCAURL)
}
if c.UnknownMACFallback != "local" { if c.UnknownMACFallback != "local" {
t.Errorf("UnknownMACFallback = %q", c.UnknownMACFallback) t.Errorf("UnknownMACFallback = %q", c.UnknownMACFallback)
} }
if len(c.Nameservers) != 1 || c.Nameservers[0] != "198.18.200.7" {
t.Errorf("default nameservers = %v, want [198.18.200.7]", c.Nameservers)
}
if c.TemplateGitInterval != 3*time.Minute {
t.Errorf("TemplateGitInterval = %v, want 3m", c.TemplateGitInterval)
}
if c.ArtifactBaseURL != "https://artifactapi.k8s.syd1.au.unkin.net/api/v1/remote" {
t.Errorf("ArtifactBaseURL = %q", c.ArtifactBaseURL)
}
}
func TestCallbackBaseDefaultsToBase(t *testing.T) {
clearEnv(t)
t.Setenv("BOOTAPI_BASE_URL", "http://bootapi.example.net/")
c, err := Load()
if err != nil {
t.Fatal(err)
}
if c.BaseURL != "http://bootapi.example.net" || c.CallbackBaseURL != "http://bootapi.example.net" {
t.Errorf("base=%q callback=%q; callback should default to base", c.BaseURL, c.CallbackBaseURL)
}
}
func TestProvisionTokenFile(t *testing.T) {
clearEnv(t)
dir := t.TempDir()
tf := filepath.Join(dir, "tok")
if err := os.WriteFile(tf, []byte(" prov-secret\n"), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("BOOTAPI_PROVISION_TOKEN_FILE", tf)
c, err := Load()
if err != nil {
t.Fatal(err)
}
if c.ProvisionToken != "prov-secret" {
t.Errorf("ProvisionToken = %q", c.ProvisionToken)
}
} }
func TestLoadTokenFile(t *testing.T) { func TestLoadTokenFile(t *testing.T) {
+178
View File
@@ -0,0 +1,178 @@
// Package gitsync keeps bootapi's template Set in step with a git repo. It
// clones the templates repo at startup and re-pulls it every interval (default
// 3m, like argocd), atomically swapping the Engine's active Set when the repo
// changes. A parse failure keeps the last-good Set and is only logged/counted,
// so a bad template push can never take bootapi down. The embedded defaults
// remain the fallback when git is unreachable at startup.
package gitsync
import (
"context"
"fmt"
"io/fs"
"log/slog"
"os"
"os/exec"
"strings"
"sync/atomic"
"time"
"git.unkin.net/unkin/bootapi/internal/render"
)
// Options configures the syncer.
type Options struct {
URL string
Branch string
Token string // optional; injected into the HTTPS URL for a private repo
Interval time.Duration
WorkDir string // local checkout path
}
// Syncer pulls a templates repo and reloads an Engine on change.
type Syncer struct {
opt Options
embedded fs.FS
engine *render.Engine
syncs atomic.Int64 // successful reloads (Set swapped)
failures atomic.Int64 // pull or parse failures (last-good kept)
generation atomic.Int64 // increments on every successful swap
}
// New builds a Syncer. embedded is the fallback template FS. Call SetEngine
// before Run so reloads have an Engine to swap into (the Engine needs the
// initial Set from Bootstrap first, hence the two-step wiring).
func New(opt Options, embedded fs.FS) *Syncer {
if opt.Branch == "" {
opt.Branch = "main"
}
if opt.Interval <= 0 {
opt.Interval = 3 * time.Minute
}
return &Syncer{opt: opt, embedded: embedded}
}
// SetEngine points the syncer at the live Engine whose Set it swaps on reload.
func (s *Syncer) SetEngine(e *render.Engine) { s.engine = e }
// Syncs/Failures/Generation are exported for the server's metrics collector.
func (s *Syncer) Syncs() int64 { return s.syncs.Load() }
func (s *Syncer) Failures() int64 { return s.failures.Load() }
func (s *Syncer) Generation() int64 { return s.generation.Load() }
// Bootstrap clones the repo and builds the initial Set from embedded + the
// checkout. On any git/parse failure it returns an embedded-only Set plus a
// non-nil error (which the caller logs but treats as non-fatal, so bootapi
// always starts with at least the embedded defaults).
func (s *Syncer) Bootstrap(ctx context.Context) (*render.Set, error) {
if err := s.clone(ctx); err != nil {
set, berr := render.BuildSet(s.embedded, nil)
if berr != nil {
return nil, berr // embedded defaults broken: genuinely fatal
}
return set, fmt.Errorf("git clone failed, using embedded defaults: %w", err)
}
set, err := render.BuildSet(s.embedded, os.DirFS(s.opt.WorkDir))
if err != nil {
emb, berr := render.BuildSet(s.embedded, nil)
if berr != nil {
return nil, berr
}
return emb, fmt.Errorf("git templates failed to parse, using embedded defaults: %w", err)
}
s.generation.Add(1)
return set, nil
}
// Run polls the repo every interval until ctx is cancelled.
func (s *Syncer) Run(ctx context.Context) {
t := time.NewTicker(s.opt.Interval)
defer t.Stop()
slog.Info("template git-sync started", "url", s.opt.URL, "branch", s.opt.Branch, "interval", s.opt.Interval)
for {
select {
case <-ctx.Done():
return
case <-t.C:
s.pollOnce(ctx)
}
}
}
func (s *Syncer) pollOnce(ctx context.Context) {
changed, head, err := s.pull(ctx)
if err != nil {
s.failures.Add(1)
slog.Error("template git pull failed; keeping last-good set", "err", err)
return
}
if !changed {
return
}
set, err := render.BuildSet(s.embedded, os.DirFS(s.opt.WorkDir))
if err != nil {
s.failures.Add(1)
slog.Error("template reload failed to parse; keeping last-good set", "commit", head, "err", err)
return
}
s.engine.Swap(set)
s.syncs.Add(1)
s.generation.Add(1)
slog.Info("templates reloaded from git", "commit", head, "generation", s.generation.Load())
}
// authURL injects a token into the HTTPS clone URL when configured.
func (s *Syncer) authURL() string {
if s.opt.Token == "" {
return s.opt.URL
}
if rest, ok := strings.CutPrefix(s.opt.URL, "https://"); ok {
return "https://" + s.opt.Token + "@" + rest
}
return s.opt.URL
}
func (s *Syncer) clone(ctx context.Context) error {
if err := os.RemoveAll(s.opt.WorkDir); err != nil {
return err
}
return run(ctx, "", "git", "clone", "--depth", "1", "--branch", s.opt.Branch, s.authURL(), s.opt.WorkDir)
}
// pull fetches origin/branch and hard-resets to it, reporting whether HEAD moved.
func (s *Syncer) pull(ctx context.Context) (changed bool, head string, err error) {
old, _ := s.head(ctx)
if err := run(ctx, s.opt.WorkDir, "git", "fetch", "--depth", "1", "origin", s.opt.Branch); err != nil {
return false, "", err
}
if err := run(ctx, s.opt.WorkDir, "git", "reset", "--hard", "origin/"+s.opt.Branch); err != nil {
return false, "", err
}
newHead, err := s.head(ctx)
if err != nil {
return false, "", err
}
return old != newHead, newHead, nil
}
func (s *Syncer) head(ctx context.Context) (string, error) {
out, err := output(ctx, s.opt.WorkDir, "git", "rev-parse", "HEAD")
return strings.TrimSpace(out), err
}
func run(ctx context.Context, dir, name string, args ...string) error {
cmd := exec.CommandContext(ctx, name, args...)
cmd.Dir = dir
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("%s %s: %w: %s", name, strings.Join(args, " "), err, strings.TrimSpace(string(out)))
}
return nil
}
func output(ctx context.Context, dir, name string, args ...string) (string, error) {
cmd := exec.CommandContext(ctx, name, args...)
cmd.Dir = dir
out, err := cmd.Output()
return string(out), err
}
+142
View File
@@ -0,0 +1,142 @@
package gitsync
import (
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"git.unkin.net/unkin/bootapi/internal/model"
"git.unkin.net/unkin/bootapi/internal/render"
"git.unkin.net/unkin/bootapi/templates"
)
// gitRepo creates a real git repo at dir with an initial almalinux9 override.
func gitRepo(t *testing.T, dir string) {
t.Helper()
gitCmd(t, "", "git", "init", "-b", "main", dir)
gitCmd(t, dir, "git", "config", "user.email", "t@example.net")
gitCmd(t, dir, "git", "config", "user.name", "test")
writeKS(t, dir, "GITSYNC-V1 {{ .Hostname }}\n")
gitCmd(t, dir, "git", "add", "-A")
gitCmd(t, dir, "git", "commit", "-m", "v1")
}
func writeKS(t *testing.T, dir, body string) {
t.Helper()
if err := os.WriteFile(filepath.Join(dir, "almalinux9.ks.tmpl"), []byte(body), 0o600); err != nil {
t.Fatal(err)
}
}
func gitCmd(t *testing.T, dir, name string, args ...string) {
t.Helper()
cmd := exec.Command(name, args...)
cmd.Dir = dir
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("%s %v: %v: %s", name, args, err, out)
}
}
func renderKS(t *testing.T, e *render.Engine) string {
t.Helper()
h := &model.Host{Hostname: "web01", Platform: "almalinux9", OSFamily: "almalinux", OSVersion: "9", Arch: "x86_64"}
out, _, err := e.RenderKickstart(h)
if err != nil {
t.Fatalf("RenderKickstart: %v", err)
}
return string(out)
}
func TestBootstrapAndReload(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not available")
}
src := t.TempDir()
gitRepo(t, src)
s := New(Options{URL: src, Branch: "main", Interval: time.Hour, WorkDir: filepath.Join(t.TempDir(), "co")}, templates.FS)
set, err := s.Bootstrap(context.Background())
if err != nil {
t.Fatalf("Bootstrap: %v", err)
}
eng := render.NewEngine(render.RenderConfig{DefaultTemplate: "almalinux9", ArtifactBase: "https://af"}, set)
s.SetEngine(eng)
if got := renderKS(t, eng); !contains(got, "GITSYNC-V1 web01") {
t.Fatalf("initial render missing v1 override:\n%s", got)
}
gen1 := s.Generation()
// Commit v2 upstream, then poll: the engine must swap to the new content.
writeKS(t, src, "GITSYNC-V2 {{ .Hostname }}\n")
gitCmd(t, src, "git", "add", "-A")
gitCmd(t, src, "git", "commit", "-m", "v2")
s.pollOnce(context.Background())
if got := renderKS(t, eng); !contains(got, "GITSYNC-V2 web01") {
t.Fatalf("after reload, render missing v2:\n%s", got)
}
if s.Generation() <= gen1 {
t.Errorf("generation did not advance: %d <= %d", s.Generation(), gen1)
}
if s.Syncs() != 1 {
t.Errorf("syncs = %d, want 1", s.Syncs())
}
}
func TestReloadKeepsLastGoodOnParseError(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not available")
}
src := t.TempDir()
gitRepo(t, src)
s := New(Options{URL: src, Branch: "main", Interval: time.Hour, WorkDir: filepath.Join(t.TempDir(), "co")}, templates.FS)
set, err := s.Bootstrap(context.Background())
if err != nil {
t.Fatal(err)
}
eng := render.NewEngine(render.RenderConfig{DefaultTemplate: "almalinux9", ArtifactBase: "https://af"}, set)
s.SetEngine(eng)
// Push a template that fails to parse.
writeKS(t, src, "BROKEN {{ .Hostname \n")
gitCmd(t, src, "git", "add", "-A")
gitCmd(t, src, "git", "commit", "-m", "broken")
s.pollOnce(context.Background())
// The last-good v1 set must still be served, and a failure recorded.
if got := renderKS(t, eng); !contains(got, "GITSYNC-V1 web01") {
t.Fatalf("last-good not kept after parse failure:\n%s", got)
}
if s.Failures() != 1 {
t.Errorf("failures = %d, want 1", s.Failures())
}
if s.Syncs() != 0 {
t.Errorf("syncs = %d, want 0 (bad push must not count as a sync)", s.Syncs())
}
}
func TestBootstrapDegradesToEmbedded(t *testing.T) {
// A bogus URL must not fail startup: Bootstrap returns the embedded set.
s := New(Options{URL: "/nonexistent/repo", Branch: "main", Interval: time.Hour, WorkDir: filepath.Join(t.TempDir(), "co")}, templates.FS)
set, err := s.Bootstrap(context.Background())
if err == nil {
t.Error("expected a non-nil (non-fatal) error describing the degrade")
}
if set == nil {
t.Fatal("expected the embedded fallback Set, got nil")
}
eng := render.NewEngine(render.RenderConfig{DefaultTemplate: "almalinux9", ArtifactBase: "https://af"}, set)
// Embedded almalinux9 template still renders.
if got := renderKS(t, eng); !contains(got, "rootpw") {
t.Errorf("embedded fallback did not render a real kickstart:\n%s", got)
}
}
func contains(s, sub string) bool { return strings.Contains(s, sub) }
+19
View File
@@ -10,6 +10,10 @@ package model
// zero value of a field means "NetBox did not provide it"; templates should // zero value of a field means "NetBox did not provide it"; templates should
// guard optional fields (e.g. Gateway) accordingly. // guard optional fields (e.g. Gateway) accordingly.
type Host struct { type Host struct {
// DeviceID is the NetBox device id, used by the provisioned-callback to
// PATCH the pxe_enabled custom field.
DeviceID int
// Hostname is the short name (NetBox device name), e.g. "web01". // Hostname is the short name (NetBox device name), e.g. "web01".
Hostname string Hostname string
// Domain is the DNS domain the host lives in, e.g. "syd1.au.unkin.net". // Domain is the DNS domain the host lives in, e.g. "syd1.au.unkin.net".
@@ -57,12 +61,27 @@ type Host struct {
// bypassing platform/role selection. Sourced from a NetBox custom field. // bypassing platform/role selection. Sourced from a NetBox custom field.
TemplateOverride string TemplateOverride string
// PXEEnabled gates network install for this host, mirroring Cobbler's
// netboot_enabled. When false, bootapi serves the safe local-boot script
// from /ipxe even for a KNOWN host, so a provisioned machine does not
// re-install on its next PXE. nil means the NetBox custom field is unset,
// which is treated as ENABLED (a host without the field still installs).
// The end-of-kickstart callback (POST /provisioned) flips this to false.
PXEEnabled *bool
// Custom carries every NetBox custom field verbatim so templates can read // Custom carries every NetBox custom field verbatim so templates can read
// site-specific knobs without a code change. Keys are the custom-field // site-specific knobs without a code change. Keys are the custom-field
// names as defined in NetBox. // names as defined in NetBox.
Custom map[string]any Custom map[string]any
} }
// ShouldPXEInstall reports whether bootapi should serve an installer boot script
// for this host. Unset (nil) is treated as enabled so hosts predating the
// custom field still provision.
func (h *Host) ShouldPXEInstall() bool {
return h.PXEEnabled == nil || *h.PXEEnabled
}
// Interface is one network interface of a Host. // Interface is one network interface of a Host.
type Interface struct { type Interface struct {
// Name is the NetBox interface name, e.g. "eth0" / "bond0". // Name is the NetBox interface name, e.g. "eth0" / "bond0".
+15 -2
View File
@@ -15,7 +15,7 @@ import (
// while keeping the data fresh enough that a re-provisioned host picks up // while keeping the data fresh enough that a re-provisioned host picks up
// changes on its next boot. // changes on its next boot.
type Cache struct { type Cache struct {
inner Resolver inner API
ttl time.Duration ttl time.Duration
now func() time.Time // injectable for tests now func() time.Time // injectable for tests
@@ -38,7 +38,7 @@ type cacheEntry struct {
} }
// NewCache wraps inner with a TTL cache. A non-positive ttl disables caching. // NewCache wraps inner with a TTL cache. A non-positive ttl disables caching.
func NewCache(inner Resolver, ttl time.Duration) *Cache { func NewCache(inner API, ttl time.Duration) *Cache {
return &Cache{ return &Cache{
inner: inner, inner: inner,
ttl: ttl, ttl: ttl,
@@ -47,6 +47,19 @@ func NewCache(inner Resolver, ttl time.Duration) *Cache {
} }
} }
// SetPXEEnabled writes through to NetBox and drops the whole cache, so the next
// /ipxe lookup reflects the flipped gate immediately rather than serving a
// stale "enabled" host for up to the TTL.
func (c *Cache) SetPXEEnabled(ctx context.Context, deviceID int, enabled bool) error {
if err := c.inner.SetPXEEnabled(ctx, deviceID, enabled); err != nil {
return err
}
c.mu.Lock()
clear(c.entries)
c.mu.Unlock()
return nil
}
// HostByMAC returns a cached host or resolves and caches one. // HostByMAC returns a cached host or resolves and caches one.
func (c *Cache) HostByMAC(ctx context.Context, mac string) (*model.Host, error) { func (c *Cache) HostByMAC(ctx context.Context, mac string) (*model.Host, error) {
return c.lookup(ctx, "mac:"+normalizeMAC(mac), func() (*model.Host, error) { return c.lookup(ctx, "mac:"+normalizeMAC(mac), func() (*model.Host, error) {
+34 -4
View File
@@ -12,10 +12,11 @@ import (
// countingResolver records how many times the underlying resolver is hit. // countingResolver records how many times the underlying resolver is hit.
type countingResolver struct { type countingResolver struct {
mu sync.Mutex mu sync.Mutex
calls int calls int
host *model.Host writes int
err error host *model.Host
err error
} }
func (c *countingResolver) HostByMAC(context.Context, string) (*model.Host, error) { func (c *countingResolver) HostByMAC(context.Context, string) (*model.Host, error) {
@@ -27,6 +28,12 @@ func (c *countingResolver) HostByMAC(context.Context, string) (*model.Host, erro
func (c *countingResolver) HostByName(context.Context, string) (*model.Host, error) { func (c *countingResolver) HostByName(context.Context, string) (*model.Host, error) {
return c.HostByMAC(context.Background(), "") return c.HostByMAC(context.Background(), "")
} }
func (c *countingResolver) SetPXEEnabled(context.Context, int, bool) error {
c.mu.Lock()
defer c.mu.Unlock()
c.writes++
return c.err
}
func TestCacheHitAndExpiry(t *testing.T) { func TestCacheHitAndExpiry(t *testing.T) {
inner := &countingResolver{host: &model.Host{Hostname: "web01"}} inner := &countingResolver{host: &model.Host{Hostname: "web01"}}
@@ -60,6 +67,29 @@ func TestCacheHitAndExpiry(t *testing.T) {
} }
} }
func TestCacheInvalidatedOnWrite(t *testing.T) {
inner := &countingResolver{host: &model.Host{Hostname: "web01"}}
cache := NewCache(inner, time.Minute)
// Warm the cache.
if _, err := cache.HostByMAC(context.Background(), "aa:bb:cc:00:11:22"); err != nil {
t.Fatal(err)
}
// A write must drop the cache so the next read re-resolves.
if err := cache.SetPXEEnabled(context.Background(), 12, false); err != nil {
t.Fatal(err)
}
if inner.writes != 1 {
t.Fatalf("inner writes = %d, want 1", inner.writes)
}
if _, err := cache.HostByMAC(context.Background(), "aa:bb:cc:00:11:22"); err != nil {
t.Fatal(err)
}
if inner.calls != 2 {
t.Fatalf("inner calls = %d, want 2 (cache dropped by write)", inner.calls)
}
}
func TestCacheDisabled(t *testing.T) { func TestCacheDisabled(t *testing.T) {
inner := &countingResolver{host: &model.Host{Hostname: "web01"}} inner := &countingResolver{host: &model.Host{Hostname: "web01"}}
cache := NewCache(inner, 0) // ttl <= 0 disables caching cache := NewCache(inner, 0) // ttl <= 0 disables caching
+71
View File
@@ -5,6 +5,7 @@
package netbox package netbox
import ( import (
"bytes"
"context" "context"
"crypto/tls" "crypto/tls"
"encoding/json" "encoding/json"
@@ -29,6 +30,19 @@ type Resolver interface {
HostByName(ctx context.Context, name string) (*model.Host, error) HostByName(ctx context.Context, name string) (*model.Host, error)
} }
// Writer mutates NetBox. Today it only flips the pxe_enabled gate (the
// end-of-kickstart callback). Kept separate from Resolver so read-only callers
// need not depend on write scope.
type Writer interface {
SetPXEEnabled(ctx context.Context, deviceID int, enabled bool) error
}
// API is the full NetBox surface bootapi uses (reads + the pxe_enabled write).
type API interface {
Resolver
Writer
}
// Client is the HTTP-backed Resolver. // Client is the HTTP-backed Resolver.
type Client struct { type Client struct {
baseURL string baseURL string
@@ -199,11 +213,13 @@ func buildHost(dev *nbDevice, ifaces []nbInterface, ips []nbIPAddress) *model.Ho
domain := cfString(cf, "domain") domain := cfString(cf, "domain")
h := &model.Host{ h := &model.Host{
DeviceID: dev.ID,
Hostname: dev.Name, Hostname: dev.Name,
Domain: domain, Domain: domain,
Custom: cf, Custom: cf,
Nameservers: cfStringList(cf, "nameservers"), Nameservers: cfStringList(cf, "nameservers"),
TemplateOverride: cfString(cf, "provision_template"), TemplateOverride: cfString(cf, "provision_template"),
PXEEnabled: cfBool(cf, "pxe_enabled"),
Arch: "x86_64", Arch: "x86_64",
} }
if dev.Platform != nil { if dev.Platform != nil {
@@ -278,6 +294,39 @@ func sortPrimaryFirst(ifaces []model.Interface) {
} }
} }
// SetPXEEnabled PATCHes the device's pxe_enabled custom field. This is the only
// write bootapi performs; the NetBox token therefore needs write scope on the
// device custom field (see docs/security.md).
func (c *Client) SetPXEEnabled(ctx context.Context, deviceID int, enabled bool) error {
body := map[string]any{"custom_fields": map[string]any{"pxe_enabled": enabled}}
b, err := json.Marshal(body)
if err != nil {
return err
}
path := fmt.Sprintf("/api/dcim/devices/%d/", deviceID)
req, err := http.NewRequestWithContext(ctx, http.MethodPatch, c.baseURL+path, bytes.NewReader(b))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
if c.token != "" {
req.Header.Set("Authorization", "Token "+c.token)
}
resp, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("netbox patch device %d: %w", deviceID, err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode == http.StatusNotFound {
return ErrNotFound
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("netbox patch device %d: HTTP %d", deviceID, resp.StatusCode)
}
return nil
}
// get performs a GET against the NetBox API and decodes the JSON body into out. // get performs a GET against the NetBox API and decodes the JSON body into out.
func (c *Client) get(ctx context.Context, path string, q url.Values, out any) error { func (c *Client) get(ctx context.Context, path string, q url.Values, out any) error {
u := c.baseURL + path u := c.baseURL + path
@@ -393,6 +442,28 @@ func cfString(cf map[string]any, key string) string {
return "" return ""
} }
// cfBool reads a boolean custom field. Returns nil when the field is absent or
// null so callers can distinguish "unset" from "false".
func cfBool(cf map[string]any, key string) *bool {
if cf == nil {
return nil
}
switch v := cf[key].(type) {
case bool:
return &v
case string: // tolerate "true"/"false" string encodings
switch strings.ToLower(v) {
case "true", "1", "yes":
b := true
return &b
case "false", "0", "no":
b := false
return &b
}
}
return nil
}
func cfStringList(cf map[string]any, key string) []string { func cfStringList(cf map[string]any, key string) []string {
if cf == nil { if cf == nil {
return nil return nil
+55 -2
View File
@@ -6,9 +6,13 @@ import (
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings" "strings"
"sync/atomic"
"testing" "testing"
) )
// patchedDevice records whether the fake NetBox saw a PATCH on device 12.
var patchedDevice atomic.Bool
// fakeNetBox serves canned NetBox v4.x JSON for the endpoints bootapi calls. // fakeNetBox serves canned NetBox v4.x JSON for the endpoints bootapi calls.
// The payloads are trimmed but structurally faithful to real API responses. // The payloads are trimmed but structurally faithful to real API responses.
func fakeNetBox(t *testing.T) *httptest.Server { func fakeNetBox(t *testing.T) *httptest.Server {
@@ -38,8 +42,13 @@ func fakeNetBox(t *testing.T) *httptest.Server {
} }
}) })
// Device detail. // Device detail (GET) + pxe_enabled write (PATCH).
mux.HandleFunc("/api/dcim/devices/", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/api/dcim/devices/", func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPatch && strings.HasSuffix(r.URL.Path, "/12/") {
patchedDevice.Store(true)
writeJSON(w, `{"id":12,"name":"web01"}`)
return
}
if strings.HasSuffix(r.URL.Path, "/12/") { if strings.HasSuffix(r.URL.Path, "/12/") {
writeJSON(w, `{ writeJSON(w, `{
"id":12,"name":"web01", "id":12,"name":"web01",
@@ -47,7 +56,7 @@ func fakeNetBox(t *testing.T) *httptest.Server {
"role":{"id":2,"name":"K8s Worker","slug":"kubernetes-worker"}, "role":{"id":2,"name":"K8s Worker","slug":"kubernetes-worker"},
"site":{"slug":"syd1"}, "site":{"slug":"syd1"},
"primary_ip":{"address":"10.0.1.20/24"}, "primary_ip":{"address":"10.0.1.20/24"},
"custom_fields":{"domain":"syd1.au.unkin.net","gateway":"10.0.1.254","nameservers":"10.0.0.1,10.0.0.2","provision_template":null}}`) "custom_fields":{"domain":"syd1.au.unkin.net","gateway":"10.0.1.254","nameservers":"10.0.0.1,10.0.0.2","provision_template":null,"pxe_enabled":true}}`)
return return
} }
// name= query (HostByName) // name= query (HostByName)
@@ -102,6 +111,12 @@ func TestHostByMAC(t *testing.T) {
if h.PrimaryIP != "10.0.1.20" { if h.PrimaryIP != "10.0.1.20" {
t.Errorf("primaryIP = %q", h.PrimaryIP) t.Errorf("primaryIP = %q", h.PrimaryIP)
} }
if h.DeviceID != 12 {
t.Errorf("deviceID = %d, want 12", h.DeviceID)
}
if h.PXEEnabled == nil || !*h.PXEEnabled || !h.ShouldPXEInstall() {
t.Errorf("pxe_enabled = %v, want true", h.PXEEnabled)
}
if len(h.Nameservers) != 2 || h.Nameservers[0] != "10.0.0.1" { if len(h.Nameservers) != 2 || h.Nameservers[0] != "10.0.0.1" {
t.Errorf("nameservers = %v", h.Nameservers) t.Errorf("nameservers = %v", h.Nameservers)
} }
@@ -181,6 +196,44 @@ func TestAuthTokenRequired(t *testing.T) {
} }
} }
func TestSetPXEEnabled(t *testing.T) {
srv := fakeNetBox(t)
defer srv.Close()
patchedDevice.Store(false)
c := newTestClient(t, srv.URL)
if err := c.SetPXEEnabled(context.Background(), 12, false); err != nil {
t.Fatalf("SetPXEEnabled: %v", err)
}
if !patchedDevice.Load() {
t.Error("expected a PATCH to device 12, got none")
}
}
func TestCfBool(t *testing.T) {
tr := true
cases := []struct {
cf map[string]any
want *bool
}{
{map[string]any{"pxe_enabled": true}, &tr},
{map[string]any{"pxe_enabled": "false"}, boolp(false)},
{map[string]any{"pxe_enabled": nil}, nil},
{map[string]any{}, nil},
}
for _, c := range cases {
got := cfBool(c.cf, "pxe_enabled")
switch {
case got == nil && c.want == nil:
case got != nil && c.want != nil && *got == *c.want:
default:
t.Errorf("cfBool(%v) = %v, want %v", c.cf, got, c.want)
}
}
}
func boolp(b bool) *bool { return &b }
func TestNormalizeMAC(t *testing.T) { func TestNormalizeMAC(t *testing.T) {
cases := map[string]string{ cases := map[string]string{
"AA:BB:CC:00:11:22": "aa:bb:cc:00:11:22", "AA:BB:CC:00:11:22": "aa:bb:cc:00:11:22",
+214 -57
View File
@@ -1,18 +1,20 @@
// Package render turns a resolved model.Host into a kickstart file or an iPXE // Package render turns a resolved model.Host into a kickstart file or an iPXE
// boot script using Go text/template. Templates come from an embedded default // boot script using Go text/template. Templates come from an embedded default
// set (ported from Cobbler's kickstarts) optionally layered with an override // set, optionally overlaid with an override source (a ConfigMap directory or a
// directory (a Kubernetes ConfigMap mount in production). // git-synced templates repo). The active template Set is swappable at runtime so
// the git-sync loop can atomically reload without dropping requests.
package render package render
import ( import (
"bytes" "bytes"
"fmt" "fmt"
"io/fs" "io/fs"
"os"
"path/filepath" "path/filepath"
"strings" "strings"
"sync/atomic"
"text/template" "text/template"
"git.unkin.net/unkin/bootapi/internal/catalog"
"git.unkin.net/unkin/bootapi/internal/model" "git.unkin.net/unkin/bootapi/internal/model"
) )
@@ -43,9 +45,17 @@ type Data struct {
// --- infra pointers (render-time config) --- // --- infra pointers (render-time config) ---
PuppetServer string PuppetServer string
PuppetCAServer string PuppetCAServer string
BaseURL string // bootapi's own base URL PuppetCAURL string // written to the puppet-initial PUPPETCA_URL env file
BootBaseURL string // OS install-tree base URL BaseURL string // bootapi's own (http) base URL
BootBaseURL string // legacy OS install-tree base (empty when catalog-driven)
KickstartURL string // absolute URL a booting host fetches its KS from KickstartURL string // absolute URL a booting host fetches its KS from
CallbackURL string // absolute URL the %post posts to when install finishes
ProvisionToken string // bearer token for the callback (embedded in the KS)
// --- distro catalog ---
// DistroVars are the selected catalog entry's evaluated vars (e.g. mirror
// base). Empty when no catalog entry matched.
DistroVars map[string]string
// --- escape hatch: every NetBox custom field, verbatim --- // --- escape hatch: every NetBox custom field, verbatim ---
Custom map[string]any Custom map[string]any
@@ -55,8 +65,12 @@ type Data struct {
type RenderConfig struct { type RenderConfig struct {
PuppetServer string PuppetServer string
PuppetCAServer string PuppetCAServer string
PuppetCAURL string
BaseURL string BaseURL string
CallbackBaseURL string
ArtifactBase string
BootBaseURL string BootBaseURL string
ProvisionToken string
DefaultDomain string DefaultDomain string
DefaultNS []string DefaultNS []string
RootPasswordHash string RootPasswordHash string
@@ -64,50 +78,55 @@ type RenderConfig struct {
DefaultTemplate string DefaultTemplate string
} }
// Engine holds parsed templates and render-time defaults.
type Engine struct {
ks *template.Template // kickstart templates, named "<key>"
ipxe *template.Template // ipxe templates, named "<key>"
cfg RenderConfig
ksSet map[string]bool // which kickstart template names exist
}
const ( const (
ksExt = ".ks.tmpl" ksExt = ".ks.tmpl"
ipxeExt = ".ipxe.tmpl" ipxeExt = ".ipxe.tmpl"
) )
// NewEngine parses the embedded defaults, then overlays overrideDir when // Set is an immutable, parsed collection of templates + the distro catalog.
// non-empty (files there win over embedded ones of the same name). type Set struct {
func NewEngine(embedded fs.FS, overrideDir string, cfg RenderConfig) (*Engine, error) { ks *template.Template
ipxe *template.Template
ksSet map[string]bool
cat *catalog.Catalog
}
// BuildSet parses the embedded default sources, then overlays override (a
// directory or git working tree) when non-nil, with override files winning by
// base name. It parses *.ks.tmpl, *.ipxe.tmpl and catalog/*.yaml.
func BuildSet(embedded fs.FS, override fs.FS) (*Set, error) {
funcs := funcMap() funcs := funcMap()
ks := template.New("kickstart").Funcs(funcs) ks := template.New("kickstart").Funcs(funcs)
ipxe := template.New("ipxe").Funcs(funcs) ipxe := template.New("ipxe").Funcs(funcs)
set := map[string]bool{} ksNames := map[string]bool{}
catFiles := map[string][]byte{}
if err := parseTree(ks, ipxe, set, embedded, ".", true); err != nil { if err := walkSet(embedded, ks, ipxe, ksNames, catFiles, true); err != nil {
return nil, fmt.Errorf("parse embedded templates: %w", err) return nil, fmt.Errorf("parse embedded templates: %w", err)
} }
if overrideDir != "" { if override != nil {
if err := parseTree(ks, ipxe, set, os.DirFS(overrideDir), ".", false); err != nil { if err := walkSet(override, ks, ipxe, ksNames, catFiles, false); err != nil {
return nil, fmt.Errorf("parse override templates in %q: %w", overrideDir, err) return nil, fmt.Errorf("parse override templates: %w", err)
} }
} }
return &Engine{ks: ks, ipxe: ipxe, cfg: cfg, ksSet: set}, nil cat, err := catalog.Parse(catFiles)
if err != nil {
return nil, err
}
return &Set{ks: ks, ipxe: ipxe, ksSet: ksNames, cat: cat}, nil
} }
// parseTree walks fsys under root, registering *.ks.tmpl into ks and // walkSet walks fsys registering templates and collecting catalog YAML.
// *.ipxe.tmpl into ipxe under their base name (extension stripped). func walkSet(fsys fs.FS, ks, ipxe *template.Template, ksNames map[string]bool, catFiles map[string][]byte, mustExist bool) error {
func parseTree(ks, ipxe *template.Template, set map[string]bool, fsys fs.FS, root string, mustExist bool) error {
walked := false walked := false
err := fs.WalkDir(fsys, root, func(path string, d fs.DirEntry, err error) error { err := fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil { if err != nil {
return err return err
} }
walked = true
if d.IsDir() { if d.IsDir() {
return nil return nil
} }
walked = true
b, err := fs.ReadFile(fsys, path) b, err := fs.ReadFile(fsys, path)
if err != nil { if err != nil {
return err return err
@@ -119,12 +138,14 @@ func parseTree(ks, ipxe *template.Template, set map[string]bool, fsys fs.FS, roo
if _, err := ks.New(name).Parse(string(b)); err != nil { if _, err := ks.New(name).Parse(string(b)); err != nil {
return fmt.Errorf("%s: %w", path, err) return fmt.Errorf("%s: %w", path, err)
} }
set[name] = true ksNames[name] = true
case strings.HasSuffix(base, ipxeExt): case strings.HasSuffix(base, ipxeExt):
name := strings.TrimSuffix(base, ipxeExt) name := strings.TrimSuffix(base, ipxeExt)
if _, err := ipxe.New(name).Parse(string(b)); err != nil { if _, err := ipxe.New(name).Parse(string(b)); err != nil {
return fmt.Errorf("%s: %w", path, err) return fmt.Errorf("%s: %w", path, err)
} }
case (strings.HasSuffix(base, ".yaml") || strings.HasSuffix(base, ".yml")) && strings.Contains(path, "catalog"):
catFiles[base] = b
} }
return nil return nil
}) })
@@ -132,25 +153,59 @@ func parseTree(ks, ipxe *template.Template, set map[string]bool, fsys fs.FS, roo
return err return err
} }
if mustExist && !walked { if mustExist && !walked {
return fmt.Errorf("no templates found under %q", root) return fmt.Errorf("no templates found")
} }
return nil return nil
} }
// SelectKickstart returns the template name chosen for host, following the // Engine holds render-time config and the current (swappable) template Set.
// documented precedence: custom-field override → platform slug → OS family → type Engine struct {
// configured default. It reports whether a concrete template was found. cfg RenderConfig
cur atomic.Pointer[Set]
}
// NewEngine builds an Engine over an initial Set.
func NewEngine(cfg RenderConfig, initial *Set) *Engine {
e := &Engine{cfg: cfg}
e.cur.Store(initial)
return e
}
// Swap atomically replaces the active template Set (used by git-sync on reload).
func (e *Engine) Swap(s *Set) { e.cur.Store(s) }
// Current returns the active Set.
func (e *Engine) Current() *Set { return e.cur.Load() }
// resolve returns the catalog entry for host (evaluated against artifactBase),
// or nil when no catalog entry matches.
func (e *Engine) resolve(set *Set, h *model.Host) (*catalog.Resolved, error) {
d, ok := set.cat.Select(h)
if !ok {
return nil, nil
}
return d.Resolve(h, e.cfg.ArtifactBase)
}
// SelectKickstart returns the kickstart template name for host: the catalog
// entry's kickstart if one matches, else the legacy precedence
// (override → platform → family → default). Reports whether it exists.
func (e *Engine) SelectKickstart(h *model.Host) (string, bool) { func (e *Engine) SelectKickstart(h *model.Host) (string, bool) {
set := e.cur.Load()
if d, ok := set.cat.Select(h); ok && set.ksSet[d.Kickstart] {
return d.Kickstart, true
}
for _, cand := range []string{h.TemplateOverride, h.Platform, h.OSFamily, e.cfg.DefaultTemplate} { for _, cand := range []string{h.TemplateOverride, h.Platform, h.OSFamily, e.cfg.DefaultTemplate} {
if cand != "" && e.ksSet[cand] { if cand != "" && set.ksSet[cand] {
return cand, true return cand, true
} }
} }
return e.cfg.DefaultTemplate, e.ksSet[e.cfg.DefaultTemplate] return e.cfg.DefaultTemplate, set.ksSet[e.cfg.DefaultTemplate]
} }
// dataFor builds the flat Data view for a host, merging render-time config. // dataFor builds the flat Data view for a host, merging render-time config and
func (e *Engine) dataFor(h *model.Host) Data { // the selected catalog entry's vars.
func (e *Engine) dataFor(h *model.Host, vars map[string]string) Data {
ns := h.Nameservers ns := h.Nameservers
if len(ns) == 0 { if len(ns) == 0 {
ns = e.cfg.DefaultNS ns = e.cfg.DefaultNS
@@ -173,7 +228,11 @@ func (e *Engine) dataFor(h *model.Host) Data {
} }
ksURL := "" ksURL := ""
if e.cfg.BaseURL != "" { if e.cfg.BaseURL != "" {
ksURL = strings.TrimRight(e.cfg.BaseURL, "/") + "/ks/" + h.Hostname ksURL = e.cfg.BaseURL + "/ks/" + h.Hostname
}
cbURL := ""
if e.cfg.CallbackBaseURL != "" {
cbURL = e.cfg.CallbackBaseURL + "/provisioned/" + h.Hostname
} }
return Data{ return Data{
Hostname: h.Hostname, Hostname: h.Hostname,
@@ -192,9 +251,13 @@ func (e *Engine) dataFor(h *model.Host) Data {
SSHAuthorizedKeys: keys, SSHAuthorizedKeys: keys,
PuppetServer: e.cfg.PuppetServer, PuppetServer: e.cfg.PuppetServer,
PuppetCAServer: e.cfg.PuppetCAServer, PuppetCAServer: e.cfg.PuppetCAServer,
PuppetCAURL: e.cfg.PuppetCAURL,
BaseURL: e.cfg.BaseURL, BaseURL: e.cfg.BaseURL,
BootBaseURL: e.cfg.BootBaseURL, BootBaseURL: e.cfg.BootBaseURL,
KickstartURL: ksURL, KickstartURL: ksURL,
CallbackURL: cbURL,
ProvisionToken: e.cfg.ProvisionToken,
DistroVars: vars,
Custom: h.Custom, Custom: h.Custom,
} }
} }
@@ -202,12 +265,27 @@ func (e *Engine) dataFor(h *model.Host) Data {
// RenderKickstart renders the selected kickstart template for host. It returns // RenderKickstart renders the selected kickstart template for host. It returns
// the rendered bytes and the template name used. // the rendered bytes and the template name used.
func (e *Engine) RenderKickstart(h *model.Host) ([]byte, string, error) { func (e *Engine) RenderKickstart(h *model.Host) ([]byte, string, error) {
name, ok := e.SelectKickstart(h) set := e.cur.Load()
if !ok { resolved, err := e.resolve(set, h)
return nil, name, fmt.Errorf("no kickstart template for host %q (tried override/platform/family/default %q)", h.Hostname, name) if err != nil {
return nil, "", err
}
var vars map[string]string
name := ""
if resolved != nil {
vars = resolved.Vars
if set.ksSet[resolved.Kickstart] {
name = resolved.Kickstart
}
}
if name == "" {
var ok bool
if name, ok = e.SelectKickstart(h); !ok {
return nil, name, fmt.Errorf("no kickstart template for host %q (catalog + override/platform/family/default %q)", h.Hostname, name)
}
} }
var buf bytes.Buffer var buf bytes.Buffer
if err := e.ks.ExecuteTemplate(&buf, name, e.dataFor(h)); err != nil { if err := set.ks.ExecuteTemplate(&buf, name, e.dataFor(h, vars)); err != nil {
return nil, name, fmt.Errorf("render kickstart %q: %w", name, err) return nil, name, fmt.Errorf("render kickstart %q: %w", name, err)
} }
return buf.Bytes(), name, nil return buf.Bytes(), name, nil
@@ -216,43 +294,122 @@ func (e *Engine) RenderKickstart(h *model.Host) ([]byte, string, error) {
// IPXEData is the value passed to iPXE templates. // IPXEData is the value passed to iPXE templates.
type IPXEData struct { type IPXEData struct {
Data Data
// KernelURL/InitrdURL point at the OS install tree; empty when BootBaseURL // KernelURL/InitrdURL point at the OS install tree (from the catalog, else
// is unset, in which case the template should fall back to a static path. // the legacy BootBaseURL). Empty when neither is configured, in which case
KernelURL string // the template falls back to local boot.
InitrdURL string KernelURL string
InitrdURL string
KernelArgs []string
// RepoURL is the OS install-tree root (KernelURL minus images/pxeboot/vmlinuz),
// passed to anaconda as inst.repo=.
RepoURL string
} }
// RenderIPXE renders the "boot" iPXE script that chains kernel+initrd with // RenderIPXE renders the "boot" iPXE script that chains kernel+initrd with
// inst.ks= pointing back at bootapi. // inst.ks= pointing back at bootapi.
func (e *Engine) RenderIPXE(h *model.Host) ([]byte, error) { func (e *Engine) RenderIPXE(h *model.Host) ([]byte, error) {
d := e.dataFor(h) set := e.cur.Load()
id := IPXEData{Data: d} resolved, err := e.resolve(set, h)
if d.BootBaseURL != "" { if err != nil {
tree := strings.TrimRight(d.BootBaseURL, "/") return nil, err
}
var vars map[string]string
if resolved != nil {
vars = resolved.Vars
}
id := IPXEData{Data: e.dataFor(h, vars)}
switch {
case resolved != nil:
id.KernelURL = resolved.KernelURL
id.InitrdURL = resolved.InitrdURL
id.KernelArgs = resolved.KernelArgs
case e.cfg.BootBaseURL != "": // legacy fallback
tree := strings.TrimRight(e.cfg.BootBaseURL, "/")
id.KernelURL = tree + "/images/pxeboot/vmlinuz" id.KernelURL = tree + "/images/pxeboot/vmlinuz"
id.InitrdURL = tree + "/images/pxeboot/initrd.img" id.InitrdURL = tree + "/images/pxeboot/initrd.img"
} }
return e.execIPXE("boot", id) id.RepoURL = strings.TrimSuffix(id.KernelURL, "/images/pxeboot/vmlinuz")
return e.execIPXE(set, "boot", id)
} }
// RenderFallback renders a fallback iPXE script ("local" or "shell") for an // RenderFallback renders a fallback iPXE script ("local" or "shell").
// unknown MAC. See docs/endpoints.md for the safety rationale.
func (e *Engine) RenderFallback(kind string) ([]byte, error) { func (e *Engine) RenderFallback(kind string) ([]byte, error) {
name := "fallback-" + kind return e.execIPXE(e.cur.Load(), "fallback-"+kind, IPXEData{})
return e.execIPXE(name, IPXEData{})
} }
func (e *Engine) execIPXE(name string, d IPXEData) ([]byte, error) { func (e *Engine) execIPXE(set *Set, name string, d IPXEData) ([]byte, error) {
if e.ipxe.Lookup(name) == nil { if set.ipxe.Lookup(name) == nil {
return nil, fmt.Errorf("no iPXE template %q", name) return nil, fmt.Errorf("no iPXE template %q", name)
} }
var buf bytes.Buffer var buf bytes.Buffer
if err := e.ipxe.ExecuteTemplate(&buf, name, d); err != nil { if err := set.ipxe.ExecuteTemplate(&buf, name, d); err != nil {
return nil, fmt.Errorf("render ipxe %q: %w", name, err) return nil, fmt.Errorf("render ipxe %q: %w", name, err)
} }
return buf.Bytes(), nil return buf.Bytes(), nil
} }
// Validate renders every catalog distro's kickstart and iPXE script against a
// representative fixture host, checking that each parses, resolves and leaves no
// unresolved template values. It is used by the templates-repo CI
// (`bootapi validate <dir>`) to reject a bad template/catalog before it ships.
func (e *Engine) Validate() error {
set := e.cur.Load()
distros := set.cat.All()
if len(distros) == 0 {
return fmt.Errorf("catalog is empty: no distros to validate")
}
var errs []string
for _, d := range distros {
h := fixtureHost(d)
ks, name, err := e.RenderKickstart(h)
if err != nil {
errs = append(errs, fmt.Sprintf("%s: kickstart: %v", d.Name, err))
} else if bad := unresolved(ks); bad != "" {
errs = append(errs, fmt.Sprintf("%s: kickstart %q has unresolved value near %q", d.Name, name, bad))
}
ipxe, err := e.RenderIPXE(h)
if err != nil {
errs = append(errs, fmt.Sprintf("%s: ipxe: %v", d.Name, err))
} else if bad := unresolved(ipxe); bad != "" {
errs = append(errs, fmt.Sprintf("%s: ipxe has unresolved value near %q", d.Name, bad))
}
}
if len(errs) > 0 {
return fmt.Errorf("catalog validation failed:\n - %s", strings.Join(errs, "\n - "))
}
return nil
}
// fixtureHost builds a representative host that selects distro d (via an exact
// override) with a plausible version/network, for validation rendering.
func fixtureHost(d *catalog.Distro) *model.Host {
version := d.VersionDefault
platform := d.Name
if len(d.Match.Platforms) > 0 {
platform = d.Match.Platforms[0]
}
return &model.Host{
Hostname: "fixture", Domain: "example.net", Platform: platform,
OSFamily: d.Match.Family, OSVersion: version, Arch: "x86_64",
TemplateOverride: d.Name, PrimaryIP: "10.0.0.10",
Interfaces: []model.Interface{{
Name: "eth0", MAC: "aa:bb:cc:00:11:22", IP: "10.0.0.10",
PrefixLen: 24, Netmask: "255.255.255.0", Gateway: "10.0.0.1", Primary: true,
}},
}
}
// unresolved returns the surrounding text of the first Go-template "<no value>"
// marker, or "" if none — a cheap check that the data model covered the template.
func unresolved(b []byte) string {
s := string(b)
if i := strings.Index(s, "<no value>"); i >= 0 {
start := max(0, i-30)
return s[start : i+10]
}
return ""
}
func funcMap() template.FuncMap { func funcMap() template.FuncMap {
return template.FuncMap{ return template.FuncMap{
"join": strings.Join, "join": strings.Join,
+62 -35
View File
@@ -1,6 +1,7 @@
package render package render
import ( import (
"io/fs"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -10,23 +11,32 @@ import (
"git.unkin.net/unkin/bootapi/templates" "git.unkin.net/unkin/bootapi/templates"
) )
func testEngine(t *testing.T, override string) *Engine { const artifactBase = "https://artifactapi.example.net/api/v1/remote"
t.Helper()
e, err := NewEngine(templates.FS, override, RenderConfig{ func testCfg() RenderConfig {
PuppetServer: "puppet.query.consul", return RenderConfig{
PuppetCAServer: "puppetca.query.consul", PuppetServer: "puppet.k8s.syd1.au.unkin.net",
PuppetCAServer: "puppetca.k8s.syd1.au.unkin.net",
PuppetCAURL: "puppetca.k8s.syd1.au.unkin.net",
BaseURL: "http://bootapi.example.net", BaseURL: "http://bootapi.example.net",
BootBaseURL: "http://mirror.example.net/almalinux/9", CallbackBaseURL: "http://bootapi.example.net",
ArtifactBase: artifactBase,
ProvisionToken: "prov-secret",
DefaultDomain: "main.unkin.net", DefaultDomain: "main.unkin.net",
DefaultNS: []string{"10.0.0.1"}, DefaultNS: []string{"198.18.200.7"},
RootPasswordHash: "$6$rounds=4096$abc$deadbeef", RootPasswordHash: "$6$rounds=4096$abc$deadbeef",
SSHAuthorizedKeys: []string{"ssh-ed25519 AAAAC3xxx root@ops"}, SSHAuthorizedKeys: []string{"ssh-ed25519 AAAAC3xxx root@ops"},
DefaultTemplate: "almalinux9", DefaultTemplate: "almalinux9",
})
if err != nil {
t.Fatalf("NewEngine: %v", err)
} }
return e }
func testEngine(t *testing.T, override fs.FS) *Engine {
t.Helper()
set, err := BuildSet(templates.FS, override)
if err != nil {
t.Fatalf("BuildSet: %v", err)
}
return NewEngine(testCfg(), set)
} }
func almaHost() *model.Host { func almaHost() *model.Host {
@@ -42,13 +52,13 @@ func almaHost() *model.Host {
PrimaryIP: "10.0.1.20", PrimaryIP: "10.0.1.20",
Interfaces: []model.Interface{ Interfaces: []model.Interface{
{Name: "eth0", MAC: "aa:bb:cc:00:11:22", IP: "10.0.1.20", PrefixLen: 24, Netmask: "255.255.255.0", Gateway: "10.0.1.254", VLAN: 100, Primary: true}, {Name: "eth0", MAC: "aa:bb:cc:00:11:22", IP: "10.0.1.20", PrefixLen: 24, Netmask: "255.255.255.0", Gateway: "10.0.1.254", VLAN: 100, Primary: true},
{Name: "eth1", MAC: "aa:bb:cc:00:11:33"}, // no IP -> must be skipped in network stanza {Name: "eth1", MAC: "aa:bb:cc:00:11:33"}, // no IP -> skipped in network stanza
}, },
} }
} }
func TestRenderKickstartAlma(t *testing.T) { func TestRenderKickstartAlma(t *testing.T) {
e := testEngine(t, "") e := testEngine(t, nil)
out, name, err := e.RenderKickstart(almaHost()) out, name, err := e.RenderKickstart(almaHost())
if err != nil { if err != nil {
t.Fatalf("RenderKickstart: %v", err) t.Fatalf("RenderKickstart: %v", err)
@@ -59,29 +69,33 @@ func TestRenderKickstartAlma(t *testing.T) {
ks := string(out) ks := string(out)
mustContain(t, ks, "rootpw --iscrypted $6$rounds=4096$abc$deadbeef") mustContain(t, ks, "rootpw --iscrypted $6$rounds=4096$abc$deadbeef")
// The primary interface must produce a full static network line incl hostname. mustContain(t, ks, "network --bootproto=static --device=aa:bb:cc:00:11:22 --ip=10.0.1.20 --netmask=255.255.255.0 --gateway=10.0.1.254 --nameserver=198.18.200.7 --hostname=web01.syd1.au.unkin.net")
mustContain(t, ks, "network --bootproto=static --device=aa:bb:cc:00:11:22 --ip=10.0.1.20 --netmask=255.255.255.0 --gateway=10.0.1.254 --nameserver=10.0.0.1 --hostname=web01.syd1.au.unkin.net") // install source comes from the catalog mirror (artifactapi almalinux remote).
mustContain(t, ks, `"$PUPPET_BIN" config set --section main server "puppet.query.consul"`) mustContain(t, ks, "url --url="+artifactBase+"/almalinux/9/BaseOS/x86_64/os/")
mustContain(t, ks, `config set --section main ca_server "puppetca.query.consul"`) mustContain(t, ks, "repo --name=AppStream --baseurl="+artifactBase+"/almalinux/9/AppStream/x86_64/os/")
mustContain(t, ks, "url --url=http://mirror.example.net/almalinux/9/BaseOS/x86_64/os/") // puppet points at the k8s server/CA.
mustContain(t, ks, `config set --section main server "puppet.k8s.syd1.au.unkin.net"`)
mustContain(t, ks, `config set --section main ca_server "puppetca.k8s.syd1.au.unkin.net"`)
// puppet-initial env file.
mustContain(t, ks, "PUPPETCA_URL=puppetca.k8s.syd1.au.unkin.net")
// end-of-install callback with the provision token.
mustContain(t, ks, `-H "Authorization: Bearer prov-secret"`)
mustContain(t, ks, `"http://bootapi.example.net/provisioned/web01"`)
mustContain(t, ks, "ssh-ed25519 AAAAC3xxx root@ops") mustContain(t, ks, "ssh-ed25519 AAAAC3xxx root@ops")
mustContain(t, ks, "dnf install -y puppet-agent")
mustContain(t, ks, "%packages")
mustContain(t, ks, "%post")
// eth1 has no IP, so it must NOT appear as a network device line.
if strings.Contains(ks, "--device=aa:bb:cc:00:11:33") { if strings.Contains(ks, "--device=aa:bb:cc:00:11:33") {
t.Error("interface without an IP leaked into a network stanza") t.Error("interface without an IP leaked into a network stanza")
} }
} }
func TestRenderKickstartLockedRoot(t *testing.T) { func TestRenderKickstartLockedRoot(t *testing.T) {
// With no root hash configured, the account must be locked, not blank. cfg := testCfg()
e, err := NewEngine(templates.FS, "", RenderConfig{DefaultTemplate: "almalinux9", BootBaseURL: "http://m/9"}) cfg.RootPasswordHash = ""
set, err := BuildSet(templates.FS, nil)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
out, _, err := e.RenderKickstart(almaHost()) out, _, err := NewEngine(cfg, set).RenderKickstart(almaHost())
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -93,14 +107,14 @@ func TestRenderKickstartLockedRoot(t *testing.T) {
} }
func TestSelectKickstartPrecedence(t *testing.T) { func TestSelectKickstartPrecedence(t *testing.T) {
e := testEngine(t, "") e := testEngine(t, nil)
cases := []struct { cases := []struct {
host *model.Host host *model.Host
want string want string
}{ }{
{&model.Host{TemplateOverride: "fedora", Platform: "almalinux9"}, "fedora"}, // override wins {&model.Host{TemplateOverride: "fedora", Platform: "almalinux9"}, "fedora"}, // override wins (catalog name)
{&model.Host{Platform: "almalinux9"}, "almalinux9"}, // platform {&model.Host{Platform: "almalinux9", OSFamily: "almalinux"}, "almalinux9"}, // platform
{&model.Host{Platform: "fedora42", OSFamily: "fedora"}, "fedora"}, // family fallback {&model.Host{Platform: "fedora42", OSFamily: "fedora"}, "fedora"}, // family fallback (catalog)
{&model.Host{Platform: "unknownos"}, "almalinux9"}, // default {&model.Host{Platform: "unknownos"}, "almalinux9"}, // default
} }
for _, c := range cases { for _, c := range cases {
@@ -111,21 +125,34 @@ func TestSelectKickstartPrecedence(t *testing.T) {
} }
} }
func TestRenderIPXE(t *testing.T) { func TestRenderIPXECatalog(t *testing.T) {
e := testEngine(t, "") e := testEngine(t, nil)
out, err := e.RenderIPXE(almaHost()) out, err := e.RenderIPXE(almaHost())
if err != nil { if err != nil {
t.Fatalf("RenderIPXE: %v", err) t.Fatalf("RenderIPXE: %v", err)
} }
s := string(out) s := string(out)
mustContain(t, s, "#!ipxe") mustContain(t, s, "#!ipxe")
mustContain(t, s, "kernel http://mirror.example.net/almalinux/9/images/pxeboot/vmlinuz") mustContain(t, s, "kernel "+artifactBase+"/almalinux/9/BaseOS/x86_64/os/images/pxeboot/vmlinuz")
mustContain(t, s, "initrd "+artifactBase+"/almalinux/9/BaseOS/x86_64/os/images/pxeboot/initrd.img")
mustContain(t, s, "inst.repo="+artifactBase+"/almalinux/9/BaseOS/x86_64/os")
mustContain(t, s, "inst.ks=http://bootapi.example.net/ks/web01") mustContain(t, s, "inst.ks=http://bootapi.example.net/ks/web01")
mustContain(t, s, "initrd http://mirror.example.net/almalinux/9/images/pxeboot/initrd.img") mustContain(t, s, "inst.text") // catalog kernel arg
mustContain(t, s, "net.ifnames=0")
}
func TestRenderIPXEFedoraCatalog(t *testing.T) {
e := testEngine(t, nil)
h := &model.Host{Hostname: "f1", Platform: "fedora41", OSFamily: "fedora", OSVersion: "41", Arch: "x86_64"}
out, err := e.RenderIPXE(h)
if err != nil {
t.Fatal(err)
}
mustContain(t, string(out), "kernel "+artifactBase+"/fedora/releases/41/Everything/x86_64/os/images/pxeboot/vmlinuz")
} }
func TestRenderFallback(t *testing.T) { func TestRenderFallback(t *testing.T) {
e := testEngine(t, "") e := testEngine(t, nil)
local, err := e.RenderFallback("local") local, err := e.RenderFallback("local")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -143,7 +170,7 @@ func TestOverrideDirWins(t *testing.T) {
if err := os.WriteFile(filepath.Join(dir, "almalinux9.ks.tmpl"), []byte("OVERRIDDEN {{ .Hostname }}\n"), 0o600); err != nil { if err := os.WriteFile(filepath.Join(dir, "almalinux9.ks.tmpl"), []byte("OVERRIDDEN {{ .Hostname }}\n"), 0o600); err != nil {
t.Fatal(err) t.Fatal(err)
} }
e := testEngine(t, dir) e := testEngine(t, os.DirFS(dir))
out, _, err := e.RenderKickstart(almaHost()) out, _, err := e.RenderKickstart(almaHost())
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
+51 -2
View File
@@ -11,6 +11,13 @@ type cacheStats interface {
Misses() int64 Misses() int64
} }
// gitStats is the read side of the template git-syncer the collector publishes.
type gitStats interface {
Syncs() int64
Failures() int64
Generation() int64
}
// metrics holds bootapi's Prometheus instruments, registered on a private // metrics holds bootapi's Prometheus instruments, registered on a private
// registry so tests can construct isolated servers. // registry so tests can construct isolated servers.
type metrics struct { type metrics struct {
@@ -20,9 +27,11 @@ type metrics struct {
renders *prometheus.CounterVec // by kind,result renders *prometheus.CounterVec // by kind,result
netboxLookups *prometheus.CounterVec // by field,result netboxLookups *prometheus.CounterVec // by field,result
netboxDuration *prometheus.HistogramVec netboxDuration *prometheus.HistogramVec
provisioned *prometheus.CounterVec // by result
ipxeGated prometheus.Counter
} }
func newMetrics(cache cacheStats) *metrics { func newMetrics(cache cacheStats, git gitStats) *metrics {
reg := prometheus.NewRegistry() reg := prometheus.NewRegistry()
m := &metrics{ m := &metrics{
reg: reg, reg: reg,
@@ -43,11 +52,22 @@ func newMetrics(cache cacheStats) *metrics {
Help: "Latency of NetBox host resolutions.", Help: "Latency of NetBox host resolutions.",
Buckets: prometheus.DefBuckets, Buckets: prometheus.DefBuckets,
}, []string{"field"}), }, []string{"field"}),
provisioned: prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "bootapi_provisioned_total",
Help: "Provisioned callbacks, by result (ok|unauthorized|notfound|error|disabled).",
}, []string{"result"}),
ipxeGated: prometheus.NewCounter(prometheus.CounterOpts{
Name: "bootapi_ipxe_gated_total",
Help: "Known hosts served the local-boot fallback because pxe_enabled=false.",
}),
} }
reg.MustRegister(m.httpRequests, m.renders, m.netboxLookups, m.netboxDuration) reg.MustRegister(m.httpRequests, m.renders, m.netboxLookups, m.netboxDuration, m.provisioned, m.ipxeGated)
if cache != nil { if cache != nil {
reg.MustRegister(newCacheCollector(cache)) reg.MustRegister(newCacheCollector(cache))
} }
if git != nil {
reg.MustRegister(newGitCollector(git))
}
reg.MustRegister( reg.MustRegister(
collectors.NewGoCollector(), collectors.NewGoCollector(),
collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}), collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}),
@@ -55,6 +75,35 @@ func newMetrics(cache cacheStats) *metrics {
return m return m
} }
// gitCollector publishes the template git-syncer counters.
type gitCollector struct {
stats gitStats
syncs *prometheus.Desc
failures *prometheus.Desc
generation *prometheus.Desc
}
func newGitCollector(s gitStats) *gitCollector {
return &gitCollector{
stats: s,
syncs: prometheus.NewDesc("bootapi_template_sync_total", "Successful template reloads from git.", nil, nil),
failures: prometheus.NewDesc("bootapi_template_sync_failures_total", "Template git pull/parse failures (last-good kept).", nil, nil),
generation: prometheus.NewDesc("bootapi_template_generation", "Monotonic counter of the active template generation.", nil, nil),
}
}
func (c *gitCollector) Describe(ch chan<- *prometheus.Desc) {
ch <- c.syncs
ch <- c.failures
ch <- c.generation
}
func (c *gitCollector) Collect(ch chan<- prometheus.Metric) {
ch <- prometheus.MustNewConstMetric(c.syncs, prometheus.CounterValue, float64(c.stats.Syncs()))
ch <- prometheus.MustNewConstMetric(c.failures, prometheus.CounterValue, float64(c.stats.Failures()))
ch <- prometheus.MustNewConstMetric(c.generation, prometheus.GaugeValue, float64(c.stats.Generation()))
}
// cacheCollector publishes the NetBox cache hit/miss counters, which live on // cacheCollector publishes the NetBox cache hit/miss counters, which live on
// the Cache itself (atomic ints) rather than in a CounterVec. // the Cache itself (atomic ints) rather than in a CounterVec.
type cacheCollector struct { type cacheCollector struct {
+134 -28
View File
@@ -4,10 +4,13 @@ package server
import ( import (
"context" "context"
"crypto/subtle"
"errors" "errors"
"fmt"
"log/slog" "log/slog"
"net/http" "net/http"
"strings" "strings"
"sync"
"time" "time"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
@@ -19,23 +22,35 @@ import (
"git.unkin.net/unkin/bootapi/internal/render" "git.unkin.net/unkin/bootapi/internal/render"
) )
// Server wires the NetBox resolver and template engine into HTTP handlers. // Server wires the NetBox API and template engine into HTTP handlers.
type Server struct { type Server struct {
resolver netbox.Resolver nb netbox.API
engine *render.Engine engine *render.Engine
metrics *metrics metrics *metrics
// fallback is the unknown-MAC iPXE behavior: "local" (safe default) or // fallback is the unknown-MAC iPXE behavior: "local" (safe default) or
// "shell" (debug). // "shell" (debug).
fallback string fallback string
// provisionToken guards POST /provisioned; empty disables the endpoint.
provisionToken string
// TLS listener (optional); the plain-HTTP listener is always on.
tlsAddr string
tlsCert string
tlsKey string
} }
// Options configures a Server. // Options configures a Server.
type Options struct { type Options struct {
Resolver netbox.Resolver NetBox netbox.API
Engine *render.Engine Engine *render.Engine
// Cache, when non-nil, has its hit/miss counters published as metrics. // Cache/GitStats, when non-nil, have their counters published as metrics.
Cache cacheStats Cache cacheStats
GitStats gitStats
UnknownMACFallback string UnknownMACFallback string
ProvisionToken string
TLSAddr string
TLSCertFile string
TLSKeyFile string
} }
// New builds a Server. // New builds a Server.
@@ -45,10 +60,14 @@ func New(o Options) *Server {
fb = "local" fb = "local"
} }
return &Server{ return &Server{
resolver: o.Resolver, nb: o.NetBox,
engine: o.Engine, engine: o.Engine,
metrics: newMetrics(o.Cache), metrics: newMetrics(o.Cache, o.GitStats),
fallback: fb, fallback: fb,
provisionToken: o.ProvisionToken,
tlsAddr: o.TLSAddr,
tlsCert: o.TLSCertFile,
tlsKey: o.TLSKeyFile,
} }
} }
@@ -70,6 +89,9 @@ func (s *Server) Router() http.Handler {
// Rendered kickstart, keyed by MAC or hostname. // Rendered kickstart, keyed by MAC or hostname.
r.Get("/ks/{ident}", s.handleKickstart) r.Get("/ks/{ident}", s.handleKickstart)
// End-of-kickstart callback: flips pxe_enabled off in NetBox. Token-guarded.
r.Post("/provisioned/{ident}", s.handleProvisioned)
return r return r
} }
@@ -116,6 +138,15 @@ func (s *Server) serveIPXE(w http.ResponseWriter, r *http.Request, mac string) {
s.renderFallback(w, "ipxe", "unknown or unresolvable MAC") s.renderFallback(w, "ipxe", "unknown or unresolvable MAC")
return return
} }
// Per-host PXE-enable gate (Cobbler's netboot_enabled): a KNOWN host whose
// pxe_enabled is false must NOT re-install. Serve the safe local-boot script
// so an already-provisioned machine just boots its disk.
if !host.ShouldPXEInstall() {
s.metrics.ipxeGated.Inc()
slog.Info("ipxe gated: pxe_enabled=false; serving local boot", "host", host.Hostname)
s.renderFallback(w, "ipxe", "pxe disabled for host")
return
}
body, err := s.engine.RenderIPXE(host) body, err := s.engine.RenderIPXE(host)
if err != nil { if err != nil {
s.metrics.renders.WithLabelValues("ipxe", "error").Inc() s.metrics.renders.WithLabelValues("ipxe", "error").Inc()
@@ -180,15 +211,68 @@ func (s *Server) handleKickstart(w http.ResponseWriter, r *http.Request) {
s.ok(w, http.StatusOK, "text/plain", body, "ks") s.ok(w, http.StatusOK, "text/plain", body, "ks")
} }
// handleProvisioned is the end-of-kickstart callback. The %post posts here with
// the shared provision token when the install finishes; bootapi flips the host's
// pxe_enabled custom field to false in NetBox so the next PXE boots local disk
// instead of re-installing. This is bootapi's only NetBox write.
func (s *Server) handleProvisioned(w http.ResponseWriter, r *http.Request) {
if s.provisionToken == "" {
http.Error(w, "provisioned callback disabled: no token configured", http.StatusServiceUnavailable)
s.metrics.provisioned.WithLabelValues("disabled").Inc()
return
}
if subtle.ConstantTimeCompare([]byte(bearer(r)), []byte(s.provisionToken)) != 1 {
http.Error(w, "invalid or missing provision token", http.StatusUnauthorized)
s.metrics.provisioned.WithLabelValues("unauthorized").Inc()
return
}
ident := chi.URLParam(r, "ident")
field := "name"
if looksLikeMAC(ident) {
field = "mac"
}
host, err := s.lookup(r.Context(), field, ident)
if err != nil {
if errors.Is(err, netbox.ErrNotFound) {
http.Error(w, "no host in NetBox for "+ident, http.StatusNotFound)
s.metrics.provisioned.WithLabelValues("notfound").Inc()
return
}
http.Error(w, "netbox lookup failed", http.StatusBadGateway)
s.metrics.provisioned.WithLabelValues("error").Inc()
return
}
if err := s.nb.SetPXEEnabled(r.Context(), host.DeviceID, false); err != nil {
slog.Error("provisioned: failed to clear pxe_enabled", "host", host.Hostname, "err", err)
http.Error(w, "failed to update NetBox", http.StatusBadGateway)
s.metrics.provisioned.WithLabelValues("error").Inc()
return
}
s.metrics.provisioned.WithLabelValues("ok").Inc()
slog.Info("host provisioned; pxe_enabled cleared", "host", host.Hostname)
w.WriteHeader(http.StatusNoContent)
}
// bearer extracts a token from "Authorization: Bearer <t>" or a bare "token"
// header.
func bearer(r *http.Request) string {
if h := r.Header.Get("Authorization"); h != "" {
if after, ok := strings.CutPrefix(h, "Bearer "); ok {
return after
}
}
return r.Header.Get("token")
}
// lookup resolves a host by field ("mac" or "name"), recording metrics. // lookup resolves a host by field ("mac" or "name"), recording metrics.
func (s *Server) lookup(ctx context.Context, field, value string) (*model.Host, error) { func (s *Server) lookup(ctx context.Context, field, value string) (*model.Host, error) {
start := time.Now() start := time.Now()
var host *model.Host var host *model.Host
var err error var err error
if field == "mac" { if field == "mac" {
host, err = s.resolver.HostByMAC(ctx, value) host, err = s.nb.HostByMAC(ctx, value)
} else { } else {
host, err = s.resolver.HostByName(ctx, value) host, err = s.nb.HostByName(ctx, value)
} }
s.metrics.netboxDuration.WithLabelValues(field).Observe(time.Since(start).Seconds()) s.metrics.netboxDuration.WithLabelValues(field).Observe(time.Since(start).Seconds())
switch { switch {
@@ -209,24 +293,46 @@ func (s *Server) ok(w http.ResponseWriter, status int, contentType string, body
s.metrics.httpRequests.WithLabelValues(endpoint, statusClass(status)).Inc() s.metrics.httpRequests.WithLabelValues(endpoint, statusClass(status)).Inc()
} }
// ListenAndServe runs the HTTP server until ctx is cancelled. // ListenAndServe runs the plain-HTTP server (always) plus, when a TLS listener
// is configured, an HTTPS server sharing the same handler — both until ctx is
// cancelled. The boot path works over plain HTTP because PXE installers have no
// internal CA trust; HTTPS is offered in parallel for clients that do.
func (s *Server) ListenAndServe(ctx context.Context, addr string) error { func (s *Server) ListenAndServe(ctx context.Context, addr string) error {
srv := &http.Server{ h := s.Router()
Addr: addr, var wg sync.WaitGroup
Handler: s.Router(), errc := make(chan error, 2)
ReadHeaderTimeout: 10 * time.Second,
serve := func(name string, srv *http.Server, tls bool) {
defer wg.Done()
go func() {
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = srv.Shutdown(shutdownCtx)
}()
slog.Info("bootapi listening", "listener", name, "addr", srv.Addr)
var err error
if tls {
err = srv.ListenAndServeTLS(s.tlsCert, s.tlsKey)
} else {
err = srv.ListenAndServe()
}
if err != nil && !errors.Is(err, http.ErrServerClosed) {
errc <- fmt.Errorf("%s listener: %w", name, err)
}
} }
go func() {
<-ctx.Done() wg.Add(1)
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) go serve("http", &http.Server{Addr: addr, Handler: h, ReadHeaderTimeout: 10 * time.Second}, false)
defer cancel()
_ = srv.Shutdown(shutdownCtx) if s.tlsAddr != "" && s.tlsCert != "" && s.tlsKey != "" {
}() wg.Add(1)
slog.Info("bootapi listening", "addr", addr) go serve("https", &http.Server{Addr: s.tlsAddr, Handler: h, ReadHeaderTimeout: 10 * time.Second}, true)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
return err
} }
return nil
wg.Wait()
close(errc)
return <-errc // first error, or nil (channel closed empty)
} }
// looksLikeMAC reports whether s is plausibly a MAC (12 hex nibbles, ignoring // looksLikeMAC reports whether s is plausibly a MAC (12 hex nibbles, ignoring
+117 -24
View File
@@ -13,14 +13,16 @@ import (
"git.unkin.net/unkin/bootapi/templates" "git.unkin.net/unkin/bootapi/templates"
) )
// fakeResolver is a canned netbox.Resolver for handler tests. // fakeNB is a canned netbox.API (reads + pxe_enabled write) for handler tests.
type fakeResolver struct { type fakeNB struct {
byMAC map[string]*model.Host byMAC map[string]*model.Host
byName map[string]*model.Host byName map[string]*model.Host
err error err error
writeErr error
writes []int // device IDs written via SetPXEEnabled
} }
func (f *fakeResolver) HostByMAC(_ context.Context, mac string) (*model.Host, error) { func (f *fakeNB) HostByMAC(_ context.Context, mac string) (*model.Host, error) {
if f.err != nil { if f.err != nil {
return nil, f.err return nil, f.err
} }
@@ -31,7 +33,7 @@ func (f *fakeResolver) HostByMAC(_ context.Context, mac string) (*model.Host, er
} }
return nil, netbox.ErrNotFound return nil, netbox.ErrNotFound
} }
func (f *fakeResolver) HostByName(_ context.Context, name string) (*model.Host, error) { func (f *fakeNB) HostByName(_ context.Context, name string) (*model.Host, error) {
if f.err != nil { if f.err != nil {
return nil, f.err return nil, f.err
} }
@@ -40,9 +42,17 @@ func (f *fakeResolver) HostByName(_ context.Context, name string) (*model.Host,
} }
return nil, netbox.ErrNotFound return nil, netbox.ErrNotFound
} }
func (f *fakeNB) SetPXEEnabled(_ context.Context, deviceID int, _ bool) error {
if f.writeErr != nil {
return f.writeErr
}
f.writes = append(f.writes, deviceID)
return nil
}
func testHost() *model.Host { func testHost() *model.Host {
return &model.Host{ return &model.Host{
DeviceID: 12,
Hostname: "web01", Domain: "syd1.au.unkin.net", FQDN: "web01.syd1.au.unkin.net", Hostname: "web01", Domain: "syd1.au.unkin.net", FQDN: "web01.syd1.au.unkin.net",
Platform: "almalinux9", OSFamily: "almalinux", OSVersion: "9", Arch: "x86_64", Platform: "almalinux9", OSFamily: "almalinux", OSVersion: "9", Arch: "x86_64",
PrimaryIP: "10.0.1.20", PrimaryIP: "10.0.1.20",
@@ -52,18 +62,25 @@ func testHost() *model.Host {
} }
} }
func newTestServer(t *testing.T, res netbox.Resolver, fallback string) *Server { func newTestServer(t *testing.T, nb netbox.API, fallback string) *Server {
t.Helper() t.Helper()
eng, err := render.NewEngine(templates.FS, "", render.RenderConfig{ return newTestServerToken(t, nb, fallback, "")
PuppetServer: "puppet.query.consul", PuppetCAServer: "puppetca.query.consul", }
BaseURL: "http://bootapi.example.net", BootBaseURL: "http://mirror.example.net/almalinux/9",
DefaultDomain: "main.unkin.net", DefaultTemplate: "almalinux9", func newTestServerToken(t *testing.T, nb netbox.API, fallback, provToken string) *Server {
RootPasswordHash: "$6$abc$def", t.Helper()
}) set, err := render.BuildSet(templates.FS, nil)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
return New(Options{Resolver: res, Engine: eng, UnknownMACFallback: fallback}) eng := render.NewEngine(render.RenderConfig{
PuppetServer: "puppet.k8s.syd1.au.unkin.net", PuppetCAServer: "puppetca.k8s.syd1.au.unkin.net",
BaseURL: "http://bootapi.example.net", CallbackBaseURL: "http://bootapi.example.net",
ArtifactBase: "https://af.example/api/v1/remote", ProvisionToken: provToken,
DefaultDomain: "main.unkin.net", DefaultTemplate: "almalinux9",
RootPasswordHash: "$6$abc$def",
}, set)
return New(Options{NetBox: nb, Engine: eng, UnknownMACFallback: fallback, ProvisionToken: provToken})
} }
func do(t *testing.T, h http.Handler, path string) *httptest.ResponseRecorder { func do(t *testing.T, h http.Handler, path string) *httptest.ResponseRecorder {
@@ -73,8 +90,19 @@ func do(t *testing.T, h http.Handler, path string) *httptest.ResponseRecorder {
return rec return rec
} }
func post(t *testing.T, h http.Handler, path, token string) *httptest.ResponseRecorder {
t.Helper()
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, path, nil)
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
h.ServeHTTP(rec, req)
return rec
}
func TestIPXEKnownMAC(t *testing.T) { func TestIPXEKnownMAC(t *testing.T) {
res := &fakeResolver{byMAC: map[string]*model.Host{"aa:bb:cc:00:11:22": testHost()}} res := &fakeNB{byMAC: map[string]*model.Host{"aa:bb:cc:00:11:22": testHost()}}
h := newTestServer(t, res, "local").Router() h := newTestServer(t, res, "local").Router()
rec := do(t, h, "/ipxe/aa:bb:cc:00:11:22") rec := do(t, h, "/ipxe/aa:bb:cc:00:11:22")
@@ -88,7 +116,7 @@ func TestIPXEKnownMAC(t *testing.T) {
} }
func TestIPXEUnknownMACServesFallback200(t *testing.T) { func TestIPXEUnknownMACServesFallback200(t *testing.T) {
h := newTestServer(t, &fakeResolver{}, "local").Router() h := newTestServer(t, &fakeNB{}, "local").Router()
rec := do(t, h, "/ipxe/de:ad:be:ef:00:00") rec := do(t, h, "/ipxe/de:ad:be:ef:00:00")
// Unknown MAC must NOT 404 — iPXE needs a valid script. Safe local-boot. // Unknown MAC must NOT 404 — iPXE needs a valid script. Safe local-boot.
if rec.Code != http.StatusOK { if rec.Code != http.StatusOK {
@@ -100,7 +128,7 @@ func TestIPXEUnknownMACServesFallback200(t *testing.T) {
} }
func TestIPXEUnknownMACShellFallback(t *testing.T) { func TestIPXEUnknownMACShellFallback(t *testing.T) {
h := newTestServer(t, &fakeResolver{}, "shell").Router() h := newTestServer(t, &fakeNB{}, "shell").Router()
rec := do(t, h, "/ipxe/de:ad:be:ef:00:00") rec := do(t, h, "/ipxe/de:ad:be:ef:00:00")
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "shell") { if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "shell") {
t.Fatalf("shell fallback not served: %d\n%s", rec.Code, rec.Body.String()) t.Fatalf("shell fallback not served: %d\n%s", rec.Code, rec.Body.String())
@@ -108,7 +136,7 @@ func TestIPXEUnknownMACShellFallback(t *testing.T) {
} }
func TestIPXEQueryAlias(t *testing.T) { func TestIPXEQueryAlias(t *testing.T) {
res := &fakeResolver{byMAC: map[string]*model.Host{"aa:bb:cc:00:11:22": testHost()}} res := &fakeNB{byMAC: map[string]*model.Host{"aa:bb:cc:00:11:22": testHost()}}
h := newTestServer(t, res, "local").Router() h := newTestServer(t, res, "local").Router()
rec := do(t, h, "/boot/ipxe?mac=AA:BB:CC:00:11:22") rec := do(t, h, "/boot/ipxe?mac=AA:BB:CC:00:11:22")
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "inst.ks=") { if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "inst.ks=") {
@@ -117,7 +145,7 @@ func TestIPXEQueryAlias(t *testing.T) {
} }
func TestKickstartByMAC(t *testing.T) { func TestKickstartByMAC(t *testing.T) {
res := &fakeResolver{byMAC: map[string]*model.Host{"aa:bb:cc:00:11:22": testHost()}} res := &fakeNB{byMAC: map[string]*model.Host{"aa:bb:cc:00:11:22": testHost()}}
h := newTestServer(t, res, "local").Router() h := newTestServer(t, res, "local").Router()
rec := do(t, h, "/ks/aa:bb:cc:00:11:22") rec := do(t, h, "/ks/aa:bb:cc:00:11:22")
if rec.Code != http.StatusOK { if rec.Code != http.StatusOK {
@@ -132,7 +160,7 @@ func TestKickstartByMAC(t *testing.T) {
} }
func TestKickstartByHostname(t *testing.T) { func TestKickstartByHostname(t *testing.T) {
res := &fakeResolver{byName: map[string]*model.Host{"web01": testHost()}} res := &fakeNB{byName: map[string]*model.Host{"web01": testHost()}}
h := newTestServer(t, res, "local").Router() h := newTestServer(t, res, "local").Router()
rec := do(t, h, "/ks/web01.cfg") // .cfg suffix must be stripped rec := do(t, h, "/ks/web01.cfg") // .cfg suffix must be stripped
if rec.Code != http.StatusOK { if rec.Code != http.StatusOK {
@@ -141,7 +169,7 @@ func TestKickstartByHostname(t *testing.T) {
} }
func TestKickstartUnknownIs404(t *testing.T) { func TestKickstartUnknownIs404(t *testing.T) {
h := newTestServer(t, &fakeResolver{}, "local").Router() h := newTestServer(t, &fakeNB{}, "local").Router()
rec := do(t, h, "/ks/nosuchhost") rec := do(t, h, "/ks/nosuchhost")
if rec.Code != http.StatusNotFound { if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404 (kickstart must fail loudly)", rec.Code) t.Fatalf("status = %d, want 404 (kickstart must fail loudly)", rec.Code)
@@ -149,7 +177,7 @@ func TestKickstartUnknownIs404(t *testing.T) {
} }
func TestHealthAndReady(t *testing.T) { func TestHealthAndReady(t *testing.T) {
h := newTestServer(t, &fakeResolver{}, "local").Router() h := newTestServer(t, &fakeNB{}, "local").Router()
if rec := do(t, h, "/healthz"); rec.Code != http.StatusOK { if rec := do(t, h, "/healthz"); rec.Code != http.StatusOK {
t.Errorf("healthz = %d", rec.Code) t.Errorf("healthz = %d", rec.Code)
} }
@@ -159,7 +187,7 @@ func TestHealthAndReady(t *testing.T) {
} }
func TestMetricsEndpoint(t *testing.T) { func TestMetricsEndpoint(t *testing.T) {
res := &fakeResolver{byMAC: map[string]*model.Host{"aa:bb:cc:00:11:22": testHost()}} res := &fakeNB{byMAC: map[string]*model.Host{"aa:bb:cc:00:11:22": testHost()}}
srv := newTestServer(t, res, "local") srv := newTestServer(t, res, "local")
h := srv.Router() h := srv.Router()
@@ -184,6 +212,71 @@ func TestMetricsEndpoint(t *testing.T) {
} }
} }
func TestIPXEGatedWhenPXEDisabled(t *testing.T) {
disabled := false
host := testHost()
host.PXEEnabled = &disabled // pxe_enabled=false: known host must NOT reinstall
res := &fakeNB{byMAC: map[string]*model.Host{"aa:bb:cc:00:11:22": host}}
srv := newTestServer(t, res, "local")
h := srv.Router()
rec := do(t, h, "/ipxe/aa:bb:cc:00:11:22")
if rec.Code != http.StatusOK {
t.Fatalf("status = %d", rec.Code)
}
body := rec.Body.String()
if !strings.Contains(body, "sanboot") || strings.Contains(body, "inst.ks=") {
t.Errorf("gated host should get local-boot fallback, not an installer:\n%s", body)
}
if !strings.Contains(do(t, h, "/metrics").Body.String(), "bootapi_ipxe_gated_total 1") {
t.Error("gate metric not incremented")
}
}
func TestProvisionedCallbackOK(t *testing.T) {
res := &fakeNB{byName: map[string]*model.Host{"web01": testHost()}}
h := newTestServerToken(t, res, "local", "prov-secret").Router()
rec := post(t, h, "/provisioned/web01", "prov-secret")
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204\n%s", rec.Code, rec.Body.String())
}
if len(res.writes) != 1 || res.writes[0] != 12 {
t.Errorf("expected SetPXEEnabled on device 12, got writes=%v", res.writes)
}
}
func TestProvisionedCallbackAuth(t *testing.T) {
res := &fakeNB{byName: map[string]*model.Host{"web01": testHost()}}
h := newTestServerToken(t, res, "local", "prov-secret").Router()
if rec := post(t, h, "/provisioned/web01", "wrong"); rec.Code != http.StatusUnauthorized {
t.Errorf("wrong token: status = %d, want 401", rec.Code)
}
if rec := post(t, h, "/provisioned/web01", ""); rec.Code != http.StatusUnauthorized {
t.Errorf("no token: status = %d, want 401", rec.Code)
}
if len(res.writes) != 0 {
t.Errorf("unauthorized calls must not write NetBox, got %v", res.writes)
}
}
func TestProvisionedCallbackDisabled(t *testing.T) {
// No provision token configured -> endpoint fails closed.
res := &fakeNB{byName: map[string]*model.Host{"web01": testHost()}}
h := newTestServer(t, res, "local").Router()
if rec := post(t, h, "/provisioned/web01", "anything"); rec.Code != http.StatusServiceUnavailable {
t.Errorf("status = %d, want 503 when no token configured", rec.Code)
}
}
func TestProvisionedCallbackUnknownHost(t *testing.T) {
h := newTestServerToken(t, &fakeNB{}, "local", "prov-secret").Router()
if rec := post(t, h, "/provisioned/nosuch", "prov-secret"); rec.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404", rec.Code)
}
}
func TestLooksLikeMAC(t *testing.T) { func TestLooksLikeMAC(t *testing.T) {
yes := []string{"aa:bb:cc:00:11:22", "aa-bb-cc-00-11-22", "aabbcc001122", "aabb.cc00.1122"} yes := []string{"aa:bb:cc:00:11:22", "aa-bb-cc-00-11-22", "aabbcc001122", "aabb.cc00.1122"}
no := []string{"web01", "web01.example.net", "aa:bb:cc", "zz:bb:cc:00:11:22"} no := []string{"web01", "web01.example.net", "aa:bb:cc", "zz:bb:cc:00:11:22"}
+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"
+3 -2
View File
@@ -5,7 +5,8 @@ package templates
import "embed" import "embed"
// FS holds the default template tree: kickstart/*.ks.tmpl and ipxe/*.ipxe.tmpl. // FS holds the default template tree: kickstart/*.ks.tmpl, ipxe/*.ipxe.tmpl and
// catalog/*.yaml (the distro catalog).
// //
//go:embed kickstart ipxe //go:embed kickstart ipxe catalog
var FS embed.FS var FS embed.FS
+10 -10
View File
@@ -1,20 +1,20 @@
{{- /* {{- /*
iPXE boot script for a known host. Chains the OS installer kernel+initrd and iPXE boot script for a known, PXE-enabled host. Chains the OS installer
points inst.ks= back at bootapi's /ks/<hostname> endpoint, mirroring how Cobbler kernel+initrd (from the distro catalog) and points inst.ks= back at bootapi's
generated a per-MAC gPXE script that carried inst.ks=. /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=.
Requires BOOTAPI_BOOT_BASE_URL (KernelURL/InitrdURL) and BOOTAPI_BASE_URL KernelURL/InitrdURL/RepoURL come from the selected catalog entry (artifactapi
(KickstartURL) to be configured. remote); KernelArgs are the catalog's extra args. KickstartURL uses
BOOTAPI_BASE_URL (http://).
*/ -}} */ -}}
#!ipxe #!ipxe
echo bootapi: provisioning {{ .FQDN }} ({{ .Platform }}) echo bootapi: provisioning {{ .FQDN }} ({{ .Platform }})
{{ if and .KernelURL .InitrdURL -}} {{ if and .KernelURL .InitrdURL -}}
kernel {{ .KernelURL }} initrd=initrd.img inst.repo={{ .BootBaseURL }} inst.ks={{ .KickstartURL }} inst.text ip=dhcp net.ifnames=0 kernel {{ .KernelURL }} initrd=initrd.img{{ if .RepoURL }} inst.repo={{ .RepoURL }}{{ end }} inst.ks={{ .KickstartURL }} ip=dhcp{{ range .KernelArgs }} {{ . }}{{ end }}
initrd {{ .InitrdURL }} initrd {{ .InitrdURL }}
boot boot
{{- else -}} {{- else -}}
echo bootapi: BOOTAPI_BOOT_BASE_URL not configured; cannot build a boot line echo bootapi: no boot images resolved for {{ .Platform }} (no catalog entry / BOOTAPI_BOOT_BASE_URL); booting local disk
echo Falling back to local disk in 5s sanboot --no-describe --drive 0x80 || exit
sleep 5
exit
{{- end }} {{- end }}
+35 -18
View File
@@ -1,15 +1,17 @@
{{- /* {{- /*
AlmaLinux 9 kickstart, ported from the Cobbler default.ks contract. AlmaLinux 9 kickstart, ported from the Cobbler default.ks contract.
Rendered by bootapi from NetBox data + render-time secrets. The %post hands off Rendered by bootapi from NetBox data + render-time secrets + the distro catalog.
to the existing Puppet firstrun bootstrap: it installs the agent, points it at Install source comes from the artifactapi almalinux remote (via the catalog
the Consul-discovered puppet servers, and triggers the first run. Autosign mirror var). The %post installs the Puppet agent and points it at the k8s
(*.main.unkin.net + the PXE subnets) and the `profiles::firstrun` class do the puppetserver (puppet.k8s.syd1.au.unkin.net / puppetca.k8s...), writes the
rest, exactly as they did under Cobbler. 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` comes from Vault at Data model: see docs/data-model.md. `.RootPasswordHash` and `.ProvisionToken`
render time, never from NetBox. come from Vault/env at render time, never from NetBox.
*/ -}} */ -}}
{{- $mirror := .DistroVars.mirror -}}
#version=RHEL9 #version=RHEL9
# Rendered by bootapi for {{ .FQDN }} (platform {{ .Platform }}, role {{ default "none" .Role }}) # Rendered by bootapi for {{ .FQDN }} (platform {{ .Platform }}, role {{ default "none" .Role }})
text text
@@ -17,9 +19,9 @@ eula --agreed
firstboot --disable firstboot --disable
reboot reboot
# --- install source (served by bootapi's configured mirror) --- # --- install source (artifactapi almalinux remote, from the distro catalog) ---
url --url={{ .BootBaseURL }}/BaseOS/{{ .Arch }}/os/ url --url={{ $mirror }}/BaseOS/{{ .Arch }}/os/
repo --name=AppStream --baseurl={{ .BootBaseURL }}/AppStream/{{ .Arch }}/os/ repo --name=AppStream --baseurl={{ $mirror }}/AppStream/{{ .Arch }}/os/
# --- localization --- # --- localization ---
keyboard --xlayouts='us' keyboard --xlayouts='us'
@@ -61,7 +63,7 @@ git
-iwl*-firmware -iwl*-firmware
%end %end
# --- bootstrap: hand off to Puppet firstrun --- # --- bootstrap: puppet (k8s) + end-of-install callback ---
%post --log=/root/bootapi-post.log %post --log=/root/bootapi-post.log
set -x set -x
@@ -82,15 +84,30 @@ rpm -q puppet-agent >/dev/null 2>&1 || \
dnf install -y https://yum.puppet.com/puppet8-release-el-9.noarch.rpm dnf install -y https://yum.puppet.com/puppet8-release-el-9.noarch.rpm
dnf install -y puppet-agent dnf install -y puppet-agent
# Point the agent at the Consul-discovered servers (matches the pre-bootapi # Point the agent at the k8s puppetserver / CA.
# Cobbler kickstart + hieradata/roles/infra/puppet).
PUPPET_BIN=/opt/puppetlabs/bin/puppet PUPPET_BIN=/opt/puppetlabs/bin/puppet
"$PUPPET_BIN" config set --section main certname "{{ .FQDN }}" "$PUPPET_BIN" config set --section main certname "{{ .FQDN }}"
"$PUPPET_BIN" config set --section main server "{{ .PuppetServer }}" "$PUPPET_BIN" config set --section main server "{{ .PuppetServer }}"
"$PUPPET_BIN" config set --section main ca_server "{{ .PuppetCAServer }}" "$PUPPET_BIN" config set --section main ca_server "{{ .PuppetCAServer }}"
"$PUPPET_BIN" config set --section main report_server "{{ .PuppetServer }}" "$PUPPET_BIN" config set --section main report_server "{{ .PuppetServer }}"
"$PUPPET_BIN" config set --section main environment production "$PUPPET_BIN" config set --section main environment production
# Enable the agent; the first boot triggers firstrun (autosign handles the CSR). # 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 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 %end
+20 -6
View File
@@ -1,17 +1,19 @@
{{- /* {{- /*
Fedora kickstart (family-level template: matches any "fedoraNN" platform slug Fedora kickstart (family-level template: matches any "fedoraNN" platform slug
via the OS-family selection fallback). Kept close to the AlmaLinux template so via the catalog family match). Kept close to the AlmaLinux template so the two
the two stay comparable; the differences are the install tree layout and that stay comparable; the differences are the install-tree layout (releases/.../
Fedora ships a recent-enough dnf/agent story out of the box. 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 }} #version=F{{ default "" .OSVersion }}
# Rendered by bootapi for {{ .FQDN }} (platform {{ .Platform }}) # Rendered by bootapi for {{ .FQDN }} (platform {{ .Platform }})
text text
firstboot --disable firstboot --disable
reboot reboot
# --- install source --- # --- install source (artifactapi fedora remote, from the distro catalog) ---
url --url={{ .BootBaseURL }}/releases/{{ default "rawhide" .OSVersion }}/Everything/{{ .Arch }}/os/ url --url={{ $mirror }}/{{ .Arch }}/os/
keyboard --xlayouts='us' keyboard --xlayouts='us'
lang en_AU.UTF-8 lang en_AU.UTF-8
@@ -56,11 +58,23 @@ cat > /root/.ssh/authorized_keys <<'EOF'
{{ end }}EOF {{ end }}EOF
chmod 0600 /root/.ssh/authorized_keys chmod 0600 /root/.ssh/authorized_keys
{{- end }} {{- end }}
dnf install -y https://yum.puppet.com/puppet8-release-fedora-{{ default "40" .OSVersion }}.noarch.rpm || true dnf install -y "https://yum.puppet.com/puppet8-release-fedora-{{ default "40" .OSVersion }}.noarch.rpm" || true
dnf install -y puppet-agent dnf install -y puppet-agent
PUPPET_BIN=/opt/puppetlabs/bin/puppet PUPPET_BIN=/opt/puppetlabs/bin/puppet
"$PUPPET_BIN" config set --section main certname "{{ .FQDN }}" "$PUPPET_BIN" config set --section main certname "{{ .FQDN }}"
"$PUPPET_BIN" config set --section main server "{{ .PuppetServer }}" "$PUPPET_BIN" config set --section main server "{{ .PuppetServer }}"
"$PUPPET_BIN" config set --section main ca_server "{{ .PuppetCAServer }}" "$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 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 %end