Initial bootapi: NetBox-driven PXE/kickstart boot service
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful

bootapi replaces Cobbler's PXE/kickstart side. It resolves a PXE-booting host
from NetBox (by MAC or hostname), renders an iPXE boot script and a kickstart
from Go text/templates, and serves them over HTTP. The ENC half already moved to
encapi; this covers the provisioning/boot half.

What's here:
- cmd/bootapi + internal/{config,model,netbox,render,server}; embedded default
  templates under templates/ (AlmaLinux 9 + Fedora kickstarts, iPXE boot +
  unknown-MAC fallbacks) ported from Cobbler's boot/bootstrap contract.
- NetBox client (v4.x API) behind a Resolver interface with a short-TTL cache;
  tested against httptest fixtures using real NetBox JSON shapes.
- chi HTTP server: /ipxe/{mac}, /boot/ipxe?mac=, /ks/{ident}, healthz/readyz,
  Prometheus /metrics. Unknown MAC -> safe fallback iPXE (200), unknown KS -> 404.
- Secrets (root pw hash, ssh keys) injected at render time from env/Vault, never
  NetBox. Config is env-based per estate convention.
- Makefile (build/test/lint/docker + patch/minor/major), Dockerfile (distroless),
  .woodpecker (pre-commit, golangci-lint v2 + go test -race, docker build on PR;
  image push + Gitea binary release on v* tag), docs/ and example config.

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 17:20:06 +10:00
parent 98e69d2fcb
commit 274c480b09
37 changed files with 3290 additions and 1 deletions
+4
View File
@@ -0,0 +1,4 @@
/bin/
/dist/
*.out
*.test
+27
View File
@@ -0,0 +1,27 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-added-large-files
- id: check-merge-conflict
- repo: https://github.com/dnephin/pre-commit-golang
rev: v0.5.1
hooks:
- id: go-fmt
- id: go-mod-tidy
# bootapi has no root-level Go files (all under cmd/, internal/, templates/),
# so the dnephin go-vet hook (which runs `go vet` at the repo root) fails with
# "no Go files". Vet the whole module instead, mirroring encapi.
- repo: local
hooks:
- id: go-vet
name: go vet
entry: go vet ./...
language: system
types: [go]
pass_filenames: false
+19
View File
@@ -0,0 +1,19 @@
when:
- event: pull_request
steps:
- name: docker-build
image: woodpeckerci/plugin-docker-buildx
settings:
repo: git.unkin.net/unkin/bootapi
dry_run: true
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 512Mi
cpu: 1
limits:
memory: 2Gi
cpu: 2
+28
View File
@@ -0,0 +1,28 @@
when:
- event: tag
ref: refs/tags/v*
steps:
- name: docker-bootapi
image: woodpeckerci/plugin-docker-buildx
settings:
registry: git.unkin.net
repo: git.unkin.net/unkin/bootapi
build_args:
VERSION: ${CI_COMMIT_TAG}
username: droneci
password:
from_secret: DRONECI_PASSWORD
tags:
- ${CI_COMMIT_TAG}
- latest
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 512Mi
cpu: 1
limits:
memory: 2Gi
cpu: 2
+18
View File
@@ -0,0 +1,18 @@
when:
- event: pull_request
steps:
- name: pre-commit
image: git.unkin.net/unkin/almalinux9-gobuilder:20260606
commands:
- uvx pre-commit run --all-files
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 512Mi
cpu: 1
limits:
memory: 2Gi
cpu: 2
+79
View File
@@ -0,0 +1,79 @@
when:
- event: tag
ref: refs/tags/v*
# Cuts a Gitea release with cross-compiled bootapi binaries attached. The
# container image is built+pushed separately by docker.yaml.
steps:
- name: test
image: golang:1.25
commands:
- go test -race ./...
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 512Mi
cpu: 1
limits:
memory: 2Gi
cpu: 2
- name: build
image: git.unkin.net/unkin/almalinux9-gobuilder:20260606
commands:
- make release-binaries VERSION=${CI_COMMIT_TAG}
depends_on: [test]
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 512Mi
cpu: 1
limits:
memory: 2Gi
cpu: 2
- name: release
image: git.unkin.net/unkin/almalinux9-base:20260606
environment:
RELEASER_TOKEN:
from_secret: RELEASER_TOKEN
commands:
- |
curl --output /usr/local/bin/tea https://artifactapi.k8s.syd1.au.unkin.net/api/v1/remote/gitea-dl/tea/0.12.0/tea-0.12.0-linux-amd64 && chmod +x /usr/local/bin/tea
tea logins add --name gitea --url https://git.unkin.net --token "$${RELEASER_TOKEN}" --no-version-check
# $$ escapes shell vars so Woodpecker doesn't substitute them at parse
# time; ${CI_COMMIT_TAG}/${CI_REPO} are real Woodpecker vars.
CUR_SHA=$$(git rev-list -n1 "${CI_COMMIT_TAG}")
PREV_TAG=""
for t in $$(git tag --sort=-v:refname); do
[ "$$t" = "${CI_COMMIT_TAG}" ] && continue
[ "$$(git rev-list -n1 "$$t")" = "$$CUR_SHA" ] && continue
if git merge-base --is-ancestor "$$t" "${CI_COMMIT_TAG}" 2>/dev/null; then
PREV_TAG="$$t"; break
fi
done
if [ -n "$$PREV_TAG" ]; then
NOTES=$$(git log "$${PREV_TAG}..${CI_COMMIT_TAG}" --pretty=format:"- %s")
else
NOTES=$$(git log --pretty=format:"- %s")
fi
tea releases create --tag "${CI_COMMIT_TAG}" --title "${CI_COMMIT_TAG}" --note "$${NOTES}" --login gitea --repo "${CI_REPO}"
ASSETS="dist/bootapi-linux-amd64 dist/bootapi-linux-arm64 dist/bootapi-darwin-amd64 dist/bootapi-darwin-arm64"
sha256sum $$ASSETS > dist/sha256sums.txt
tea releases assets create "${CI_COMMIT_TAG}" $$ASSETS dist/sha256sums.txt \
--login gitea --repo "${CI_REPO}"
depends_on: [build]
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 128Mi
cpu: 100m
limits:
memory: 512Mi
cpu: 500m
+33
View File
@@ -0,0 +1,33 @@
when:
- event: pull_request
steps:
- name: lint
image: golangci/golangci-lint:v2.5.0
commands:
- golangci-lint run ./...
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 512Mi
cpu: 1
limits:
memory: 2Gi
cpu: 2
- name: test
image: golang:1.25
commands:
- go test -race ./...
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 512Mi
cpu: 1
limits:
memory: 2Gi
cpu: 2
+21
View File
@@ -0,0 +1,21 @@
FROM golang:1.25-alpine AS builder
RUN apk add --no-cache git
WORKDIR /build
COPY go.mod go.sum ./
RUN go mod download
COPY . .
ARG VERSION=dev
RUN CGO_ENABLED=0 go build -ldflags="-s -w -X main.version=${VERSION}" -o bootapi ./cmd/bootapi
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /build/bootapi /usr/local/bin/bootapi
EXPOSE 8000
ENTRYPOINT ["bootapi"]
+77
View File
@@ -0,0 +1,77 @@
.PHONY: build test test-race lint fmt vet docker run clean tidy check-go release-binaries patch minor major
MODULE := git.unkin.net/unkin/bootapi
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "0.0.0-dev")
DIST := dist
OS ?= $(shell go env GOOS)
ARCH ?= $(shell go env GOARCH)
GO_VERSION_REQUIRED := 1.25
GO_VERSION_ACTUAL := $(shell go version | sed 's/go version go\([0-9]*\.[0-9]*\).*/\1/')
check-go:
@if [ "$$(printf '%s\n%s' "$(GO_VERSION_REQUIRED)" "$(GO_VERSION_ACTUAL)" | sort -V | head -1)" != "$(GO_VERSION_REQUIRED)" ]; then \
echo "ERROR: Go >= $(GO_VERSION_REQUIRED) required, found $(GO_VERSION_ACTUAL)"; exit 1; \
fi
build: check-go tidy
go build -ldflags="-s -w -X main.version=$(VERSION)" -o bin/bootapi ./cmd/bootapi
test: check-go
go test -count=1 ./...
test-race: check-go
go test -race -count=1 ./...
# golangci-lint v2 runs in CI via the golangci/golangci-lint container; locally
# `make vet` is the quick check and `make lint` runs golangci-lint if present.
vet: check-go
go vet ./...
lint: check-go
@if command -v golangci-lint >/dev/null 2>&1; then golangci-lint run ./...; else echo "golangci-lint not installed; running go vet"; go vet ./...; fi
fmt: check-go
gofmt -w .
docker:
docker build -t bootapi:$(VERSION) .
run: build
./bin/bootapi
# Cross-compiled binaries attached to the Gitea release (mirrors node-lookup).
release-binaries: check-go
@mkdir -p $(DIST)
@for osarch in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64; do \
os=$${osarch%/*}; arch=$${osarch#*/}; \
echo "building bootapi-$$os-$$arch"; \
CGO_ENABLED=0 GOOS=$$os GOARCH=$$arch \
go build -ldflags="-s -w -X main.version=$(VERSION)" \
-o "$(DIST)/bootapi-$$os-$$arch" ./cmd/bootapi; \
done
clean:
rm -rf bin/ $(DIST)/
tidy:
go mod tidy
# --- version bump: tag + push triggers the release pipeline ---
_LATEST := $(shell git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$$' | head -1)
_BASE := $(if $(_LATEST),$(_LATEST),v0.0.0)
_MAJ := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f1)
_MIN := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f2)
_PAT := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f3)
patch:
@NEW=v$(_MAJ).$(_MIN).$(shell expr $(_PAT) + 1); \
git tag $$NEW && echo "Tagged $$NEW" && git push origin $$NEW
minor:
@NEW=v$(_MAJ).$(shell expr $(_MIN) + 1).0; \
git tag $$NEW && echo "Tagged $$NEW" && git push origin $$NEW
major:
@NEW=v$(shell expr $(_MAJ) + 1).0.0; \
git tag $$NEW && echo "Tagged $$NEW" && git push origin $$NEW
+78 -1
View File
@@ -1,3 +1,80 @@
# bootapi
PXE/kickstart boot service replacing Cobbler. Renders kickstart + iPXE from NetBox data over HTTP. Go API.
`bootapi` is a small Go service that replaces Cobbler's PXE/kickstart side. It
renders kickstart files and iPXE boot scripts from **NetBox** device data and
serves them over HTTP to PXE-booting hosts.
The ENC half of Cobbler already moved to [encapi](https://git.unkin.net/unkin/encapi).
With bootapi, provisioning a new host is:
1. `terraform` the host into **NetBox** (device, interfaces/MACs, IP, platform,
role) and **encapi** (classification), then
2. rack it and let it **PXE-boot** — DHCP points it at bootapi, which serves the
iPXE script and the rendered kickstart; the kickstart hands off to the
existing Puppet firstrun bootstrap.
## How it works
```
DHCP next-server ─▶ iPXE ─▶ GET /ipxe/<mac> ─▶ boot kernel+initrd, inst.ks=<bootapi>/ks/<host>
└▶ GET /ks/<host> ─▶ rendered kickstart ─▶ puppet firstrun
```
bootapi identifies the booting host by the **MAC** it booted from (NetBox
interface lookup → device → primary IP, platform, role, interfaces), renders a
Go `text/template` selected from the host's platform/role/custom-field, and
serves it. Templates are embedded defaults, overridable from a directory
(ConfigMap in k8s).
## Endpoints (summary)
| Path | Purpose |
|------|---------|
| `GET /ipxe/{mac}` · `GET /boot/ipxe?mac=` | iPXE boot script |
| `GET /ks/{ident}` | rendered kickstart (MAC or hostname) |
| `GET /healthz` · `/readyz` · `/metrics` | health + Prometheus |
Unknown MAC → iPXE gets a **safe fallback** (local-disk boot, HTTP 200), never a
404. Unknown kickstart host → **404** (fail loud once installing). Full rationale
in [docs/endpoints.md](docs/endpoints.md).
## Documentation
- [docs/endpoints.md](docs/endpoints.md) — the PXE flow, every endpoint, error/fallback behavior, metrics.
- [docs/data-model.md](docs/data-model.md) — the exact template data model + NetBox custom fields + template selection.
- [docs/template-authoring.md](docs/template-authoring.md) — writing/overriding kickstart & iPXE templates.
- [docs/deployment.md](docs/deployment.md) — Kubernetes/argocd wiring, Vault secrets, and the DHCP cutover.
- [docs/security.md](docs/security.md) — what belongs in NetBox vs Vault; secrets in kickstarts.
## Configuration
Env-based (12-factor), see [`config.example.env`](config.example.env). Key vars:
`BOOTAPI_NETBOX_URL`, `BOOTAPI_NETBOX_TOKEN[_FILE]`, `BOOTAPI_BASE_URL`,
`BOOTAPI_BOOT_BASE_URL`, `BOOTAPI_ROOT_PASSWORD_HASH[_FILE]`,
`BOOTAPI_UNKNOWN_MAC_FALLBACK`.
## Development
```bash
make build # build ./bin/bootapi
make test-race # go test -race ./...
make lint # golangci-lint (v2) if installed, else go vet
make run # build + run
```
CI (Woodpecker): `pre-commit`, `golangci-lint v2` + `go test -race`, and a
docker build on PRs; on a `v*` tag, a container image push and a Gitea binary
release. Cut a release with `make patch|minor|major` (tags + pushes).
## Layout
```
cmd/bootapi/ main
internal/config/ env config
internal/model/ Host/Interface data model
internal/netbox/ NetBox client (+ TTL cache), behind a Resolver interface
internal/render/ text/template engine, selection, embedded-defaults loader
internal/server/ chi HTTP handlers + Prometheus metrics
templates/ embedded default kickstart + iPXE templates
docs/ see above
```
+76
View File
@@ -0,0 +1,76 @@
// Command bootapi is the PXE/kickstart boot service: it renders kickstart files
// and iPXE boot scripts from NetBox device data and serves them to PXE-booting
// hosts, replacing Cobbler's provisioning side.
package main
import (
"context"
"log/slog"
"os"
"os/signal"
"syscall"
"git.unkin.net/unkin/bootapi/internal/config"
"git.unkin.net/unkin/bootapi/internal/netbox"
"git.unkin.net/unkin/bootapi/internal/render"
"git.unkin.net/unkin/bootapi/internal/server"
"git.unkin.net/unkin/bootapi/templates"
)
var version = "dev"
func main() {
slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, nil)))
slog.Info("starting bootapi", "version", version)
cfg, err := config.Load()
if err != nil {
slog.Error("load config", "err", err)
os.Exit(1)
}
if cfg.NetBoxURL == "" {
slog.Warn("BOOTAPI_NETBOX_URL is empty; every host lookup will fail and iPXE will serve the fallback")
}
if cfg.NetBoxToken == "" {
slog.Warn("no NetBox token set (BOOTAPI_NETBOX_TOKEN/_FILE); NetBox reads will likely be denied")
}
engine, err := render.NewEngine(templates.FS, cfg.TemplateDir, render.RenderConfig{
PuppetServer: cfg.PuppetServer,
PuppetCAServer: cfg.PuppetCAServer,
BaseURL: cfg.BaseURL,
BootBaseURL: cfg.BootBaseURL,
DefaultDomain: cfg.Domain,
DefaultNS: cfg.Nameservers,
RootPasswordHash: cfg.RootPasswordHash,
SSHAuthorizedKeys: cfg.SSHAuthorizedKeys,
DefaultTemplate: cfg.DefaultTemplate,
})
if err != nil {
slog.Error("load templates", "err", err)
os.Exit(1)
}
nb := netbox.New(netbox.Options{
BaseURL: cfg.NetBoxURL,
Token: cfg.NetBoxToken,
Timeout: cfg.NetBoxTimeout,
Insecure: cfg.NetBoxInsecure,
})
cache := netbox.NewCache(nb, cfg.CacheTTL)
srv := server.New(server.Options{
Resolver: cache,
Engine: engine,
Cache: cache,
UnknownMACFallback: cfg.UnknownMACFallback,
})
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
if err := srv.ListenAndServe(ctx, cfg.ListenAddr); err != nil {
slog.Error("server", "err", err)
os.Exit(1)
}
}
+52
View File
@@ -0,0 +1,52 @@
# bootapi configuration (environment variables).
#
# bootapi is configured entirely from the environment (12-factor style, same as
# encapi). In Kubernetes these come from the Deployment env + a Vault-sourced
# Secret (see docs/deployment.md). Locally, `env $(grep -v '^#' config.example.env | xargs) ./bin/bootapi`.
# --- HTTP ---
BOOTAPI_LISTEN_ADDR=:8000
# --- NetBox (source of truth for host -> boot data) ---
BOOTAPI_NETBOX_URL=https://netbox.k8s.syd1.au.unkin.net
# Provide the token inline OR (preferred in k8s) via a file mounted from Vault:
BOOTAPI_NETBOX_TOKEN=
# BOOTAPI_NETBOX_TOKEN_FILE=/var/run/secrets/netbox/api_token
BOOTAPI_NETBOX_TIMEOUT=5s
BOOTAPI_NETBOX_INSECURE=false
# --- caching ---
# Short by design: a re-provisioned host must pick up NetBox changes on its next
# boot. Set 0 to disable.
BOOTAPI_CACHE_TTL=30s
# --- templates ---
# Optional override directory (a ConfigMap mount in k8s); files here win over
# the embedded defaults. Leave empty to use only the built-in templates.
# BOOTAPI_TEMPLATE_DIR=/etc/bootapi/templates
# Template used when NetBox provides no platform/role/override selection key.
BOOTAPI_DEFAULT_TEMPLATE=almalinux9
# --- URLs baked into rendered output ---
# bootapi's own externally-reachable base URL (goes into the iPXE inst.ks=).
BOOTAPI_BASE_URL=http://bootapi.k8s.syd1.au.unkin.net
# Base URL of the OS install trees (kernel/initrd + inst.repo).
BOOTAPI_BOOT_BASE_URL=http://mirror.k8s.syd1.au.unkin.net/almalinux/9
# --- puppet bootstrap targets (baked into kickstart %post) ---
BOOTAPI_PUPPET_SERVER=puppet.query.consul
BOOTAPI_PUPPET_CA_SERVER=puppetca.query.consul
# --- network defaults (used when NetBox does not record them per-device) ---
BOOTAPI_DOMAIN=main.unkin.net
BOOTAPI_NAMESERVERS=198.18.19.19
# --- render-time secrets (NEVER stored in NetBox; from Vault in k8s) ---
# crypt(3) hash for the root account. Empty => root account locked.
BOOTAPI_ROOT_PASSWORD_HASH=
# BOOTAPI_ROOT_PASSWORD_HASH_FILE=/var/run/secrets/bootapi/root_password_hash
# Newline-separated SSH public keys installed for root.
BOOTAPI_SSH_AUTHORIZED_KEYS=
# --- unknown-MAC fallback: "local" (safe: boot local disk) or "shell" (debug) ---
BOOTAPI_UNKNOWN_MAC_FALLBACK=local
+77
View File
@@ -0,0 +1,77 @@
# Template data model
Every kickstart and iPXE template is rendered with Go `text/template` against a
single flat value. This is the exact, stable contract template authors code
against. It is assembled in `internal/render.dataFor` from a NetBox device
(`internal/netbox`) plus render-time config (env/Vault).
## Kickstart templates (`*.ks.tmpl`)
| Field | Type | Source | Notes |
|-------|------|--------|-------|
| `.Hostname` | string | NetBox device name | short name, e.g. `web01` |
| `.Domain` | string | NetBox CF `domain`, else `BOOTAPI_DOMAIN` | |
| `.FQDN` | string | derived | `Hostname.Domain` (or `Hostname` if no domain) |
| `.Platform` | string | NetBox platform slug | e.g. `almalinux9` — primary template-selection key |
| `.OSFamily` | string | derived from platform | e.g. `almalinux` |
| `.OSVersion` | string | derived from platform | e.g. `9` |
| `.Arch` | string | fixed `x86_64` (today) | |
| `.Role` | string | NetBox device role slug | e.g. `kubernetes-worker` |
| `.Interfaces` | `[]Interface` | NetBox interfaces + IPs | primary interface sorted first |
| `.PrimaryInterface` | `*Interface` | derived | the NIC carrying the primary IP (or first) |
| `.PrimaryIP` | string | NetBox device `primary_ip` | address only, no prefix |
| `.Nameservers` | `[]string` | NetBox CF `nameservers`, else `BOOTAPI_NAMESERVERS` | |
| `.RootPasswordHash` | string | **render-time** (`BOOTAPI_ROOT_PASSWORD_HASH[_FILE]`) | crypt(3) hash; empty ⇒ lock root. **Never** from NetBox — see [security.md](security.md) |
| `.SSHAuthorizedKeys` | `[]string` | **render-time** (`BOOTAPI_SSH_AUTHORIZED_KEYS`) | |
| `.PuppetServer` | string | `BOOTAPI_PUPPET_SERVER` | default `puppet.query.consul` |
| `.PuppetCAServer` | string | `BOOTAPI_PUPPET_CA_SERVER` | default `puppetca.query.consul` |
| `.BaseURL` | string | `BOOTAPI_BASE_URL` | bootapi's own URL |
| `.BootBaseURL` | string | `BOOTAPI_BOOT_BASE_URL` | OS install-tree base |
| `.KickstartURL` | string | derived | `BaseURL/ks/Hostname` |
| `.Custom` | `map[string]any` | **all** NetBox custom fields, verbatim | escape hatch for site-specific knobs without a code change |
### `Interface`
| Field | Type | Notes |
|-------|------|-------|
| `.Name` | string | NetBox interface name, e.g. `eth0` |
| `.MAC` | string | normalized lower-case colon form |
| `.IP` | string | address only (empty ⇒ no IP; skip in the network stanza) |
| `.PrefixLen` | int | CIDR length, e.g. `24` |
| `.Netmask` | string | dotted-quad, e.g. `255.255.255.0` |
| `.Gateway` | string | per-IP CF `gateway`, else device CF `gateway`, else empty |
| `.VLAN` | int | untagged VLAN id, or 0 |
| `.Primary` | bool | true for the NIC with the primary IP |
## iPXE templates (`*.ipxe.tmpl`)
Rendered with everything above **plus**:
| Field | Type | Notes |
|-------|------|-------|
| `.KernelURL` | string | `BootBaseURL/images/pxeboot/vmlinuz` (empty if `BootBaseURL` unset) |
| `.InitrdURL` | string | `BootBaseURL/images/pxeboot/initrd.img` |
The fallback templates (`fallback-local`, `fallback-shell`) are rendered with an
empty value — they take no host data by design.
## NetBox custom fields bootapi reads
Define these on the *device* (or, where noted, the *IP address*) in NetBox.
All are optional; sensible fallbacks apply.
| Custom field | On | Effect |
|--------------|----|--------|
| `domain` | device | DNS domain; overrides `BOOTAPI_DOMAIN` |
| `gateway` | device / IP address | default gateway (IP-level wins) |
| `nameservers` | device | comma-separated resolvers; overrides `BOOTAPI_NAMESERVERS` |
| `provision_template` | device | force a specific template name (see below) |
## Template selection precedence
`SelectKickstart` picks the first template name that exists, in order:
1. `provision_template` custom field (exact template name)
2. `.Platform` slug (e.g. `almalinux9`)
3. `.OSFamily` (e.g. `almalinux`, or `fedora`)
4. `BOOTAPI_DEFAULT_TEMPLATE` (default `almalinux9`)
+79
View File
@@ -0,0 +1,79 @@
# Deploying bootapi
> The actual argocd-apps deployment is a **follow-up task** and is intentionally
> not part of this repo. This document is the spec for that follow-up plus the
> DHCP change the estate needs.
bootapi is a stateless HTTP service. It mirrors encapi's deployment shape: a Go
binary in a distroless image, config from env, secrets from Vault via the Vault
Secrets Operator (VSO).
## Container image
`git.unkin.net/unkin/bootapi:<tag>` (built + pushed by `.woodpecker/docker.yaml`
on a `v*` tag). Also mirror to the artifactapi local docker registry if desired.
## Kubernetes wiring (argocd-apps follow-up)
Create `apps/base/bootapi/` following the argocd-apps `AGENTS.md` pattern:
1. **namespace** `bootapi`.
2. **VaultAuth** (`default`) — kubernetes method, mount `k8s/au/syd1`, role
`default`, SA `default` (copy netbox's `vaultauth.yaml`).
3. **VaultStaticSecret** → k8s Secret `bootapi-secrets`, from Vault kv path
`kubernetes/namespace/bootapi/default/bootapi-secrets` with keys:
- `netbox_token` — a **dedicated, read-only** NetBox API token for bootapi
(create a `bootapi` NetBox user/token via terraform-netbox rather than
reusing the seeded superuser token at
`kv/kubernetes/namespace/netbox/default/netbox-superuser`).
- `root_password_hash` — crypt(3) hash for the installed root account
(the successor to Cobbler's eyaml `default_password_crypted`).
- `ssh_authorized_keys` — optional, newline-separated.
4. **ConfigMap** `bootapi-templates` (optional) — override `*.ks.tmpl` /
`*.ipxe.tmpl`, mounted at `BOOTAPI_TEMPLATE_DIR=/etc/bootapi/templates`. Omit
to use the embedded defaults. Annotate the Deployment with
`reloader.stakater.com/auto: "true"` so template edits roll the pods.
5. **Deployment** — image above, env from `config.example.env`, secret keys wired
as `BOOTAPI_NETBOX_TOKEN_FILE`/`BOOTAPI_ROOT_PASSWORD_HASH_FILE` (mount the
Secret) or `...FROM secretKeyRef`. Least-privilege securityContext
(`runAsNonRoot`, `drop: [all]`). Baseline resources: requests `512Mi`/`1`,
limits `2Gi`/`2` cpu.
6. **Service** `bootapi` (ClusterIP, port 80 → 8000) plus a **LoadBalancer** (or
Gateway HTTPRoute) reachable by PXE clients at a stable address/hostname —
this is what DHCP points at. Reuse the Vault-issued TLS the Cobbler vhost used
if you terminate TLS at a gateway; note that iPXE fetches are plain HTTP, so a
plain HTTP listener on the PXE VLAN is required either way.
7. Register in `argocd/applicationsets/platform.yaml` (`apps/overlays/*/bootapi`)
and the platform AppProject destinations.
### Cross-repo dependencies (per estate conventions)
- **argocd-apps**: add a `serviceaccount_*` under `apps/base/woodpecker/` if the
bootapi pipelines need a dedicated SA (they use `default` today).
- **terraform-vault**: add the k8s auth role + kv policy granting the `bootapi`
namespace read on `kv/kubernetes/namespace/bootapi/default/*`.
- **terraform-netbox**: create the read-only `bootapi` NetBox token and seed it
(plus `root_password_hash`) into the Vault kv path above.
## DHCP change (the cutover)
Cobbler advertised itself at anycast `198.18.19.19` as the DHCP `next-server`,
with `filename "/ipxe.efi"` (UEFI arch 7/9) or `/undionly.kpxe` (BIOS arch 0).
Today those are set in `puppet-prod` hieradata
`hieradata/roles/infra/dhcp/server.yaml` (`pools.*.pxeserver` and the
`UEFI-64`/`Legacy` dhcp classes).
To cut a subnet over to bootapi, repoint DHCP for that pool:
- `next-server` → bootapi's LB IP (or keep the `198.18.19.19` anycast and move
the anycast advertisement to bootapi's node/LB).
- `filename` → the iPXE binary as before (`/ipxe.efi` / `/undionly.kpxe`); bootapi
does not serve the NBP itself. The chained iPXE must then be told to fetch
bootapi's script — either bake `chain http://<bootapi>/ipxe/${net0/mac}` into
the site iPXE binary/embedded script, or set DHCP option 67 to that URL for
iPXE user-class requests. This replaces Cobbler's
`chain http://${next-server}/cblr/svc/op/gpxe/mac/${net0/mac}`.
Roll one pool at a time (the PXE subnets are `198.18.13.0/24``198.18.17.0/24`);
Puppet autosign already trusts those subnets and `*.main.unkin.net`, so a host
installed via bootapi checks in exactly as before.
+68
View File
@@ -0,0 +1,68 @@
# bootapi HTTP endpoints
bootapi speaks plain HTTP. It is fronted by the same Vault-issued TLS the Cobbler
server used; the booting firmware reaches it at the DHCP `next-server` (see
[deployment.md](deployment.md)).
## The PXE flow
```
DHCP ── next-server + filename (ipxe.efi / undionly.kpxe) ──▶ firmware loads iPXE
iPXE ── GET /ipxe/<mac> ───────────────────────────────────▶ bootapi renders a boot script
boot ── kernel + initrd + inst.ks=<BASE_URL>/ks/<host> ────▶ Anaconda fetches the kickstart
KS ── GET /ks/<host> ────────────────────────────────────▶ bootapi renders the kickstart
```
This mirrors Cobbler, which chained iPXE to `/cblr/svc/op/gpxe/mac/<mac>` and
served a per-system script carrying `inst.ks=`.
## Endpoints
| Method | Path | Purpose |
|--------|------|---------|
| GET | `/ipxe/{mac}` | iPXE boot script for the host owning `{mac}`. `{mac}` may use `:`/`-`/`.` separators or be bare hex; a trailing `.ipxe` is stripped. |
| GET | `/boot/ipxe?mac=...` | Query-string alias of `/ipxe/{mac}` (some firmware finds this shape easier to template). |
| GET | `/ks/{ident}` | Rendered kickstart. `{ident}` is a MAC (auto-detected) or a hostname; trailing `.ks`/`.cfg` is stripped. |
| 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 | `/metrics` | Prometheus metrics (see below). |
## Host identification
A booting host is identified by the **MAC** of the NIC it PXE-booted from
(`/ipxe/{mac}`), which bootapi resolves via NetBox
`GET /api/dcim/interfaces/?mac_address=<mac>` → device → primary IP, platform,
role, interfaces. `/ks/{ident}` additionally accepts a **hostname** (NetBox
device name), for hand-testing and for installers that template the hostname
into the kickstart URL.
## Error behavior (important, and deliberate)
The two endpoints fail **differently** on an unknown host, because the cost of a
wrong answer differs:
- **`/ipxe/{mac}` never returns 404.** iPXE needs a syntactically valid script or
the boot chain simply errors. An unknown MAC — or *any* NetBox error — returns
HTTP 200 with the **fallback script** selected by `BOOTAPI_UNKNOWN_MAC_FALLBACK`:
- `local` (default): `sanboot` the local disk. Safe: a machine that PXE-booted
by accident (or a NetBox blip) just boots its installed OS; a genuinely new
machine loops back to PXE next time, by which point NetBox should know it. We
deliberately do **not** start an installer for a machine we can't identify —
that could wipe a production box.
- `shell`: drop to an interactive iPXE shell so an operator racking a new box
can read `${net0/mac}` and register it. Opt-in; unsafe as a default because
it halts the boot.
- **`/ks/{ident}` returns 404** for an unknown host (and 502 on a NetBox error).
By the time Anaconda fetches the kickstart it has already committed to
installing; a clear failure is safer than serving an empty or wrong kickstart.
## Metrics
All on `/metrics`, prefix `bootapi_`:
- `bootapi_http_requests_total{endpoint,status}` — endpoint = `ipxe|ks|healthz|readyz`, status = `2xx|3xx|4xx|5xx`.
- `bootapi_render_total{kind,result}` — kind = `kickstart|ipxe`, result = `ok|error`.
- `bootapi_netbox_lookups_total{field,result}` — field = `mac|name`, result = `ok|notfound|error`.
- `bootapi_netbox_lookup_duration_seconds{field}` — histogram.
- `bootapi_netbox_cache_hits_total` / `bootapi_netbox_cache_misses_total`.
- standard Go/process collectors.
+49
View File
@@ -0,0 +1,49 @@
# Security: secrets in kickstarts
A kickstart is fetched over the network by an unauthenticated installer and can
embed real secrets: the root password hash, SSH keys, bootstrap tokens, repo
credentials. bootapi's rule is:
**NetBox holds identity and topology, never secrets. Secrets are injected at
render time from Vault/env.**
## What goes where
| Value | Where it lives | How it reaches the template |
|-------|----------------|-----------------------------|
| hostname, domain, IPs, MACs, VLANs, gateway, platform, role | NetBox | `internal/netbox``model.Host` |
| template selection knobs (`provision_template`, `nameservers`, `gateway`) | NetBox custom fields | `.Custom` / typed fields |
| **root password hash** | Vault → `BOOTAPI_ROOT_PASSWORD_HASH[_FILE]` | `.RootPasswordHash` |
| **SSH authorized keys** | Vault → `BOOTAPI_SSH_AUTHORIZED_KEYS` | `.SSHAuthorizedKeys` |
| puppet CA/server names | env (not secret) | `.PuppetServer` / `.PuppetCAServer` |
`BOOTAPI_ROOT_PASSWORD_HASH_FILE` and `BOOTAPI_NETBOX_TOKEN_FILE` let the values
arrive as Vault-mounted files rather than env, which is the k8s norm (see
[deployment.md](deployment.md)). If no root hash is configured, the default
templates emit `rootpw --lock` rather than a blank/guessable password.
This matches the pre-bootapi setup, where the root hash was Cobbler's eyaml
`default_password_crypted` injected into `settings.yaml` — an operator-managed
secret, never in the NetBox/inventory layer.
## Exposure notes
- Kickstarts are served over **HTTP** to the installer, so treat any embedded
secret as visible to anything on the provisioning VLAN. Keep bootapi's
kickstart endpoint on the trusted PXE network, exactly as Cobbler's was.
- The root password hash *is* in the rendered kickstart by necessity (Anaconda
needs it). Prefer SSH-key login + a locked or strong-random root password, and
rotate the hash in Vault as normal.
- The **puppet bootstrap uses no long-lived token**: the host generates a CSR and
the puppetmaster autosigns it based on source subnet + `*.main.unkin.net`
(unchanged from Cobbler). So the kickstart carries no puppet secret.
## Follow-up: per-template Vault lookups
Today all render-time secrets are process-wide env/files (one root hash, one key
set for the fleet), which covers the current estate. If per-host or per-role
secrets are ever needed (e.g. a distinct bootstrap token per role), the seam is
`internal/render.dataFor`: add a Vault kv fetch keyed by host/role there, behind
an interface, the same way encapi's `internal/distro` resolver injects per-host
params behind an interface. Tracked as a follow-up, not implemented, to avoid
giving bootapi broad Vault read scope before it's needed.
+71
View File
@@ -0,0 +1,71 @@
# Authoring templates
bootapi ships an embedded default set and lets you override or extend it.
## Where templates live
- **Embedded defaults**: `templates/kickstart/*.ks.tmpl` and
`templates/ipxe/*.ipxe.tmpl`, compiled into the binary (`templates/embed.go`).
- **Overrides**: any directory pointed to by `BOOTAPI_TEMPLATE_DIR`. Files there
with the same base name **replace** the embedded one; new names **add** to the
set. In Kubernetes this is a ConfigMap mount (see [deployment.md](deployment.md)).
## Naming
- Kickstart: `<name>.ks.tmpl` → registered as template `<name>`.
- iPXE: `<name>.ipxe.tmpl` → registered as template `<name>`.
`<name>` is what template selection matches against (platform slug, OS family,
`provision_template`, or the configured default — see
[data-model.md](data-model.md#template-selection-precedence)).
Reserved iPXE names bootapi renders directly:
- `boot` — the per-host boot script (`/ipxe/{mac}` for a known host).
- `fallback-local`, `fallback-shell` — unknown-MAC fallbacks.
## Engine and functions
Standard Go `text/template`. Available funcs: `join`, `upper`, `lower`,
`default` (`{{ default "x" .Maybe }}``.Maybe` unless empty). The data model is
in [data-model.md](data-model.md).
Example network stanza (iterate interfaces, skip those without an IP, set the
hostname on the primary):
```gotemplate
{{- $primary := .PrimaryInterface }}
{{- range .Interfaces }}
{{- if .IP }}
network --bootproto=static --device={{ .MAC }} --ip={{ .IP }} --netmask={{ .Netmask }}{{ if .Gateway }} --gateway={{ .Gateway }}{{ end }}{{ range $.Nameservers }} --nameserver={{ . }}{{ end }}{{ if and $primary (eq .MAC $primary.MAC) }} --hostname={{ $.FQDN }}{{ end }} --activate
{{- end }}
{{- end }}
```
## What the default AlmaLinux template does (ported from Cobbler)
The literal `.ks` bodies from the old Cobbler server are not in version control
(they lived in `/var/lib/cobbler/{templates,snippets}` on the Cobbler host). The
embedded `almalinux9.ks.tmpl` reproduces the **contract** that estate relied on:
- static per-interface networking from NetBox, hostname on the primary NIC;
- `rootpw --iscrypted` from the render-time hash (Cobbler's
`default_password_crypted`), or `--lock` when unset;
- a minimal package set + `openssh-server`, `chrony`;
- a `%post` that installs the Puppet agent, points it at `puppet.query.consul` /
`puppetca.query.consul`, and enables it — handing off to the existing
`profiles::firstrun` Puppet bootstrap and autosign, exactly as the Cobbler
kickstart did.
Adjust partitioning, package sets and repos to taste; keep the puppet `%post`
handoff so a freshly-installed host still checks in and converges.
## Testing a template locally
```bash
BOOTAPI_NETBOX_URL=... BOOTAPI_NETBOX_TOKEN=... \
BOOTAPI_BASE_URL=http://localhost:8000 \
BOOTAPI_BOOT_BASE_URL=http://mirror/almalinux/9 \
BOOTAPI_TEMPLATE_DIR=./mytemplates ./bin/bootapi &
curl -s localhost:8000/ks/web01 # rendered kickstart
curl -s localhost:8000/ipxe/aa:bb:cc:00:11:22
```
+20
View File
@@ -0,0 +1,20 @@
module git.unkin.net/unkin/bootapi
go 1.25
require (
github.com/go-chi/chi/v5 v5.3.0
github.com/prometheus/client_golang v1.23.0
)
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.65.0 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
golang.org/x/sys v0.33.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect
)
+36
View File
@@ -0,0 +1,36 @@
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/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
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/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
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/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/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
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/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.23.0 h1:ust4zpdl9r4trLY/gSjlm07PuiBq2ynaXXlptpfy8Uc=
github.com/prometheus/client_golang v1.23.0/go.mod h1:i/o0R9ByOnHX0McrTMTyhYvKE4haaf2mW08I+jGAjEE=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE=
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/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
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/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
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/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+163
View File
@@ -0,0 +1,163 @@
// Package config loads bootapi server configuration from the environment,
// following the same env-first convention as encapi.
package config
import (
"fmt"
"os"
"strings"
"time"
)
// Config is the fully-resolved server configuration.
type Config struct {
// ListenAddr is the HTTP bind address, e.g. ":8000".
ListenAddr string
// NetBoxURL is the base URL of the NetBox API,
// e.g. "https://netbox.k8s.syd1.au.unkin.net".
NetBoxURL string
// NetBoxToken is the NetBox API token. Prefer NetBoxTokenFile in k8s.
NetBoxToken string
// NetBoxTimeout bounds each NetBox HTTP request.
NetBoxTimeout time.Duration
// NetBoxInsecure disables TLS verification against NetBox (dev only).
NetBoxInsecure bool
// CacheTTL is how long a resolved host is cached in memory. Short by
// design: NetBox is the source of truth and a machine's provisioning data
// can change between boots.
CacheTTL time.Duration
// TemplateDir, when set, is a directory of override templates layered on
// top of the embedded defaults (a Kubernetes ConfigMap mount in prod).
TemplateDir string
// DefaultTemplate is the kickstart template used when NetBox provides no
// platform/role/override selection key.
DefaultTemplate string
// BaseURL is bootapi's own externally-reachable base URL, baked into the
// iPXE script's inst.ks= and repo URLs so a booting host calls back here.
// e.g. "http://bootapi.k8s.syd1.au.unkin.net".
BaseURL string
// BootBaseURL is the base URL of the OS install trees (kernel/initrd +
// inst.repo), e.g. "http://mirror.k8s.syd1.au.unkin.net/almalinux".
BootBaseURL string
// PuppetServer / PuppetCAServer are baked into kickstart %post so the
// freshly-installed host checks in to the right place.
PuppetServer string
PuppetCAServer string
// Domain is the default DNS domain applied when NetBox does not record one
// for a device.
Domain string
// Nameservers is the default resolver list applied when NetBox records
// none for a device.
Nameservers []string
// RootPasswordHash is a crypt(3) hash injected into kickstarts at render
// time (sourced from Vault in k8s). Empty locks the root account.
RootPasswordHash string
// SSHAuthorizedKeys are public keys installed for root at render time.
SSHAuthorizedKeys []string
// UnknownMACFallback selects what the iPXE endpoint returns for a MAC that
// NetBox does not know: "local" (chain to local disk, the safe default) or
// "shell" (drop to an iPXE shell for debugging). See docs/endpoints.md.
UnknownMACFallback string
}
// Load reads configuration from the environment, applying defaults, and reads a
// token file when BOOTAPI_NETBOX_TOKEN_FILE is set (Vault-mounted secret).
func Load() (*Config, error) {
cacheTTL, err := time.ParseDuration(getenv("BOOTAPI_CACHE_TTL", "30s"))
if err != nil {
return nil, fmt.Errorf("invalid BOOTAPI_CACHE_TTL: %w", err)
}
nbTimeout, err := time.ParseDuration(getenv("BOOTAPI_NETBOX_TIMEOUT", "5s"))
if err != nil {
return nil, fmt.Errorf("invalid BOOTAPI_NETBOX_TIMEOUT: %w", err)
}
token := os.Getenv("BOOTAPI_NETBOX_TOKEN")
if tf := os.Getenv("BOOTAPI_NETBOX_TOKEN_FILE"); tf != "" {
b, err := os.ReadFile(tf)
if err != nil {
return nil, fmt.Errorf("read BOOTAPI_NETBOX_TOKEN_FILE %q: %w", tf, err)
}
token = strings.TrimSpace(string(b))
}
fallback := getenv("BOOTAPI_UNKNOWN_MAC_FALLBACK", "local")
if fallback != "local" && fallback != "shell" {
return nil, fmt.Errorf("invalid BOOTAPI_UNKNOWN_MAC_FALLBACK %q: want \"local\" or \"shell\"", fallback)
}
rootHash := os.Getenv("BOOTAPI_ROOT_PASSWORD_HASH")
if rf := os.Getenv("BOOTAPI_ROOT_PASSWORD_HASH_FILE"); rf != "" {
b, err := os.ReadFile(rf)
if err != nil {
return nil, fmt.Errorf("read BOOTAPI_ROOT_PASSWORD_HASH_FILE %q: %w", rf, err)
}
rootHash = strings.TrimSpace(string(b))
}
return &Config{
ListenAddr: getenv("BOOTAPI_LISTEN_ADDR", ":8000"),
NetBoxURL: strings.TrimRight(os.Getenv("BOOTAPI_NETBOX_URL"), "/"),
NetBoxToken: token,
NetBoxTimeout: nbTimeout,
NetBoxInsecure: getenv("BOOTAPI_NETBOX_INSECURE", "false") == "true",
CacheTTL: cacheTTL,
TemplateDir: os.Getenv("BOOTAPI_TEMPLATE_DIR"),
DefaultTemplate: getenv("BOOTAPI_DEFAULT_TEMPLATE", "almalinux9"),
BaseURL: strings.TrimRight(os.Getenv("BOOTAPI_BASE_URL"), "/"),
BootBaseURL: strings.TrimRight(os.Getenv("BOOTAPI_BOOT_BASE_URL"), "/"),
PuppetServer: getenv("BOOTAPI_PUPPET_SERVER", "puppet.query.consul"),
PuppetCAServer: getenv("BOOTAPI_PUPPET_CA_SERVER", "puppetca.query.consul"),
Domain: getenv("BOOTAPI_DOMAIN", "main.unkin.net"),
Nameservers: splitList(os.Getenv("BOOTAPI_NAMESERVERS")),
RootPasswordHash: rootHash,
SSHAuthorizedKeys: splitLines(os.Getenv("BOOTAPI_SSH_AUTHORIZED_KEYS")),
UnknownMACFallback: fallback,
}, nil
}
func getenv(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
// splitList splits a comma-separated env value into a trimmed, non-empty slice.
func splitList(v string) []string {
if v == "" {
return nil
}
var out []string
for _, p := range strings.Split(v, ",") {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
// splitLines splits a newline-separated env value (e.g. multiple SSH keys) into
// a trimmed, non-empty slice.
func splitLines(v string) []string {
if v == "" {
return nil
}
var out []string
for _, p := range strings.Split(v, "\n") {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
+94
View File
@@ -0,0 +1,94 @@
package config
import (
"os"
"path/filepath"
"testing"
"time"
)
func TestLoadDefaults(t *testing.T) {
clearEnv(t)
c, err := Load()
if err != nil {
t.Fatal(err)
}
if c.ListenAddr != ":8000" {
t.Errorf("ListenAddr = %q", c.ListenAddr)
}
if c.CacheTTL != 30*time.Second {
t.Errorf("CacheTTL = %v", c.CacheTTL)
}
if c.DefaultTemplate != "almalinux9" {
t.Errorf("DefaultTemplate = %q", c.DefaultTemplate)
}
if c.PuppetServer != "puppet.query.consul" || c.PuppetCAServer != "puppetca.query.consul" {
t.Errorf("puppet servers = %q / %q", c.PuppetServer, c.PuppetCAServer)
}
if c.UnknownMACFallback != "local" {
t.Errorf("UnknownMACFallback = %q", c.UnknownMACFallback)
}
}
func TestLoadTokenFile(t *testing.T) {
clearEnv(t)
dir := t.TempDir()
tf := filepath.Join(dir, "token")
if err := os.WriteFile(tf, []byte(" secret-token\n"), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("BOOTAPI_NETBOX_TOKEN_FILE", tf)
c, err := Load()
if err != nil {
t.Fatal(err)
}
if c.NetBoxToken != "secret-token" {
t.Errorf("token = %q, want trimmed file contents", c.NetBoxToken)
}
}
func TestLoadRejectsBadFallback(t *testing.T) {
clearEnv(t)
t.Setenv("BOOTAPI_UNKNOWN_MAC_FALLBACK", "bogus")
if _, err := Load(); err == nil {
t.Fatal("expected error for invalid fallback")
}
}
func TestLoadListsAndTrim(t *testing.T) {
clearEnv(t)
t.Setenv("BOOTAPI_NAMESERVERS", " 10.0.0.1, 10.0.0.2 ,")
t.Setenv("BOOTAPI_NETBOX_URL", "https://netbox.example.net/")
c, err := Load()
if err != nil {
t.Fatal(err)
}
if len(c.Nameservers) != 2 || c.Nameservers[1] != "10.0.0.2" {
t.Errorf("nameservers = %v", c.Nameservers)
}
if c.NetBoxURL != "https://netbox.example.net" {
t.Errorf("NetBoxURL trailing slash not trimmed: %q", c.NetBoxURL)
}
}
// clearEnv unsets every BOOTAPI_* var so a developer's shell can't leak into
// the test. t.Setenv restores them after the test.
func clearEnv(t *testing.T) {
t.Helper()
for _, kv := range os.Environ() {
if k, _, ok := cut(kv, '='); ok && len(k) > 8 && k[:8] == "BOOTAPI_" {
// t.Setenv to "" is enough: Load treats empty as unset, and the
// test framework restores the original value on cleanup.
t.Setenv(k, "")
}
}
}
func cut(s string, sep byte) (before, after string, found bool) {
for i := 0; i < len(s); i++ {
if s[i] == sep {
return s[:i], s[i+1:], true
}
}
return s, "", false
}
+100
View File
@@ -0,0 +1,100 @@
// Package model holds the provisioning data model bootapi renders templates
// against. A Host is the normalized view of a NetBox device: enough to build a
// kickstart and an iPXE boot script without the template author needing to know
// anything about NetBox's API shapes.
package model
// Host is the fully-resolved provisioning view of a single machine.
//
// Every field here is safe to reference from a kickstart or iPXE template. The
// zero value of a field means "NetBox did not provide it"; templates should
// guard optional fields (e.g. Gateway) accordingly.
type Host struct {
// Hostname is the short name (NetBox device name), e.g. "web01".
Hostname string
// Domain is the DNS domain the host lives in, e.g. "syd1.au.unkin.net".
Domain string
// FQDN is Hostname joined to Domain when a domain is known, else Hostname.
FQDN string
// Platform is the NetBox platform slug, e.g. "almalinux9". It is the
// primary template-selection key.
Platform string
// OSFamily is a coarse family derived from Platform ("almalinux",
// "fedora", "rocky", ...). Handy for shared template logic.
OSFamily string
// OSVersion is the major version string when derivable, e.g. "9".
OSVersion string
// Arch is the CPU architecture, defaulting to "x86_64".
Arch string
// Role is the NetBox device role slug, e.g. "kubernetes-worker". Available
// as a secondary template-selection key and for %post logic.
Role string
// Interfaces are the host's network interfaces, primary first.
Interfaces []Interface
// PrimaryIP is the address of the primary interface (no prefix length),
// e.g. "10.0.1.20". Empty when NetBox has no primary IP set.
PrimaryIP string
// Nameservers are DNS resolvers to configure, when NetBox provides them
// (via a custom field); otherwise empty and templates fall back to a
// site default.
Nameservers []string
// RootPasswordHash is a crypt(3) hash for the root account, sourced at
// render time (env/Vault), NOT stored in NetBox. Empty means "locked
// account / template default".
RootPasswordHash string
// SSHAuthorizedKeys are public keys to install for root, sourced at render
// time. Empty means none.
SSHAuthorizedKeys []string
// TemplateOverride, when non-empty, names the template to use verbatim,
// bypassing platform/role selection. Sourced from a NetBox custom field.
TemplateOverride string
// Custom carries every NetBox custom field verbatim so templates can read
// site-specific knobs without a code change. Keys are the custom-field
// names as defined in NetBox.
Custom map[string]any
}
// Interface is one network interface of a Host.
type Interface struct {
// Name is the NetBox interface name, e.g. "eth0" / "bond0".
Name string
// MAC is the normalized (lower-case, colon-separated) hardware address.
MAC string
// IP is the interface address without prefix, e.g. "10.0.1.20". Empty for
// interfaces with no assigned address.
IP string
// PrefixLen is the CIDR prefix length of IP, e.g. 24. Zero when unknown.
PrefixLen int
// Netmask is the dotted-quad form of PrefixLen, e.g. "255.255.255.0".
Netmask string
// Gateway is the default gateway for this interface's prefix, when NetBox
// records one on the prefix. Empty otherwise.
Gateway string
// VLAN is the untagged VLAN id of the interface, or 0 when none.
VLAN int
// Primary reports whether this interface holds the device's primary IP.
Primary bool
}
// PrimaryInterface returns the primary interface (the one carrying the primary
// IP), falling back to the first interface, or nil when there are none.
func (h *Host) PrimaryInterface() *Interface {
for i := range h.Interfaces {
if h.Interfaces[i].Primary {
return &h.Interfaces[i]
}
}
if len(h.Interfaces) > 0 {
return &h.Interfaces[0]
}
return nil
}
+89
View File
@@ -0,0 +1,89 @@
package netbox
import (
"context"
"sync"
"sync/atomic"
"time"
"git.unkin.net/unkin/bootapi/internal/model"
)
// Cache wraps a Resolver with a short-TTL in-memory cache. PXE boots come in
// bursts (iPXE fetches the boot script, then the kickstart, then package repos
// hit repeatedly), so even a 30s TTL collapses many NetBox lookups per host
// while keeping the data fresh enough that a re-provisioned host picks up
// changes on its next boot.
type Cache struct {
inner Resolver
ttl time.Duration
now func() time.Time // injectable for tests
mu sync.Mutex
entries map[string]cacheEntry
hits atomic.Int64
misses atomic.Int64
}
// Hits returns the cumulative cache-hit count (published as a metric).
func (c *Cache) Hits() int64 { return c.hits.Load() }
// Misses returns the cumulative cache-miss count (published as a metric).
func (c *Cache) Misses() int64 { return c.misses.Load() }
type cacheEntry struct {
host *model.Host
exp time.Time
}
// NewCache wraps inner with a TTL cache. A non-positive ttl disables caching.
func NewCache(inner Resolver, ttl time.Duration) *Cache {
return &Cache{
inner: inner,
ttl: ttl,
now: time.Now,
entries: map[string]cacheEntry{},
}
}
// HostByMAC returns a cached host or resolves and caches one.
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.inner.HostByMAC(ctx, mac)
})
}
// HostByName returns a cached host or resolves and caches one.
func (c *Cache) HostByName(ctx context.Context, name string) (*model.Host, error) {
return c.lookup(ctx, "name:"+name, func() (*model.Host, error) {
return c.inner.HostByName(ctx, name)
})
}
func (c *Cache) lookup(_ context.Context, key string, resolve func() (*model.Host, error)) (*model.Host, error) {
if c.ttl <= 0 {
return resolve()
}
now := c.now()
c.mu.Lock()
if e, ok := c.entries[key]; ok && now.Before(e.exp) {
c.mu.Unlock()
c.hits.Add(1)
return e.host, nil
}
c.mu.Unlock()
c.misses.Add(1)
// Resolve outside the lock so a slow NetBox call doesn't block cache hits.
host, err := resolve()
if err != nil {
return nil, err
}
c.mu.Lock()
c.entries[key] = cacheEntry{host: host, exp: now.Add(c.ttl)}
c.mu.Unlock()
return host, nil
}
+87
View File
@@ -0,0 +1,87 @@
package netbox
import (
"context"
"errors"
"sync"
"testing"
"time"
"git.unkin.net/unkin/bootapi/internal/model"
)
// countingResolver records how many times the underlying resolver is hit.
type countingResolver struct {
mu sync.Mutex
calls int
host *model.Host
err error
}
func (c *countingResolver) HostByMAC(context.Context, string) (*model.Host, error) {
c.mu.Lock()
defer c.mu.Unlock()
c.calls++
return c.host, c.err
}
func (c *countingResolver) HostByName(context.Context, string) (*model.Host, error) {
return c.HostByMAC(context.Background(), "")
}
func TestCacheHitAndExpiry(t *testing.T) {
inner := &countingResolver{host: &model.Host{Hostname: "web01"}}
cache := NewCache(inner, time.Minute)
now := time.Unix(1000, 0)
cache.now = func() time.Time { return now }
// First call misses and resolves.
if _, err := cache.HostByMAC(context.Background(), "aa:bb:cc:00:11:22"); err != nil {
t.Fatal(err)
}
// Second call within TTL is a hit; the inner resolver is not called again.
if _, err := cache.HostByMAC(context.Background(), "AA:BB:CC:00:11:22"); err != nil {
t.Fatal(err)
}
if inner.calls != 1 {
t.Fatalf("inner calls = %d, want 1 (second served from cache)", inner.calls)
}
if cache.Hits() != 1 || cache.Misses() != 1 {
t.Fatalf("hits=%d misses=%d, want 1/1", cache.Hits(), cache.Misses())
}
// Advance past the TTL -> next call misses and re-resolves.
now = now.Add(2 * time.Minute)
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 after expiry", inner.calls)
}
}
func TestCacheDisabled(t *testing.T) {
inner := &countingResolver{host: &model.Host{Hostname: "web01"}}
cache := NewCache(inner, 0) // ttl <= 0 disables caching
for i := 0; i < 3; i++ {
if _, err := cache.HostByMAC(context.Background(), "aa:bb:cc:00:11:22"); err != nil {
t.Fatal(err)
}
}
if inner.calls != 3 {
t.Fatalf("inner calls = %d, want 3 (cache disabled)", inner.calls)
}
}
func TestCacheDoesNotCacheErrors(t *testing.T) {
inner := &countingResolver{err: ErrNotFound}
cache := NewCache(inner, time.Minute)
for i := 0; i < 2; i++ {
if _, err := cache.HostByMAC(context.Background(), "de:ad:be:ef:00:00"); !errors.Is(err, ErrNotFound) {
t.Fatalf("err = %v, want ErrNotFound", err)
}
}
if inner.calls != 2 {
t.Fatalf("inner calls = %d, want 2 (errors are not cached)", inner.calls)
}
}
+423
View File
@@ -0,0 +1,423 @@
// Package netbox resolves a PXE-booting machine (by MAC or hostname) into the
// normalized model.Host that bootapi renders templates against. It talks to the
// NetBox REST API (v4.x) behind the Resolver interface so the server can be
// tested with an httptest fake.
package netbox
import (
"context"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"net/url"
"strings"
"time"
"git.unkin.net/unkin/bootapi/internal/model"
)
// ErrNotFound is returned when NetBox has no device matching the query. The
// server maps it to an HTTP 404 (and, for iPXE, a safe fallback boot script).
var ErrNotFound = errors.New("netbox: device not found")
// Resolver turns a MAC or hostname into a fully-resolved Host.
type Resolver interface {
HostByMAC(ctx context.Context, mac string) (*model.Host, error)
HostByName(ctx context.Context, name string) (*model.Host, error)
}
// Client is the HTTP-backed Resolver.
type Client struct {
baseURL string
token string
http *http.Client
}
// Options configures a Client.
type Options struct {
BaseURL string
Token string
Timeout time.Duration
Insecure bool
// HTTPClient overrides the constructed client (used by tests).
HTTPClient *http.Client
}
// New builds a NetBox Client.
func New(o Options) *Client {
hc := o.HTTPClient
if hc == nil {
tr := &http.Transport{}
if o.Insecure {
tr.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} //nolint:gosec // opt-in dev flag
}
timeout := o.Timeout
if timeout == 0 {
timeout = 5 * time.Second
}
hc = &http.Client{Timeout: timeout, Transport: tr}
}
return &Client{
baseURL: strings.TrimRight(o.BaseURL, "/"),
token: o.Token,
http: hc,
}
}
// --- NetBox API JSON shapes (only the fields bootapi consumes) ---
type nbList[T any] struct {
Count int `json:"count"`
Results []T `json:"results"`
}
type nbRef struct {
ID int `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
}
type nbVLAN struct {
VID int `json:"vid"`
Name string `json:"name"`
}
type nbMAC struct {
MACAddress string `json:"mac_address"`
}
type nbDevice struct {
ID int `json:"id"`
Name string `json:"name"`
Platform *nbRef `json:"platform"`
Role *nbRef `json:"role"`
Site *nbRef `json:"site"`
PrimaryIP *nbIPRef `json:"primary_ip"`
CustomFields map[string]any `json:"custom_fields"`
}
type nbIPRef struct {
Address string `json:"address"`
}
type nbInterface struct {
ID int `json:"id"`
Name string `json:"name"`
MACAddress string `json:"mac_address"`
PrimaryMACAddress *nbMAC `json:"primary_mac_address"`
UntaggedVLAN *nbVLAN `json:"untagged_vlan"`
Device *nbRef `json:"device"`
}
func (i nbInterface) mac() string {
if i.MACAddress != "" {
return normalizeMAC(i.MACAddress)
}
if i.PrimaryMACAddress != nil {
return normalizeMAC(i.PrimaryMACAddress.MACAddress)
}
return ""
}
type nbIPAddress struct {
Address string `json:"address"`
AssignedObjectID int `json:"assigned_object_id"`
CustomFields map[string]any `json:"custom_fields"`
}
// --- Resolver implementation ---
// HostByMAC finds the device owning an interface with the given MAC and
// resolves it to a Host.
func (c *Client) HostByMAC(ctx context.Context, mac string) (*model.Host, error) {
mac = normalizeMAC(mac)
if mac == "" {
return nil, fmt.Errorf("netbox: empty MAC")
}
var list nbList[nbInterface]
if err := c.get(ctx, "/api/dcim/interfaces/", url.Values{"mac_address": {mac}}, &list); err != nil {
return nil, err
}
var dev *nbRef
for _, i := range list.Results {
if i.mac() == mac && i.Device != nil {
dev = i.Device
break
}
}
if dev == nil {
return nil, ErrNotFound
}
return c.resolveDevice(ctx, dev.ID)
}
// HostByName finds a device by its NetBox name and resolves it to a Host.
func (c *Client) HostByName(ctx context.Context, name string) (*model.Host, error) {
// Strip any domain suffix: NetBox device names are short hostnames.
short := name
if i := strings.IndexByte(short, '.'); i >= 0 {
short = short[:i]
}
var list nbList[nbDevice]
if err := c.get(ctx, "/api/dcim/devices/", url.Values{"name": {short}}, &list); err != nil {
return nil, err
}
if len(list.Results) == 0 {
return nil, ErrNotFound
}
return c.resolveDevice(ctx, list.Results[0].ID)
}
// resolveDevice fetches the device, its interfaces and IP addresses, and
// assembles a Host. It performs three bounded API calls.
func (c *Client) resolveDevice(ctx context.Context, id int) (*model.Host, error) {
var dev nbDevice
if err := c.get(ctx, fmt.Sprintf("/api/dcim/devices/%d/", id), nil, &dev); err != nil {
return nil, err
}
devID := url.Values{"device_id": {fmt.Sprint(id)}}
var ifaces nbList[nbInterface]
if err := c.get(ctx, "/api/dcim/interfaces/", devID, &ifaces); err != nil {
return nil, err
}
var ips nbList[nbIPAddress]
if err := c.get(ctx, "/api/ipam/ip-addresses/", devID, &ips); err != nil {
return nil, err
}
return buildHost(&dev, ifaces.Results, ips.Results), nil
}
// buildHost assembles the normalized Host from raw NetBox objects. It is pure
// (no I/O) so it can be unit-tested directly against fixture structs.
func buildHost(dev *nbDevice, ifaces []nbInterface, ips []nbIPAddress) *model.Host {
cf := dev.CustomFields
domain := cfString(cf, "domain")
h := &model.Host{
Hostname: dev.Name,
Domain: domain,
Custom: cf,
Nameservers: cfStringList(cf, "nameservers"),
TemplateOverride: cfString(cf, "provision_template"),
Arch: "x86_64",
}
if dev.Platform != nil {
h.Platform = dev.Platform.Slug
h.OSFamily, h.OSVersion = splitPlatform(dev.Platform.Slug)
}
if dev.Role != nil {
h.Role = dev.Role.Slug
}
// Index IP addresses by the interface they are assigned to.
ipByIface := map[int]nbIPAddress{}
for _, ip := range ips {
if _, seen := ipByIface[ip.AssignedObjectID]; !seen {
ipByIface[ip.AssignedObjectID] = ip
}
}
primaryAddr := ""
if dev.PrimaryIP != nil {
primaryAddr = dev.PrimaryIP.Address
h.PrimaryIP = addrOnly(primaryAddr)
}
deviceGateway := cfString(cf, "gateway")
for _, in := range ifaces {
iface := model.Interface{
Name: in.Name,
MAC: in.mac(),
}
if in.UntaggedVLAN != nil {
iface.VLAN = in.UntaggedVLAN.VID
}
if ip, ok := ipByIface[in.ID]; ok {
iface.IP = addrOnly(ip.Address)
iface.PrefixLen = prefixLen(ip.Address)
iface.Netmask = netmaskFor(iface.PrefixLen)
if g := cfString(ip.CustomFields, "gateway"); g != "" {
iface.Gateway = g
}
if iface.IP != "" && iface.IP == h.PrimaryIP {
iface.Primary = true
}
}
if iface.Gateway == "" {
iface.Gateway = deviceGateway
}
h.Interfaces = append(h.Interfaces, iface)
}
sortPrimaryFirst(h.Interfaces)
if h.Domain != "" {
h.FQDN = h.Hostname + "." + h.Domain
} else {
h.FQDN = h.Hostname
}
return h
}
// sortPrimaryFirst moves the primary interface to the front, preserving the
// relative order of the rest.
func sortPrimaryFirst(ifaces []model.Interface) {
for i := range ifaces {
if ifaces[i].Primary && i != 0 {
p := ifaces[i]
copy(ifaces[1:i+1], ifaces[0:i])
ifaces[0] = p
return
}
}
}
// 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 {
u := c.baseURL + path
if len(q) > 0 {
u += "?" + q.Encode()
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return err
}
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 request %s: %w", path, err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode == http.StatusNotFound {
return ErrNotFound
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("netbox %s: HTTP %d", path, resp.StatusCode)
}
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
return fmt.Errorf("netbox decode %s: %w", path, err)
}
return nil
}
// --- helpers ---
func normalizeMAC(s string) string {
s = strings.TrimSpace(s)
if s == "" {
return ""
}
// Drop common separators then re-insert colons every 2 hex chars.
var hex strings.Builder
for _, r := range strings.ToLower(s) {
if (r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') {
hex.WriteRune(r)
}
}
h := hex.String()
if len(h) != 12 {
// Not a canonical 48-bit MAC; return lower-cased trimmed input.
return strings.ToLower(s)
}
var b strings.Builder
for i := 0; i < 12; i += 2 {
if i > 0 {
b.WriteByte(':')
}
b.WriteString(h[i : i+2])
}
return b.String()
}
func addrOnly(cidr string) string {
if i := strings.IndexByte(cidr, '/'); i >= 0 {
return cidr[:i]
}
return cidr
}
func prefixLen(cidr string) int {
_, ipnet, err := net.ParseCIDR(cidr)
if err != nil {
return 0
}
ones, _ := ipnet.Mask.Size()
return ones
}
func netmaskFor(prefix int) string {
if prefix <= 0 || prefix > 32 {
return ""
}
mask := net.CIDRMask(prefix, 32)
return fmt.Sprintf("%d.%d.%d.%d", mask[0], mask[1], mask[2], mask[3])
}
// splitPlatform derives (family, majorVersion) from a NetBox platform slug such
// as "almalinux9" -> ("almalinux","9") or "fedora42" -> ("fedora","42").
func splitPlatform(slug string) (family, version string) {
slug = strings.ToLower(slug)
i := strings.IndexFunc(slug, func(r rune) bool { return r >= '0' && r <= '9' })
if i < 0 {
return slug, ""
}
family = strings.Trim(slug[:i], "-_")
version = slug[i:]
if d := strings.IndexByte(version, '.'); d >= 0 {
version = version[:d]
}
return family, version
}
func cfString(cf map[string]any, key string) string {
if cf == nil {
return ""
}
switch v := cf[key].(type) {
case string:
return v
case map[string]any: // NetBox object custom fields serialize as {value,label}
if s, ok := v["value"].(string); ok {
return s
}
}
return ""
}
func cfStringList(cf map[string]any, key string) []string {
if cf == nil {
return nil
}
switch v := cf[key].(type) {
case string:
return splitComma(v)
case []any:
var out []string
for _, e := range v {
if s, ok := e.(string); ok && s != "" {
out = append(out, s)
}
}
return out
}
return nil
}
func splitComma(v string) []string {
var out []string
for _, p := range strings.Split(v, ",") {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
+220
View File
@@ -0,0 +1,220 @@
package netbox
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// fakeNetBox serves canned NetBox v4.x JSON for the endpoints bootapi calls.
// The payloads are trimmed but structurally faithful to real API responses.
func fakeNetBox(t *testing.T) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
// Interfaces filtered by MAC -> the interface (with nested device brief).
mux.HandleFunc("/api/dcim/interfaces/", func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("Authorization"); got != "Token testtoken" {
http.Error(w, `{"detail":"auth"}`, http.StatusForbidden)
return
}
q := r.URL.Query()
switch {
case q.Get("mac_address") == "aa:bb:cc:00:11:22":
writeJSON(w, `{"count":1,"results":[
{"id":40,"name":"eth0","mac_address":"AA:BB:CC:00:11:22",
"untagged_vlan":{"vid":100,"name":"prod"},
"device":{"id":12,"name":"web01","slug":""}}]}`)
case q.Get("device_id") == "12":
// Full interface list for the device (two NICs).
writeJSON(w, `{"count":2,"results":[
{"id":40,"name":"eth0","mac_address":"AA:BB:CC:00:11:22","untagged_vlan":{"vid":100,"name":"prod"}},
{"id":41,"name":"eth1","mac_address":"AA:BB:CC:00:11:33"}]}`)
default:
writeJSON(w, `{"count":0,"results":[]}`)
}
})
// Device detail.
mux.HandleFunc("/api/dcim/devices/", func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/12/") {
writeJSON(w, `{
"id":12,"name":"web01",
"platform":{"id":3,"name":"AlmaLinux 9","slug":"almalinux9"},
"role":{"id":2,"name":"K8s Worker","slug":"kubernetes-worker"},
"site":{"slug":"syd1"},
"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}}`)
return
}
// name= query (HostByName)
if r.URL.Query().Get("name") == "web01" {
writeJSON(w, `{"count":1,"results":[{"id":12,"name":"web01"}]}`)
return
}
writeJSON(w, `{"count":0,"results":[]}`)
})
// IP addresses for the device: eth0 has the primary, eth1 a second addr.
mux.HandleFunc("/api/ipam/ip-addresses/", func(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, `{"count":2,"results":[
{"address":"10.0.1.20/24","assigned_object_id":40,"custom_fields":{"gateway":null}},
{"address":"10.9.9.5/24","assigned_object_id":41,"custom_fields":{"gateway":"10.9.9.1"}}]}`)
})
return httptest.NewServer(mux)
}
func writeJSON(w http.ResponseWriter, body string) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(body))
}
func newTestClient(t *testing.T, base string) *Client {
t.Helper()
return New(Options{BaseURL: base, Token: "testtoken"})
}
func TestHostByMAC(t *testing.T) {
srv := fakeNetBox(t)
defer srv.Close()
c := newTestClient(t, srv.URL)
h, err := c.HostByMAC(context.Background(), "AA-BB-CC-00-11-22")
if err != nil {
t.Fatalf("HostByMAC: %v", err)
}
if h.Hostname != "web01" {
t.Errorf("hostname = %q, want web01", h.Hostname)
}
if h.FQDN != "web01.syd1.au.unkin.net" {
t.Errorf("fqdn = %q", h.FQDN)
}
if h.Platform != "almalinux9" || h.OSFamily != "almalinux" || h.OSVersion != "9" {
t.Errorf("platform=%q family=%q version=%q", h.Platform, h.OSFamily, h.OSVersion)
}
if h.Role != "kubernetes-worker" {
t.Errorf("role = %q", h.Role)
}
if h.PrimaryIP != "10.0.1.20" {
t.Errorf("primaryIP = %q", h.PrimaryIP)
}
if len(h.Nameservers) != 2 || h.Nameservers[0] != "10.0.0.1" {
t.Errorf("nameservers = %v", h.Nameservers)
}
if len(h.Interfaces) != 2 {
t.Fatalf("interfaces = %d, want 2", len(h.Interfaces))
}
// Primary interface (eth0, carrying the primary IP) must sort first.
pi := h.PrimaryInterface()
if pi == nil || pi.Name != "eth0" || !pi.Primary {
t.Fatalf("primary interface = %+v", pi)
}
if pi.MAC != "aa:bb:cc:00:11:22" {
t.Errorf("primary MAC = %q (want normalized lower-colon)", pi.MAC)
}
if pi.IP != "10.0.1.20" || pi.Netmask != "255.255.255.0" || pi.PrefixLen != 24 {
t.Errorf("primary iface addr = %+v", pi)
}
// eth0 gateway comes from the device custom field (its IP had none).
if pi.Gateway != "10.0.1.254" {
t.Errorf("primary gateway = %q, want device CF 10.0.1.254", pi.Gateway)
}
if pi.VLAN != 100 {
t.Errorf("primary vlan = %d", pi.VLAN)
}
// eth1's IP custom field gateway wins over the device default.
for i := range h.Interfaces {
if h.Interfaces[i].Name == "eth1" {
if h.Interfaces[i].Gateway != "10.9.9.1" {
t.Errorf("eth1 gateway = %q, want per-IP CF 10.9.9.1", h.Interfaces[i].Gateway)
}
}
}
}
func TestHostByMACNotFound(t *testing.T) {
srv := fakeNetBox(t)
defer srv.Close()
c := newTestClient(t, srv.URL)
_, err := c.HostByMAC(context.Background(), "de:ad:be:ef:00:00")
if !errors.Is(err, ErrNotFound) {
t.Fatalf("err = %v, want ErrNotFound", err)
}
}
func TestHostByName(t *testing.T) {
srv := fakeNetBox(t)
defer srv.Close()
c := newTestClient(t, srv.URL)
// FQDN input should be reduced to the short name for the NetBox query.
h, err := c.HostByName(context.Background(), "web01.syd1.au.unkin.net")
if err != nil {
t.Fatalf("HostByName: %v", err)
}
if h.Hostname != "web01" || h.PrimaryIP != "10.0.1.20" {
t.Errorf("host = %+v", h)
}
}
func TestHostByNameNotFound(t *testing.T) {
srv := fakeNetBox(t)
defer srv.Close()
c := newTestClient(t, srv.URL)
if _, err := c.HostByName(context.Background(), "nope"); !errors.Is(err, ErrNotFound) {
t.Fatalf("err = %v, want ErrNotFound", err)
}
}
func TestAuthTokenRequired(t *testing.T) {
srv := fakeNetBox(t)
defer srv.Close()
// Client with the wrong token -> NetBox 403 -> surfaced as an error.
c := New(Options{BaseURL: srv.URL, Token: "wrong"})
if _, err := c.HostByMAC(context.Background(), "aa:bb:cc:00:11:22"); err == nil {
t.Fatal("expected an auth error, got nil")
}
}
func TestNormalizeMAC(t *testing.T) {
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",
"aabb.cc00.1122": "aa:bb:cc:00:11:22",
"AABBCC001122": "aa:bb:cc:00:11:22",
}
for in, want := range cases {
if got := normalizeMAC(in); got != want {
t.Errorf("normalizeMAC(%q) = %q, want %q", in, got, want)
}
}
}
func TestSplitPlatform(t *testing.T) {
cases := []struct{ in, fam, ver string }{
{"almalinux9", "almalinux", "9"},
{"fedora42", "fedora", "42"},
{"rocky9.4", "rocky", "9"},
{"debian", "debian", ""},
}
for _, c := range cases {
f, v := splitPlatform(c.in)
if f != c.fam || v != c.ver {
t.Errorf("splitPlatform(%q) = (%q,%q), want (%q,%q)", c.in, f, v, c.fam, c.ver)
}
}
}
func TestNetmaskFor(t *testing.T) {
cases := map[int]string{24: "255.255.255.0", 16: "255.255.0.0", 25: "255.255.255.128", 0: ""}
for prefix, want := range cases {
if got := netmaskFor(prefix); got != want {
t.Errorf("netmaskFor(%d) = %q, want %q", prefix, got, want)
}
}
}
+268
View File
@@ -0,0 +1,268 @@
// 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
// set (ported from Cobbler's kickstarts) optionally layered with an override
// directory (a Kubernetes ConfigMap mount in production).
package render
import (
"bytes"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
"text/template"
"git.unkin.net/unkin/bootapi/internal/model"
)
// Data is the exact, documented value passed to every kickstart/iPXE template.
// It is intentionally flat: template authors get one clear namespace. See
// docs/data-model.md.
type Data struct {
// --- identity (from NetBox) ---
Hostname string
Domain string
FQDN string
Platform string // NetBox platform slug, e.g. "almalinux9"
OSFamily string // "almalinux", "fedora", ...
OSVersion string // "9", "42", ...
Arch string // "x86_64"
Role string // NetBox device role slug
// --- network (from NetBox) ---
Interfaces []model.Interface
PrimaryInterface *model.Interface
PrimaryIP string
Nameservers []string // resolved: host value, else site default
// --- secrets (from Vault/env at render time, never NetBox) ---
RootPasswordHash string
SSHAuthorizedKeys []string
// --- infra pointers (render-time config) ---
PuppetServer string
PuppetCAServer string
BaseURL string // bootapi's own base URL
BootBaseURL string // OS install-tree base URL
KickstartURL string // absolute URL a booting host fetches its KS from
// --- escape hatch: every NetBox custom field, verbatim ---
Custom map[string]any
}
// RenderConfig carries the render-time infra values merged into each Data.
type RenderConfig struct {
PuppetServer string
PuppetCAServer string
BaseURL string
BootBaseURL string
DefaultDomain string
DefaultNS []string
RootPasswordHash string
SSHAuthorizedKeys []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 (
ksExt = ".ks.tmpl"
ipxeExt = ".ipxe.tmpl"
)
// NewEngine parses the embedded defaults, then overlays overrideDir when
// non-empty (files there win over embedded ones of the same name).
func NewEngine(embedded fs.FS, overrideDir string, cfg RenderConfig) (*Engine, error) {
funcs := funcMap()
ks := template.New("kickstart").Funcs(funcs)
ipxe := template.New("ipxe").Funcs(funcs)
set := map[string]bool{}
if err := parseTree(ks, ipxe, set, embedded, ".", true); err != nil {
return nil, fmt.Errorf("parse embedded templates: %w", err)
}
if overrideDir != "" {
if err := parseTree(ks, ipxe, set, os.DirFS(overrideDir), ".", false); err != nil {
return nil, fmt.Errorf("parse override templates in %q: %w", overrideDir, err)
}
}
return &Engine{ks: ks, ipxe: ipxe, cfg: cfg, ksSet: set}, nil
}
// parseTree walks fsys under root, registering *.ks.tmpl into ks and
// *.ipxe.tmpl into ipxe under their base name (extension stripped).
func parseTree(ks, ipxe *template.Template, set map[string]bool, fsys fs.FS, root string, mustExist bool) error {
walked := false
err := fs.WalkDir(fsys, root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
walked = true
if d.IsDir() {
return nil
}
b, err := fs.ReadFile(fsys, path)
if err != nil {
return err
}
base := filepath.Base(path)
switch {
case strings.HasSuffix(base, ksExt):
name := strings.TrimSuffix(base, ksExt)
if _, err := ks.New(name).Parse(string(b)); err != nil {
return fmt.Errorf("%s: %w", path, err)
}
set[name] = true
case strings.HasSuffix(base, ipxeExt):
name := strings.TrimSuffix(base, ipxeExt)
if _, err := ipxe.New(name).Parse(string(b)); err != nil {
return fmt.Errorf("%s: %w", path, err)
}
}
return nil
})
if err != nil {
return err
}
if mustExist && !walked {
return fmt.Errorf("no templates found under %q", root)
}
return nil
}
// SelectKickstart returns the template name chosen for host, following the
// documented precedence: custom-field override → platform slug → OS family →
// configured default. It reports whether a concrete template was found.
func (e *Engine) SelectKickstart(h *model.Host) (string, bool) {
for _, cand := range []string{h.TemplateOverride, h.Platform, h.OSFamily, e.cfg.DefaultTemplate} {
if cand != "" && e.ksSet[cand] {
return cand, true
}
}
return e.cfg.DefaultTemplate, e.ksSet[e.cfg.DefaultTemplate]
}
// dataFor builds the flat Data view for a host, merging render-time config.
func (e *Engine) dataFor(h *model.Host) Data {
ns := h.Nameservers
if len(ns) == 0 {
ns = e.cfg.DefaultNS
}
domain := h.Domain
if domain == "" {
domain = e.cfg.DefaultDomain
}
fqdn := h.Hostname
if domain != "" {
fqdn = h.Hostname + "." + domain
}
root := h.RootPasswordHash
if root == "" {
root = e.cfg.RootPasswordHash
}
keys := h.SSHAuthorizedKeys
if len(keys) == 0 {
keys = e.cfg.SSHAuthorizedKeys
}
ksURL := ""
if e.cfg.BaseURL != "" {
ksURL = strings.TrimRight(e.cfg.BaseURL, "/") + "/ks/" + h.Hostname
}
return Data{
Hostname: h.Hostname,
Domain: domain,
FQDN: fqdn,
Platform: h.Platform,
OSFamily: h.OSFamily,
OSVersion: h.OSVersion,
Arch: h.Arch,
Role: h.Role,
Interfaces: h.Interfaces,
PrimaryInterface: h.PrimaryInterface(),
PrimaryIP: h.PrimaryIP,
Nameservers: ns,
RootPasswordHash: root,
SSHAuthorizedKeys: keys,
PuppetServer: e.cfg.PuppetServer,
PuppetCAServer: e.cfg.PuppetCAServer,
BaseURL: e.cfg.BaseURL,
BootBaseURL: e.cfg.BootBaseURL,
KickstartURL: ksURL,
Custom: h.Custom,
}
}
// RenderKickstart renders the selected kickstart template for host. It returns
// the rendered bytes and the template name used.
func (e *Engine) RenderKickstart(h *model.Host) ([]byte, string, error) {
name, ok := e.SelectKickstart(h)
if !ok {
return nil, name, fmt.Errorf("no kickstart template for host %q (tried override/platform/family/default %q)", h.Hostname, name)
}
var buf bytes.Buffer
if err := e.ks.ExecuteTemplate(&buf, name, e.dataFor(h)); err != nil {
return nil, name, fmt.Errorf("render kickstart %q: %w", name, err)
}
return buf.Bytes(), name, nil
}
// IPXEData is the value passed to iPXE templates.
type IPXEData struct {
Data
// KernelURL/InitrdURL point at the OS install tree; empty when BootBaseURL
// is unset, in which case the template should fall back to a static path.
KernelURL string
InitrdURL string
}
// RenderIPXE renders the "boot" iPXE script that chains kernel+initrd with
// inst.ks= pointing back at bootapi.
func (e *Engine) RenderIPXE(h *model.Host) ([]byte, error) {
d := e.dataFor(h)
id := IPXEData{Data: d}
if d.BootBaseURL != "" {
tree := strings.TrimRight(d.BootBaseURL, "/")
id.KernelURL = tree + "/images/pxeboot/vmlinuz"
id.InitrdURL = tree + "/images/pxeboot/initrd.img"
}
return e.execIPXE("boot", id)
}
// RenderFallback renders a fallback iPXE script ("local" or "shell") for an
// unknown MAC. See docs/endpoints.md for the safety rationale.
func (e *Engine) RenderFallback(kind string) ([]byte, error) {
name := "fallback-" + kind
return e.execIPXE(name, IPXEData{})
}
func (e *Engine) execIPXE(name string, d IPXEData) ([]byte, error) {
if e.ipxe.Lookup(name) == nil {
return nil, fmt.Errorf("no iPXE template %q", name)
}
var buf bytes.Buffer
if err := e.ipxe.ExecuteTemplate(&buf, name, d); err != nil {
return nil, fmt.Errorf("render ipxe %q: %w", name, err)
}
return buf.Bytes(), nil
}
func funcMap() template.FuncMap {
return template.FuncMap{
"join": strings.Join,
"upper": strings.ToUpper,
"lower": strings.ToLower,
"default": func(def, v string) string { // {{ default "x" .Maybe }}
if v == "" {
return def
}
return v
},
}
}
+161
View File
@@ -0,0 +1,161 @@
package render
import (
"os"
"path/filepath"
"strings"
"testing"
"git.unkin.net/unkin/bootapi/internal/model"
"git.unkin.net/unkin/bootapi/templates"
)
func testEngine(t *testing.T, override string) *Engine {
t.Helper()
e, err := NewEngine(templates.FS, override, RenderConfig{
PuppetServer: "puppet.query.consul",
PuppetCAServer: "puppetca.query.consul",
BaseURL: "http://bootapi.example.net",
BootBaseURL: "http://mirror.example.net/almalinux/9",
DefaultDomain: "main.unkin.net",
DefaultNS: []string{"10.0.0.1"},
RootPasswordHash: "$6$rounds=4096$abc$deadbeef",
SSHAuthorizedKeys: []string{"ssh-ed25519 AAAAC3xxx root@ops"},
DefaultTemplate: "almalinux9",
})
if err != nil {
t.Fatalf("NewEngine: %v", err)
}
return e
}
func almaHost() *model.Host {
return &model.Host{
Hostname: "web01",
Domain: "syd1.au.unkin.net",
FQDN: "web01.syd1.au.unkin.net",
Platform: "almalinux9",
OSFamily: "almalinux",
OSVersion: "9",
Arch: "x86_64",
Role: "kubernetes-worker",
PrimaryIP: "10.0.1.20",
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: "eth1", MAC: "aa:bb:cc:00:11:33"}, // no IP -> must be skipped in network stanza
},
}
}
func TestRenderKickstartAlma(t *testing.T) {
e := testEngine(t, "")
out, name, err := e.RenderKickstart(almaHost())
if err != nil {
t.Fatalf("RenderKickstart: %v", err)
}
if name != "almalinux9" {
t.Errorf("selected template = %q, want almalinux9", name)
}
ks := string(out)
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=10.0.0.1 --hostname=web01.syd1.au.unkin.net")
mustContain(t, ks, `"$PUPPET_BIN" config set --section main server "puppet.query.consul"`)
mustContain(t, ks, `config set --section main ca_server "puppetca.query.consul"`)
mustContain(t, ks, "url --url=http://mirror.example.net/almalinux/9/BaseOS/x86_64/os/")
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") {
t.Error("interface without an IP leaked into a network stanza")
}
}
func TestRenderKickstartLockedRoot(t *testing.T) {
// With no root hash configured, the account must be locked, not blank.
e, err := NewEngine(templates.FS, "", RenderConfig{DefaultTemplate: "almalinux9", BootBaseURL: "http://m/9"})
if err != nil {
t.Fatal(err)
}
out, _, err := e.RenderKickstart(almaHost())
if err != nil {
t.Fatal(err)
}
ks := string(out)
mustContain(t, ks, "rootpw --lock")
if strings.Contains(ks, "--iscrypted") {
t.Error("expected locked root, got an --iscrypted line")
}
}
func TestSelectKickstartPrecedence(t *testing.T) {
e := testEngine(t, "")
cases := []struct {
host *model.Host
want string
}{
{&model.Host{TemplateOverride: "fedora", Platform: "almalinux9"}, "fedora"}, // override wins
{&model.Host{Platform: "almalinux9"}, "almalinux9"}, // platform
{&model.Host{Platform: "fedora42", OSFamily: "fedora"}, "fedora"}, // family fallback
{&model.Host{Platform: "unknownos"}, "almalinux9"}, // default
}
for _, c := range cases {
got, ok := e.SelectKickstart(c.host)
if !ok || got != c.want {
t.Errorf("SelectKickstart(%+v) = (%q,%v), want %q", c.host, got, ok, c.want)
}
}
}
func TestRenderIPXE(t *testing.T) {
e := testEngine(t, "")
out, err := e.RenderIPXE(almaHost())
if err != nil {
t.Fatalf("RenderIPXE: %v", err)
}
s := string(out)
mustContain(t, s, "#!ipxe")
mustContain(t, s, "kernel http://mirror.example.net/almalinux/9/images/pxeboot/vmlinuz")
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")
}
func TestRenderFallback(t *testing.T) {
e := testEngine(t, "")
local, err := e.RenderFallback("local")
if err != nil {
t.Fatal(err)
}
mustContain(t, string(local), "sanboot")
shell, err := e.RenderFallback("shell")
if err != nil {
t.Fatal(err)
}
mustContain(t, string(shell), "shell")
}
func TestOverrideDirWins(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "almalinux9.ks.tmpl"), []byte("OVERRIDDEN {{ .Hostname }}\n"), 0o600); err != nil {
t.Fatal(err)
}
e := testEngine(t, dir)
out, _, err := e.RenderKickstart(almaHost())
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(string(out), "OVERRIDDEN web01") {
t.Errorf("override not applied: %q", string(out))
}
}
func mustContain(t *testing.T, haystack, needle string) {
t.Helper()
if !strings.Contains(haystack, needle) {
t.Errorf("output missing %q\n--- output ---\n%s", needle, haystack)
}
}
+82
View File
@@ -0,0 +1,82 @@
package server
import (
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/collectors"
)
// cacheStats is the read side of the NetBox cache the collector publishes.
type cacheStats interface {
Hits() int64
Misses() int64
}
// metrics holds bootapi's Prometheus instruments, registered on a private
// registry so tests can construct isolated servers.
type metrics struct {
reg *prometheus.Registry
httpRequests *prometheus.CounterVec // by endpoint,status
renders *prometheus.CounterVec // by kind,result
netboxLookups *prometheus.CounterVec // by field,result
netboxDuration *prometheus.HistogramVec
}
func newMetrics(cache cacheStats) *metrics {
reg := prometheus.NewRegistry()
m := &metrics{
reg: reg,
httpRequests: prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "bootapi_http_requests_total",
Help: "HTTP requests handled, by endpoint and status class.",
}, []string{"endpoint", "status"}),
renders: prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "bootapi_render_total",
Help: "Template renders, by kind (kickstart|ipxe) and result (ok|error).",
}, []string{"kind", "result"}),
netboxLookups: prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "bootapi_netbox_lookups_total",
Help: "NetBox host resolutions, by field (mac|name) and result (ok|notfound|error).",
}, []string{"field", "result"}),
netboxDuration: prometheus.NewHistogramVec(prometheus.HistogramOpts{
Name: "bootapi_netbox_lookup_duration_seconds",
Help: "Latency of NetBox host resolutions.",
Buckets: prometheus.DefBuckets,
}, []string{"field"}),
}
reg.MustRegister(m.httpRequests, m.renders, m.netboxLookups, m.netboxDuration)
if cache != nil {
reg.MustRegister(newCacheCollector(cache))
}
reg.MustRegister(
collectors.NewGoCollector(),
collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}),
)
return m
}
// cacheCollector publishes the NetBox cache hit/miss counters, which live on
// the Cache itself (atomic ints) rather than in a CounterVec.
type cacheCollector struct {
stats cacheStats
hits *prometheus.Desc
miss *prometheus.Desc
}
func newCacheCollector(s cacheStats) *cacheCollector {
return &cacheCollector{
stats: s,
hits: prometheus.NewDesc("bootapi_netbox_cache_hits_total", "NetBox cache hits.", nil, nil),
miss: prometheus.NewDesc("bootapi_netbox_cache_misses_total", "NetBox cache misses.", nil, nil),
}
}
func (c *cacheCollector) Describe(ch chan<- *prometheus.Desc) {
ch <- c.hits
ch <- c.miss
}
func (c *cacheCollector) Collect(ch chan<- prometheus.Metric) {
ch <- prometheus.MustNewConstMetric(c.hits, prometheus.CounterValue, float64(c.stats.Hits()))
ch <- prometheus.MustNewConstMetric(c.miss, prometheus.CounterValue, float64(c.stats.Misses()))
}
+278
View File
@@ -0,0 +1,278 @@
// Package server exposes bootapi over HTTP: iPXE boot scripts and rendered
// kickstarts for PXE-booting hosts, plus health and metrics endpoints.
package server
import (
"context"
"errors"
"log/slog"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/prometheus/client_golang/prometheus/promhttp"
"git.unkin.net/unkin/bootapi/internal/model"
"git.unkin.net/unkin/bootapi/internal/netbox"
"git.unkin.net/unkin/bootapi/internal/render"
)
// Server wires the NetBox resolver and template engine into HTTP handlers.
type Server struct {
resolver netbox.Resolver
engine *render.Engine
metrics *metrics
// fallback is the unknown-MAC iPXE behavior: "local" (safe default) or
// "shell" (debug).
fallback string
}
// Options configures a Server.
type Options struct {
Resolver netbox.Resolver
Engine *render.Engine
// Cache, when non-nil, has its hit/miss counters published as metrics.
Cache cacheStats
UnknownMACFallback string
}
// New builds a Server.
func New(o Options) *Server {
fb := o.UnknownMACFallback
if fb == "" {
fb = "local"
}
return &Server{
resolver: o.Resolver,
engine: o.Engine,
metrics: newMetrics(o.Cache),
fallback: fb,
}
}
// Router returns the fully-wired HTTP handler.
func (s *Server) Router() http.Handler {
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.Recoverer)
r.Use(s.logRequests)
r.Get("/healthz", s.handleHealthz)
r.Get("/readyz", s.handleReadyz)
r.Handle("/metrics", promhttp.HandlerFor(s.metrics.reg, promhttp.HandlerOpts{}))
// iPXE boot script: primary path-style, plus a query-style alias.
r.Get("/ipxe/{mac}", s.handleIPXE)
r.Get("/boot/ipxe", s.handleIPXEQuery)
// Rendered kickstart, keyed by MAC or hostname.
r.Get("/ks/{ident}", s.handleKickstart)
return r
}
func (s *Server) handleHealthz(w http.ResponseWriter, _ *http.Request) {
s.ok(w, http.StatusOK, "text/plain", []byte("ok\n"), "healthz")
}
// handleReadyz is ready once templates parsed (engine present). NetBox is a
// soft dependency — the iPXE fallback works without it — so readiness does not
// probe NetBox.
func (s *Server) handleReadyz(w http.ResponseWriter, _ *http.Request) {
if s.engine == nil {
s.ok(w, http.StatusServiceUnavailable, "text/plain", []byte("no template engine\n"), "readyz")
return
}
s.ok(w, http.StatusOK, "text/plain", []byte("ready\n"), "readyz")
}
// handleIPXE serves the per-MAC iPXE script. An unknown MAC (or a NetBox error)
// yields a SAFE fallback script with HTTP 200 — never a 404 — so the booting
// firmware always receives a valid iPXE script instead of failing the chain.
func (s *Server) handleIPXE(w http.ResponseWriter, r *http.Request) {
s.serveIPXE(w, r, chi.URLParam(r, "mac"))
}
func (s *Server) handleIPXEQuery(w http.ResponseWriter, r *http.Request) {
s.serveIPXE(w, r, r.URL.Query().Get("mac"))
}
func (s *Server) serveIPXE(w http.ResponseWriter, r *http.Request, mac string) {
const ct = "text/plain" // iPXE scripts are served as text/plain
mac = strings.TrimSuffix(mac, ".ipxe")
if mac == "" {
s.renderFallback(w, "ipxe", "missing MAC")
return
}
host, err := s.lookup(r.Context(), "mac", mac)
if err != nil {
if errors.Is(err, netbox.ErrNotFound) {
slog.Info("ipxe unknown MAC; serving fallback", "mac", mac, "fallback", s.fallback)
} else {
slog.Error("ipxe netbox lookup failed; serving safe fallback", "mac", mac, "err", err)
}
s.renderFallback(w, "ipxe", "unknown or unresolvable MAC")
return
}
body, err := s.engine.RenderIPXE(host)
if err != nil {
s.metrics.renders.WithLabelValues("ipxe", "error").Inc()
slog.Error("render ipxe", "host", host.Hostname, "err", err)
s.renderFallback(w, "ipxe", "render error")
return
}
s.metrics.renders.WithLabelValues("ipxe", "ok").Inc()
s.ok(w, http.StatusOK, ct, body, "ipxe")
}
// renderFallback emits the configured unknown-MAC iPXE script (still HTTP 200).
func (s *Server) renderFallback(w http.ResponseWriter, endpoint, _ string) {
body, err := s.engine.RenderFallback(s.fallback)
if err != nil {
// Last-resort inline script so the firmware still gets something valid.
body = []byte("#!ipxe\necho bootapi: fallback render failed; booting local disk\nsanboot --no-describe --drive 0x80 || exit\n")
}
s.ok(w, http.StatusOK, "text/plain", body, endpoint)
}
// handleKickstart serves the rendered kickstart for a host identified by MAC or
// hostname. Unlike iPXE, an unknown host here is a hard 404: the installer has
// already committed to installing and a wrong/empty kickstart is worse than a
// clear failure.
func (s *Server) handleKickstart(w http.ResponseWriter, r *http.Request) {
ident := chi.URLParam(r, "ident")
for _, suf := range []string{".ks", ".cfg"} {
ident = strings.TrimSuffix(ident, suf)
}
if ident == "" {
http.Error(w, "missing host identifier", http.StatusBadRequest)
s.metrics.httpRequests.WithLabelValues("ks", "4xx").Inc()
return
}
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.httpRequests.WithLabelValues("ks", "4xx").Inc()
return
}
http.Error(w, "netbox lookup failed", http.StatusBadGateway)
s.metrics.httpRequests.WithLabelValues("ks", "5xx").Inc()
return
}
body, name, err := s.engine.RenderKickstart(host)
if err != nil {
s.metrics.renders.WithLabelValues("kickstart", "error").Inc()
slog.Error("render kickstart", "host", host.Hostname, "err", err)
http.Error(w, "kickstart render failed", http.StatusInternalServerError)
s.metrics.httpRequests.WithLabelValues("ks", "5xx").Inc()
return
}
s.metrics.renders.WithLabelValues("kickstart", "ok").Inc()
slog.Info("served kickstart", "host", host.Hostname, "template", name)
s.ok(w, http.StatusOK, "text/plain", body, "ks")
}
// lookup resolves a host by field ("mac" or "name"), recording metrics.
func (s *Server) lookup(ctx context.Context, field, value string) (*model.Host, error) {
start := time.Now()
var host *model.Host
var err error
if field == "mac" {
host, err = s.resolver.HostByMAC(ctx, value)
} else {
host, err = s.resolver.HostByName(ctx, value)
}
s.metrics.netboxDuration.WithLabelValues(field).Observe(time.Since(start).Seconds())
switch {
case err == nil:
s.metrics.netboxLookups.WithLabelValues(field, "ok").Inc()
case errors.Is(err, netbox.ErrNotFound):
s.metrics.netboxLookups.WithLabelValues(field, "notfound").Inc()
default:
s.metrics.netboxLookups.WithLabelValues(field, "error").Inc()
}
return host, err
}
func (s *Server) ok(w http.ResponseWriter, status int, contentType string, body []byte, endpoint string) {
w.Header().Set("Content-Type", contentType)
w.WriteHeader(status)
_, _ = w.Write(body)
s.metrics.httpRequests.WithLabelValues(endpoint, statusClass(status)).Inc()
}
// ListenAndServe runs the HTTP server until ctx is cancelled.
func (s *Server) ListenAndServe(ctx context.Context, addr string) error {
srv := &http.Server{
Addr: addr,
Handler: s.Router(),
ReadHeaderTimeout: 10 * time.Second,
}
go func() {
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = srv.Shutdown(shutdownCtx)
}()
slog.Info("bootapi listening", "addr", addr)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
return err
}
return nil
}
// looksLikeMAC reports whether s is plausibly a MAC (12 hex nibbles, ignoring
// common separators). Used to pick the NetBox lookup field for /ks/{ident}.
func looksLikeMAC(s string) bool {
n := 0
for _, r := range strings.ToLower(s) {
switch {
case r >= '0' && r <= '9', r >= 'a' && r <= 'f':
n++
case r == ':' || r == '-' || r == '.':
// separator, ignore
default:
return false
}
}
return n == 12
}
func statusClass(code int) string {
switch {
case code < 300:
return "2xx"
case code < 400:
return "3xx"
case code < 500:
return "4xx"
default:
return "5xx"
}
}
func (s *Server) logRequests(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
defer func() {
slog.Info("request",
"method", r.Method,
"path", r.URL.Path,
"status", ww.Status(),
"duration_ms", time.Since(start).Milliseconds(),
"remote", r.RemoteAddr,
"request_id", middleware.GetReqID(r.Context()),
)
}()
next.ServeHTTP(ww, r)
})
}
+200
View File
@@ -0,0 +1,200 @@
package server
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.unkin.net/unkin/bootapi/internal/model"
"git.unkin.net/unkin/bootapi/internal/netbox"
"git.unkin.net/unkin/bootapi/internal/render"
"git.unkin.net/unkin/bootapi/templates"
)
// fakeResolver is a canned netbox.Resolver for handler tests.
type fakeResolver struct {
byMAC map[string]*model.Host
byName map[string]*model.Host
err error
}
func (f *fakeResolver) HostByMAC(_ context.Context, mac string) (*model.Host, error) {
if f.err != nil {
return nil, f.err
}
// The real NetBox client normalizes MAC case/separators before matching;
// mirror that here so case-insensitive lookups behave like production.
if h, ok := f.byMAC[strings.ToLower(mac)]; ok {
return h, nil
}
return nil, netbox.ErrNotFound
}
func (f *fakeResolver) HostByName(_ context.Context, name string) (*model.Host, error) {
if f.err != nil {
return nil, f.err
}
if h, ok := f.byName[name]; ok {
return h, nil
}
return nil, netbox.ErrNotFound
}
func testHost() *model.Host {
return &model.Host{
Hostname: "web01", Domain: "syd1.au.unkin.net", FQDN: "web01.syd1.au.unkin.net",
Platform: "almalinux9", OSFamily: "almalinux", OSVersion: "9", Arch: "x86_64",
PrimaryIP: "10.0.1.20",
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", Primary: true},
},
}
}
func newTestServer(t *testing.T, res netbox.Resolver, fallback string) *Server {
t.Helper()
eng, err := render.NewEngine(templates.FS, "", render.RenderConfig{
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",
RootPasswordHash: "$6$abc$def",
})
if err != nil {
t.Fatal(err)
}
return New(Options{Resolver: res, Engine: eng, UnknownMACFallback: fallback})
}
func do(t *testing.T, h http.Handler, path string) *httptest.ResponseRecorder {
t.Helper()
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
return rec
}
func TestIPXEKnownMAC(t *testing.T) {
res := &fakeResolver{byMAC: map[string]*model.Host{"aa:bb:cc:00:11:22": testHost()}}
h := newTestServer(t, res, "local").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, "inst.ks=http://bootapi.example.net/ks/web01") {
t.Errorf("ipxe body missing inst.ks:\n%s", body)
}
}
func TestIPXEUnknownMACServesFallback200(t *testing.T) {
h := newTestServer(t, &fakeResolver{}, "local").Router()
rec := do(t, h, "/ipxe/de:ad:be:ef:00:00")
// Unknown MAC must NOT 404 — iPXE needs a valid script. Safe local-boot.
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 with fallback", rec.Code)
}
if !strings.Contains(rec.Body.String(), "sanboot") {
t.Errorf("expected local-boot fallback, got:\n%s", rec.Body.String())
}
}
func TestIPXEUnknownMACShellFallback(t *testing.T) {
h := newTestServer(t, &fakeResolver{}, "shell").Router()
rec := do(t, h, "/ipxe/de:ad:be:ef:00:00")
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())
}
}
func TestIPXEQueryAlias(t *testing.T) {
res := &fakeResolver{byMAC: map[string]*model.Host{"aa:bb:cc:00:11:22": testHost()}}
h := newTestServer(t, res, "local").Router()
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=") {
t.Fatalf("query-style ipxe failed: %d\n%s", rec.Code, rec.Body.String())
}
}
func TestKickstartByMAC(t *testing.T) {
res := &fakeResolver{byMAC: map[string]*model.Host{"aa:bb:cc:00:11:22": testHost()}}
h := newTestServer(t, res, "local").Router()
rec := do(t, h, "/ks/aa:bb:cc:00:11:22")
if rec.Code != http.StatusOK {
t.Fatalf("status = %d", rec.Code)
}
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/plain") {
t.Errorf("content-type = %q", ct)
}
if !strings.Contains(rec.Body.String(), "rootpw --iscrypted") {
t.Errorf("kickstart body missing rootpw:\n%s", rec.Body.String())
}
}
func TestKickstartByHostname(t *testing.T) {
res := &fakeResolver{byName: map[string]*model.Host{"web01": testHost()}}
h := newTestServer(t, res, "local").Router()
rec := do(t, h, "/ks/web01.cfg") // .cfg suffix must be stripped
if rec.Code != http.StatusOK {
t.Fatalf("status = %d\n%s", rec.Code, rec.Body.String())
}
}
func TestKickstartUnknownIs404(t *testing.T) {
h := newTestServer(t, &fakeResolver{}, "local").Router()
rec := do(t, h, "/ks/nosuchhost")
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404 (kickstart must fail loudly)", rec.Code)
}
}
func TestHealthAndReady(t *testing.T) {
h := newTestServer(t, &fakeResolver{}, "local").Router()
if rec := do(t, h, "/healthz"); rec.Code != http.StatusOK {
t.Errorf("healthz = %d", rec.Code)
}
if rec := do(t, h, "/readyz"); rec.Code != http.StatusOK {
t.Errorf("readyz = %d", rec.Code)
}
}
func TestMetricsEndpoint(t *testing.T) {
res := &fakeResolver{byMAC: map[string]*model.Host{"aa:bb:cc:00:11:22": testHost()}}
srv := newTestServer(t, res, "local")
h := srv.Router()
do(t, h, "/ipxe/aa:bb:cc:00:11:22") // ok render + netbox ok
do(t, h, "/ipxe/de:ad:be:ef:00:00") // notfound + fallback
do(t, h, "/ks/aa:bb:cc:00:11:22") // kickstart render
rec := do(t, h, "/metrics")
if rec.Code != http.StatusOK {
t.Fatalf("metrics status = %d", rec.Code)
}
body := rec.Body.String()
for _, want := range []string{
`bootapi_render_total{kind="ipxe",result="ok"} 1`,
`bootapi_render_total{kind="kickstart",result="ok"} 1`,
`bootapi_netbox_lookups_total{field="mac",result="notfound"} 1`,
"bootapi_http_requests_total",
} {
if !strings.Contains(body, want) {
t.Errorf("metrics missing %q", want)
}
}
}
func TestLooksLikeMAC(t *testing.T) {
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"}
for _, s := range yes {
if !looksLikeMAC(s) {
t.Errorf("looksLikeMAC(%q) = false, want true", s)
}
}
for _, s := range no {
if looksLikeMAC(s) {
t.Errorf("looksLikeMAC(%q) = true, want false", s)
}
}
}
+11
View File
@@ -0,0 +1,11 @@
// Package templates embeds bootapi's default kickstart and iPXE templates.
// These are the built-in fallback set; an operator can override any of them by
// mounting a ConfigMap at BOOTAPI_TEMPLATE_DIR (see docs/deployment.md).
package templates
import "embed"
// FS holds the default template tree: kickstart/*.ks.tmpl and ipxe/*.ipxe.tmpl.
//
//go:embed kickstart ipxe
var FS embed.FS
+20
View File
@@ -0,0 +1,20 @@
{{- /*
iPXE boot script for a known host. Chains the OS installer kernel+initrd and
points inst.ks= back at bootapi's /ks/<hostname> endpoint, mirroring how Cobbler
generated a per-MAC gPXE script that carried inst.ks=.
Requires BOOTAPI_BOOT_BASE_URL (KernelURL/InitrdURL) and BOOTAPI_BASE_URL
(KickstartURL) to be configured.
*/ -}}
#!ipxe
echo bootapi: provisioning {{ .FQDN }} ({{ .Platform }})
{{ if and .KernelURL .InitrdURL -}}
kernel {{ .KernelURL }} initrd=initrd.img inst.repo={{ .BootBaseURL }} inst.ks={{ .KickstartURL }} inst.text ip=dhcp net.ifnames=0
initrd {{ .InitrdURL }}
boot
{{- else -}}
echo bootapi: BOOTAPI_BOOT_BASE_URL not configured; cannot build a boot line
echo Falling back to local disk in 5s
sleep 5
exit
{{- end }}
+10
View File
@@ -0,0 +1,10 @@
{{- /*
Safe default for an UNKNOWN MAC (NetBox has no matching device). We deliberately
do NOT start an installer for a machine we can't identify — that could wipe a
production box that PXE-booted by accident. Instead we boot from local disk, so
an already-installed host just continues, and a brand-new host loops back to PXE
on its next attempt (by which point NetBox should know it).
*/ -}}
#!ipxe
echo bootapi: unknown MAC ${net0/mac}; not provisioning. Booting local disk.
sanboot --no-describe --drive 0x80 || exit
+10
View File
@@ -0,0 +1,10 @@
{{- /*
Debug fallback for an unknown MAC (opt in via BOOTAPI_UNKNOWN_MAC_FALLBACK=shell).
Drops to an interactive iPXE shell instead of booting anything, so an operator
racking a new box can inspect ${net0/mac} and register it in NetBox. Not the
default because it halts the boot and is unsafe for an accidental PXE of a prod
host.
*/ -}}
#!ipxe
echo bootapi: unknown MAC ${net0/mac}; dropping to iPXE shell for debugging.
shell
+96
View File
@@ -0,0 +1,96 @@
{{- /*
AlmaLinux 9 kickstart, ported from the Cobbler default.ks contract.
Rendered by bootapi from NetBox data + render-time secrets. The %post hands off
to the existing Puppet firstrun bootstrap: it installs the agent, points it at
the Consul-discovered puppet servers, and triggers the first run. Autosign
(*.main.unkin.net + the PXE subnets) and the `profiles::firstrun` class do the
rest, exactly as they did under Cobbler.
Data model: see docs/data-model.md. `.RootPasswordHash` comes from Vault at
render time, never from NetBox.
*/ -}}
#version=RHEL9
# Rendered by bootapi for {{ .FQDN }} (platform {{ .Platform }}, role {{ default "none" .Role }})
text
eula --agreed
firstboot --disable
reboot
# --- install source (served by bootapi's configured mirror) ---
url --url={{ .BootBaseURL }}/BaseOS/{{ .Arch }}/os/
repo --name=AppStream --baseurl={{ .BootBaseURL }}/AppStream/{{ .Arch }}/os/
# --- localization ---
keyboard --xlayouts='us'
lang en_AU.UTF-8
timezone Australia/Sydney --utc
# --- security ---
{{ if .RootPasswordHash -}}
rootpw --iscrypted {{ .RootPasswordHash }}
{{- else -}}
rootpw --lock
{{- end }}
selinux --enforcing
firewall --enabled --service=ssh
authselect select sssd with-mkhomedir --force
# --- networking (static, from NetBox) ---
{{- $primary := .PrimaryInterface }}
{{- range .Interfaces }}
{{- if .IP }}
network --bootproto=static --device={{ .MAC }} --ip={{ .IP }} --netmask={{ .Netmask }}{{ if .Gateway }} --gateway={{ .Gateway }}{{ end }}{{ range $.Nameservers }} --nameserver={{ . }}{{ end }}{{ if and $primary (eq .MAC $primary.MAC) }} --hostname={{ $.FQDN }}{{ end }} --activate --onboot=on --noipv6
{{- end }}
{{- end }}
# --- storage ---
ignoredisk --only-use=sda
clearpart --all --initlabel --drives=sda
bootloader --location=mbr --boot-drive=sda --append="crashkernel=auto"
autopart --type=lvm --nohome
# --- packages ---
%packages --ignoremissing --excludedocs
@^minimal-environment
openssh-server
chrony
vim-minimal
tmux
git
-iwl*-firmware
%end
# --- bootstrap: hand off to Puppet firstrun ---
%post --log=/root/bootapi-post.log
set -x
# chrony: keep time sane before any cert work.
systemctl enable chronyd
{{ if .SSHAuthorizedKeys -}}
# root authorized_keys (from render-time config, not NetBox).
install -d -m0700 /root/.ssh
cat > /root/.ssh/authorized_keys <<'EOF'
{{ range .SSHAuthorizedKeys }}{{ . }}
{{ end }}EOF
chmod 0600 /root/.ssh/authorized_keys
{{- end }}
# Install the Puppet 8 agent from the puppet platform repo.
rpm -q puppet-agent >/dev/null 2>&1 || \
dnf install -y https://yum.puppet.com/puppet8-release-el-9.noarch.rpm
dnf install -y puppet-agent
# Point the agent at the Consul-discovered servers (matches the pre-bootapi
# Cobbler kickstart + hieradata/roles/infra/puppet).
PUPPET_BIN=/opt/puppetlabs/bin/puppet
"$PUPPET_BIN" config set --section main certname "{{ .FQDN }}"
"$PUPPET_BIN" config set --section main server "{{ .PuppetServer }}"
"$PUPPET_BIN" config set --section main ca_server "{{ .PuppetCAServer }}"
"$PUPPET_BIN" config set --section main report_server "{{ .PuppetServer }}"
"$PUPPET_BIN" config set --section main environment production
# Enable the agent; the first boot triggers firstrun (autosign handles the CSR).
systemctl enable puppet
%end
+66
View File
@@ -0,0 +1,66 @@
{{- /*
Fedora kickstart (family-level template: matches any "fedoraNN" platform slug
via the OS-family selection fallback). Kept close to the AlmaLinux template so
the two stay comparable; the differences are the install tree layout and that
Fedora ships a recent-enough dnf/agent story out of the box.
*/ -}}
#version=F{{ default "" .OSVersion }}
# Rendered by bootapi for {{ .FQDN }} (platform {{ .Platform }})
text
firstboot --disable
reboot
# --- install source ---
url --url={{ .BootBaseURL }}/releases/{{ default "rawhide" .OSVersion }}/Everything/{{ .Arch }}/os/
keyboard --xlayouts='us'
lang en_AU.UTF-8
timezone Australia/Sydney --utc
{{ if .RootPasswordHash -}}
rootpw --iscrypted {{ .RootPasswordHash }}
{{- else -}}
rootpw --lock
{{- end }}
selinux --enforcing
firewall --enabled --service=ssh
# --- networking (static, from NetBox) ---
{{- $primary := .PrimaryInterface }}
{{- range .Interfaces }}
{{- if .IP }}
network --bootproto=static --device={{ .MAC }} --ip={{ .IP }} --netmask={{ .Netmask }}{{ if .Gateway }} --gateway={{ .Gateway }}{{ end }}{{ range $.Nameservers }} --nameserver={{ . }}{{ end }}{{ if and $primary (eq .MAC $primary.MAC) }} --hostname={{ $.FQDN }}{{ end }} --activate --onboot=on --noipv6
{{- end }}
{{- end }}
# --- storage ---
ignoredisk --only-use=sda
clearpart --all --initlabel --drives=sda
bootloader --location=mbr --boot-drive=sda
autopart --type=lvm --nohome
%packages --ignoremissing
@^minimal-environment
openssh-server
chrony
git
%end
%post --log=/root/bootapi-post.log
set -x
systemctl enable chronyd sshd
{{ if .SSHAuthorizedKeys -}}
install -d -m0700 /root/.ssh
cat > /root/.ssh/authorized_keys <<'EOF'
{{ range .SSHAuthorizedKeys }}{{ . }}
{{ end }}EOF
chmod 0600 /root/.ssh/authorized_keys
{{- end }}
dnf install -y https://yum.puppet.com/puppet8-release-fedora-{{ default "40" .OSVersion }}.noarch.rpm || true
dnf install -y puppet-agent
PUPPET_BIN=/opt/puppetlabs/bin/puppet
"$PUPPET_BIN" config set --section main certname "{{ .FQDN }}"
"$PUPPET_BIN" config set --section main server "{{ .PuppetServer }}"
"$PUPPET_BIN" config set --section main ca_server "{{ .PuppetCAServer }}"
systemctl enable puppet
%end