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