Merge pull request 'Initial bootapi: NetBox-driven PXE/kickstart boot service' (#1) from benvin/bootapi-initial into main
Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
/bin/
|
||||
/dist/
|
||||
*.out
|
||||
*.test
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,33 @@
|
||||
when:
|
||||
- event: pull_request
|
||||
|
||||
steps:
|
||||
- name: lint
|
||||
image: golangci/golangci-lint:v2.5.0
|
||||
commands:
|
||||
- golangci-lint run ./...
|
||||
backend_options:
|
||||
kubernetes:
|
||||
serviceAccountName: default
|
||||
resources:
|
||||
requests:
|
||||
memory: 512Mi
|
||||
cpu: 1
|
||||
limits:
|
||||
memory: 2Gi
|
||||
cpu: 2
|
||||
|
||||
- name: test
|
||||
image: golang:1.25
|
||||
commands:
|
||||
- go test -race ./...
|
||||
backend_options:
|
||||
kubernetes:
|
||||
serviceAccountName: default
|
||||
resources:
|
||||
requests:
|
||||
memory: 512Mi
|
||||
cpu: 1
|
||||
limits:
|
||||
memory: 2Gi
|
||||
cpu: 2
|
||||
+21
@@ -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"]
|
||||
@@ -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
|
||||
@@ -1,3 +1,95 @@
|
||||
# bootapi
|
||||
|
||||
PXE/kickstart boot service replacing Cobbler. Renders kickstart + iPXE from NetBox data over HTTP. Go API.
|
||||
`bootapi` is a small Go service that replaces Cobbler's PXE/kickstart side. It
|
||||
renders kickstart files and iPXE boot scripts from **NetBox** device data and
|
||||
serves them over HTTP to PXE-booting hosts.
|
||||
|
||||
The ENC half of Cobbler already moved to [encapi](https://git.unkin.net/unkin/encapi).
|
||||
With bootapi, provisioning a new host is:
|
||||
|
||||
1. `terraform` the host into **NetBox** (device, interfaces/MACs, IP, platform,
|
||||
role) and **encapi** (classification), then
|
||||
2. rack it and let it **PXE-boot** — DHCP points it at bootapi, which serves the
|
||||
iPXE script and the rendered kickstart; the kickstart hands off to the
|
||||
existing Puppet firstrun bootstrap.
|
||||
|
||||
## How it works
|
||||
|
||||
```
|
||||
DHCP next-server ─▶ iPXE ─▶ GET /ipxe/<mac> ─▶ boot kernel+initrd, inst.ks=<bootapi>/ks/<host>
|
||||
└▶ GET /ks/<host> ─▶ rendered kickstart ─▶ puppet firstrun
|
||||
```
|
||||
|
||||
bootapi identifies the booting host by the **MAC** it booted from (NetBox
|
||||
interface lookup → device → primary IP, platform, role, interfaces), renders a
|
||||
Go `text/template` selected from the host's platform/role/custom-field, and
|
||||
serves it. Templates are embedded defaults, overridable from a directory
|
||||
(ConfigMap in k8s).
|
||||
|
||||
## Endpoints (summary)
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `GET /ipxe/{mac}` · `GET /boot/ipxe?mac=` | iPXE boot script |
|
||||
| `GET /ks/{ident}` | rendered kickstart (MAC or hostname) |
|
||||
| `POST /provisioned/{ident}` | end-of-kickstart callback (token) → clears `pxe_enabled` in NetBox |
|
||||
| `GET /healthz` · `/readyz` · `/metrics` | health + Prometheus |
|
||||
|
||||
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
|
||||
|
||||
- [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 (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 defaults: kickstart, iPXE, catalog/*.yaml
|
||||
docs/ see above
|
||||
```
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
// 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"
|
||||
"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"
|
||||
"git.unkin.net/unkin/bootapi/templates"
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
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")
|
||||
}
|
||||
if cfg.ProvisionToken == "" {
|
||||
slog.Warn("no BOOTAPI_PROVISION_TOKEN set; the /provisioned callback is disabled (pxe_enabled will not auto-clear)")
|
||||
}
|
||||
|
||||
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,
|
||||
Token: cfg.NetBoxToken,
|
||||
Timeout: cfg.NetBoxTimeout,
|
||||
Insecure: cfg.NetBoxInsecure,
|
||||
})
|
||||
cache := netbox.NewCache(nb, cfg.CacheTTL)
|
||||
|
||||
opts := server.Options{
|
||||
NetBox: cache,
|
||||
Engine: engine,
|
||||
Cache: cache,
|
||||
UnknownMACFallback: cfg.UnknownMACFallback,
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
# bootapi configuration (environment variables).
|
||||
#
|
||||
# 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 (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 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: a re-provisioned host must pick up changes next boot) ---
|
||||
BOOTAPI_CACHE_TTL=30s
|
||||
|
||||
# --- 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 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 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=
|
||||
|
||||
# --- 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
|
||||
|
||||
# --- 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
|
||||
# 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.
|
||||
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
|
||||
@@ -0,0 +1,94 @@
|
||||
# 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.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`
|
||||
|
||||
| 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 | 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.
|
||||
|
||||
## 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 | 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. |
|
||||
|
||||
## Distro selection and the catalog
|
||||
|
||||
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).
|
||||
|
||||
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`.
|
||||
@@ -0,0 +1,107 @@
|
||||
# Deploying bootapi
|
||||
|
||||
> The actual argocd-apps deployment is a **follow-up task** and is intentionally
|
||||
> not part of this repo. This document is the spec for that follow-up plus the
|
||||
> DHCP change the estate needs.
|
||||
|
||||
bootapi is a stateless HTTP service. It mirrors encapi's deployment shape: a Go
|
||||
binary in a distroless image, config from env, secrets from Vault via the Vault
|
||||
Secrets Operator (VSO).
|
||||
|
||||
## Container image
|
||||
|
||||
`git.unkin.net/unkin/bootapi:<tag>` (built + pushed by `.woodpecker/docker.yaml`
|
||||
on a `v*` tag). Also mirror to the artifactapi local docker registry if desired.
|
||||
|
||||
## Kubernetes wiring (argocd-apps follow-up)
|
||||
|
||||
Create `apps/base/bootapi/` following the argocd-apps `AGENTS.md` pattern:
|
||||
|
||||
1. **namespace** `bootapi`.
|
||||
2. **VaultAuth** (`default`) — kubernetes method, mount `k8s/au/syd1`, role
|
||||
`default`, SA `default` (copy netbox's `vaultauth.yaml`).
|
||||
3. **VaultStaticSecret** → k8s Secret `bootapi-secrets`, from Vault kv path
|
||||
`kubernetes/namespace/bootapi/default/bootapi-secrets` with keys:
|
||||
- `netbox_token` — a **dedicated** 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. **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_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 `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)
|
||||
|
||||
Cobbler advertised itself at anycast `198.18.19.19` as the DHCP `next-server`,
|
||||
with `filename "/ipxe.efi"` (UEFI arch 7/9) or `/undionly.kpxe` (BIOS arch 0).
|
||||
Today those are set in `puppet-prod` hieradata
|
||||
`hieradata/roles/infra/dhcp/server.yaml` (`pools.*.pxeserver` and the
|
||||
`UEFI-64`/`Legacy` dhcp classes).
|
||||
|
||||
To cut a subnet over to bootapi, repoint DHCP for that pool:
|
||||
|
||||
- `next-server` → bootapi's LB IP (or keep the `198.18.19.19` anycast and move
|
||||
the anycast advertisement to bootapi's node/LB).
|
||||
- `filename` → the iPXE binary as before (`/ipxe.efi` / `/undionly.kpxe`); bootapi
|
||||
does not serve the NBP itself. The chained iPXE must then be told to fetch
|
||||
bootapi's script — either bake `chain http://<bootapi>/ipxe/${net0/mac}` into
|
||||
the site iPXE binary/embedded script, or set DHCP option 67 to that URL for
|
||||
iPXE user-class requests. This replaces Cobbler's
|
||||
`chain http://${next-server}/cblr/svc/op/gpxe/mac/${net0/mac}`.
|
||||
|
||||
Roll one pool at a time (the PXE subnets are `198.18.13.0/24`–`198.18.17.0/24`);
|
||||
Puppet autosign already trusts those subnets and `*.main.unkin.net`, so a host
|
||||
installed via bootapi checks in exactly as before.
|
||||
@@ -0,0 +1,100 @@
|
||||
# bootapi HTTP endpoints
|
||||
|
||||
## 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 http://<base>/ipxe/<mac> ─────────────────────▶ bootapi renders a boot script
|
||||
boot ── kernel + initrd + inst.ks=http://<base>/ks/<host> ─▶ Anaconda fetches the kickstart
|
||||
KS ── GET http://<base>/ks/<host> ──────────────────────▶ bootapi renders the kickstart
|
||||
post ── POST http://<base>/provisioned/<host> (token) ────▶ bootapi clears pxe_enabled in NetBox
|
||||
```
|
||||
|
||||
This mirrors Cobbler, which chained iPXE to `/cblr/svc/op/gpxe/mac/<mac>`, 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}`. |
|
||||
| GET | `/ks/{ident}` | Rendered kickstart. `{ident}` is a MAC (auto-detected) or a hostname; trailing `.ks`/`.cfg` is stripped. |
|
||||
| POST | `/provisioned/{ident}` | End-of-kickstart callback; clears `pxe_enabled` in NetBox. **Token-guarded** (`Authorization: Bearer <BOOTAPI_PROVISION_TOKEN>`). |
|
||||
| GET | `/healthz` | Liveness: always `200 ok`. |
|
||||
| GET | `/readyz` | Readiness: `200` once templates parsed. Does **not** probe NetBox. |
|
||||
| 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}`), resolved via NetBox `GET /api/dcim/interfaces/?mac_address=<mac>`
|
||||
→ device → primary IP, platform, role, interfaces. `/ks/{ident}` and
|
||||
`/provisioned/{ident}` also accept a **hostname** (NetBox device name).
|
||||
|
||||
## Per-host PXE-enable gate (`pxe_enabled`)
|
||||
|
||||
`/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.
|
||||
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
|
||||
|
||||
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`.
|
||||
- `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.
|
||||
@@ -0,0 +1,74 @@
|
||||
# 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` |
|
||||
| **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
|
||||
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.
|
||||
|
||||
## 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
|
||||
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.
|
||||
@@ -0,0 +1,117 @@
|
||||
# Authoring templates
|
||||
|
||||
bootapi ships an embedded default set and lets you override or extend it.
|
||||
|
||||
## Where templates live
|
||||
|
||||
- **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
|
||||
|
||||
- Kickstart: `<name>.ks.tmpl` → registered as template `<name>`.
|
||||
- iPXE: `<name>.ipxe.tmpl` → registered as template `<name>`.
|
||||
|
||||
`<name>` is what template selection matches against (platform slug, OS family,
|
||||
`provision_template`, or the configured default — see
|
||||
[data-model.md](data-model.md#template-selection-precedence)).
|
||||
|
||||
Reserved iPXE names bootapi renders directly:
|
||||
- `boot` — the per-host boot script (`/ipxe/{mac}` for a known host).
|
||||
- `fallback-local`, `fallback-shell` — unknown-MAC fallbacks.
|
||||
|
||||
## The distro catalog
|
||||
|
||||
`catalog/*.yaml` describes each bootable OS, so adding a distro is a YAML +
|
||||
template change (and, if needed, a new artifactapi remote) — **no bootapi code
|
||||
change**. One file per distro:
|
||||
|
||||
```yaml
|
||||
name: almalinux9 # catalog key; also what provision_template matches
|
||||
match:
|
||||
platforms: [almalinux9] # exact NetBox platform slugs
|
||||
family: almalinux # OR an OS family (matches almalinux8/9/...)
|
||||
kickstart: almalinux9 # kickstart template name to render
|
||||
version_default: "9" # used when the platform slug carries no version
|
||||
kernel_url: "{{.ArtifactBase}}/almalinux/{{.Version}}/BaseOS/{{.Arch}}/os/images/pxeboot/vmlinuz"
|
||||
initrd_url: "{{.ArtifactBase}}/almalinux/{{.Version}}/BaseOS/{{.Arch}}/os/images/pxeboot/initrd.img"
|
||||
kernel_args: [inst.text, net.ifnames=0]
|
||||
vars: # arbitrary templated strings -> .DistroVars.<key>
|
||||
mirror: "{{.ArtifactBase}}/almalinux/{{.Version}}"
|
||||
```
|
||||
|
||||
`kernel_url`, `initrd_url` and each `vars` value are Go templates rendered with
|
||||
`{{.ArtifactBase}}` (`BOOTAPI_ARTIFACT_BASE_URL`), `{{.Version}}`, `{{.Arch}}`,
|
||||
`{{.Hostname}}`, `{{.Platform}}`, `{{.OSFamily}}`. The kickstart template reads
|
||||
`.DistroVars.mirror` to build its `url`/`repo` lines, so the install-tree layout
|
||||
lives entirely in the catalog. bootapi derives `inst.repo` for iPXE by trimming
|
||||
`/images/pxeboot/vmlinuz` off `kernel_url`.
|
||||
|
||||
Shipped entries: `almalinux9` (artifactapi `almalinux` remote) and `fedora`
|
||||
(`fedora` remote). **debian / talos** are documented but not implemented — their
|
||||
artifact shapes differ (Debian netboot `linux`+`initrd.gz` under
|
||||
`dists/<rel>/main/installer-<arch>/current/images/netboot/`; Talos ships factory
|
||||
`vmlinuz`+`initramfs.xz` images) so they need their own catalog fields/template
|
||||
and possibly a new artifactapi remote. See the catalog README in the templates
|
||||
repo for the intended path.
|
||||
|
||||
## Engine and functions
|
||||
|
||||
Standard Go `text/template`. Available funcs: `join`, `upper`, `lower`,
|
||||
`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
|
||||
```
|
||||
@@ -0,0 +1,22 @@
|
||||
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
|
||||
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
|
||||
github.com/prometheus/procfs v0.16.1 // indirect
|
||||
golang.org/x/sys v0.33.0 // indirect
|
||||
google.golang.org/protobuf v1.36.6 // indirect
|
||||
)
|
||||
@@ -0,0 +1,46 @@
|
||||
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=
|
||||
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/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=
|
||||
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/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=
|
||||
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/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=
|
||||
@@ -0,0 +1,227 @@
|
||||
// Package catalog is the distro catalog: a set of YAML descriptors (one per
|
||||
// bootable OS) that map a NetBox host to its boot images, kernel args and
|
||||
// kickstart template. Catalog files live in the templates git repo (or the
|
||||
// embedded defaults), so adding Fedora/Debian/Talos later is a YAML + template
|
||||
// change with no bootapi code change. Host -> distro selection stays
|
||||
// NetBox-driven (platform slug / family / provision_template override).
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"git.unkin.net/unkin/bootapi/internal/model"
|
||||
)
|
||||
|
||||
// Distro is one catalog entry (one YAML file).
|
||||
type Distro struct {
|
||||
// Name is the catalog key, also what a provision_template override matches.
|
||||
Name string `yaml:"name"`
|
||||
// Match decides which hosts this distro applies to.
|
||||
Match Match `yaml:"match"`
|
||||
// Kickstart is the kickstart template name to render for this distro.
|
||||
Kickstart string `yaml:"kickstart"`
|
||||
// KernelURL / InitrdURL are Go-template strings rendered with Ctx (they may
|
||||
// reference {{.ArtifactBase}}, {{.Version}}, {{.Arch}}).
|
||||
KernelURL string `yaml:"kernel_url"`
|
||||
InitrdURL string `yaml:"initrd_url"`
|
||||
// KernelArgs are extra iPXE kernel arguments appended verbatim.
|
||||
KernelArgs []string `yaml:"kernel_args"`
|
||||
// VersionDefault is used when the host's platform slug carries no version.
|
||||
VersionDefault string `yaml:"version_default"`
|
||||
// Vars are arbitrary named Go-template strings (rendered with Ctx) exposed
|
||||
// to kickstart/iPXE templates as .DistroVars.<key>. This is how a template
|
||||
// gets e.g. the install-tree mirror base without per-distro Go code.
|
||||
Vars map[string]string `yaml:"vars"`
|
||||
|
||||
kernelTmpl *template.Template
|
||||
initrdTmpl *template.Template
|
||||
varTmpls map[string]*template.Template
|
||||
}
|
||||
|
||||
// Match selects hosts for a Distro.
|
||||
type Match struct {
|
||||
// Platforms are exact NetBox platform slugs, e.g. ["almalinux9"].
|
||||
Platforms []string `yaml:"platforms"`
|
||||
// Family is a NetBox platform family, e.g. "fedora" (matches fedora42 etc).
|
||||
Family string `yaml:"family"`
|
||||
}
|
||||
|
||||
// Ctx is the value catalog URL/var templates are rendered against.
|
||||
type Ctx struct {
|
||||
ArtifactBase string
|
||||
Version string
|
||||
Arch string
|
||||
Hostname string
|
||||
Platform string
|
||||
OSFamily string
|
||||
}
|
||||
|
||||
// Resolved is a Distro with its templated fields evaluated for a specific host.
|
||||
type Resolved struct {
|
||||
Name string
|
||||
Kickstart string
|
||||
KernelURL string
|
||||
InitrdURL string
|
||||
KernelArgs []string
|
||||
Vars map[string]string
|
||||
}
|
||||
|
||||
// Catalog is the parsed, validated set of distros.
|
||||
type Catalog struct {
|
||||
distros []*Distro
|
||||
}
|
||||
|
||||
// Parse builds a Catalog from named YAML documents (filename -> contents),
|
||||
// validating each and compiling its templates. It is deterministic: distros are
|
||||
// sorted by name so selection is stable regardless of map iteration order.
|
||||
func Parse(files map[string][]byte) (*Catalog, error) {
|
||||
var distros []*Distro
|
||||
names := make([]string, 0, len(files))
|
||||
for f := range files {
|
||||
names = append(names, f)
|
||||
}
|
||||
sort.Strings(names)
|
||||
|
||||
for _, f := range names {
|
||||
d := &Distro{}
|
||||
if err := yaml.Unmarshal(files[f], d); err != nil {
|
||||
return nil, fmt.Errorf("catalog %s: %w", f, err)
|
||||
}
|
||||
if err := d.compile(); err != nil {
|
||||
return nil, fmt.Errorf("catalog %s: %w", f, err)
|
||||
}
|
||||
distros = append(distros, d)
|
||||
}
|
||||
sort.Slice(distros, func(i, j int) bool { return distros[i].Name < distros[j].Name })
|
||||
return &Catalog{distros: distros}, nil
|
||||
}
|
||||
|
||||
func (d *Distro) compile() error {
|
||||
if d.Name == "" {
|
||||
return fmt.Errorf("missing name")
|
||||
}
|
||||
if d.Kickstart == "" {
|
||||
return fmt.Errorf("%s: missing kickstart", d.Name)
|
||||
}
|
||||
if d.KernelURL == "" || d.InitrdURL == "" {
|
||||
return fmt.Errorf("%s: kernel_url and initrd_url are required", d.Name)
|
||||
}
|
||||
if len(d.Match.Platforms) == 0 && d.Match.Family == "" {
|
||||
return fmt.Errorf("%s: match needs at least one platform or a family", d.Name)
|
||||
}
|
||||
var err error
|
||||
if d.kernelTmpl, err = template.New("kernel").Parse(d.KernelURL); err != nil {
|
||||
return fmt.Errorf("%s: kernel_url: %w", d.Name, err)
|
||||
}
|
||||
if d.initrdTmpl, err = template.New("initrd").Parse(d.InitrdURL); err != nil {
|
||||
return fmt.Errorf("%s: initrd_url: %w", d.Name, err)
|
||||
}
|
||||
d.varTmpls = map[string]*template.Template{}
|
||||
for k, v := range d.Vars {
|
||||
t, err := template.New(k).Parse(v)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: var %q: %w", d.Name, k, err)
|
||||
}
|
||||
d.varTmpls[k] = t
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// All returns the catalog's distros (sorted by name).
|
||||
func (c *Catalog) All() []*Distro { return c.distros }
|
||||
|
||||
// Names returns the catalog distro names (sorted); handy for tests/logging.
|
||||
func (c *Catalog) Names() []string {
|
||||
out := make([]string, len(c.distros))
|
||||
for i, d := range c.distros {
|
||||
out[i] = d.Name
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Select returns the distro for a host, following precedence:
|
||||
// 1. provision_template override that names a distro exactly,
|
||||
// 2. exact platform-slug match,
|
||||
// 3. OS-family match.
|
||||
//
|
||||
// It reports false when nothing matches (caller falls back to legacy behavior).
|
||||
func (c *Catalog) Select(h *model.Host) (*Distro, bool) {
|
||||
if h.TemplateOverride != "" {
|
||||
for _, d := range c.distros {
|
||||
if d.Name == h.TemplateOverride {
|
||||
return d, true
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, d := range c.distros {
|
||||
for _, p := range d.Match.Platforms {
|
||||
if p == h.Platform && h.Platform != "" {
|
||||
return d, true
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, d := range c.distros {
|
||||
if d.Match.Family != "" && d.Match.Family == h.OSFamily {
|
||||
return d, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Resolve evaluates a distro's templated fields for a host against artifactBase.
|
||||
func (d *Distro) Resolve(h *model.Host, artifactBase string) (*Resolved, error) {
|
||||
version := h.OSVersion
|
||||
if version == "" {
|
||||
version = d.VersionDefault
|
||||
}
|
||||
arch := h.Arch
|
||||
if arch == "" {
|
||||
arch = "x86_64"
|
||||
}
|
||||
ctx := Ctx{
|
||||
ArtifactBase: strings.TrimRight(artifactBase, "/"),
|
||||
Version: version,
|
||||
Arch: arch,
|
||||
Hostname: h.Hostname,
|
||||
Platform: h.Platform,
|
||||
OSFamily: h.OSFamily,
|
||||
}
|
||||
kernel, err := exec(d.kernelTmpl, ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s kernel_url: %w", d.Name, err)
|
||||
}
|
||||
initrd, err := exec(d.initrdTmpl, ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s initrd_url: %w", d.Name, err)
|
||||
}
|
||||
vars := map[string]string{}
|
||||
for k, t := range d.varTmpls {
|
||||
v, err := exec(t, ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s var %q: %w", d.Name, k, err)
|
||||
}
|
||||
vars[k] = v
|
||||
}
|
||||
return &Resolved{
|
||||
Name: d.Name,
|
||||
Kickstart: d.Kickstart,
|
||||
KernelURL: kernel,
|
||||
InitrdURL: initrd,
|
||||
KernelArgs: d.KernelArgs,
|
||||
Vars: vars,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func exec(t *template.Template, ctx Ctx) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
if err := t.Execute(&buf, ctx); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return buf.String(), nil
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
// 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 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. 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.
|
||||
CacheTTL time.Duration
|
||||
|
||||
// TemplateDir, when set, is a directory of override templates layered on
|
||||
// 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 no catalog/platform
|
||||
// selection key matches.
|
||||
DefaultTemplate 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
|
||||
|
||||
// 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 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 records none.
|
||||
Domain string
|
||||
// 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 (Vault in k8s). Empty locks the root account.
|
||||
RootPasswordHash string
|
||||
// SSHAuthorizedKeys are public keys installed for root at render time.
|
||||
SSHAuthorizedKeys []string
|
||||
|
||||
// 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. *_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 {
|
||||
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)
|
||||
}
|
||||
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, 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")
|
||||
if fallback != "local" && fallback != "shell" {
|
||||
return nil, fmt.Errorf("invalid BOOTAPI_UNKNOWN_MAC_FALLBACK %q: want \"local\" or \"shell\"", fallback)
|
||||
}
|
||||
|
||||
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"),
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
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.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) {
|
||||
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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) }
|
||||
@@ -0,0 +1,119 @@
|
||||
// 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 {
|
||||
// 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".
|
||||
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
|
||||
|
||||
// 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".
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
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 API
|
||||
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 API, ttl time.Duration) *Cache {
|
||||
return &Cache{
|
||||
inner: inner,
|
||||
ttl: ttl,
|
||||
now: time.Now,
|
||||
entries: map[string]cacheEntry{},
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
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
|
||||
writes 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 (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"}}
|
||||
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 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
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,494 @@
|
||||
// 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 (
|
||||
"bytes"
|
||||
"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)
|
||||
}
|
||||
|
||||
// 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
|
||||
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{
|
||||
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 {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
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 ""
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
package netbox
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"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 {
|
||||
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 (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",
|
||||
"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,"pxe_enabled":true}}`)
|
||||
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 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)
|
||||
}
|
||||
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 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",
|
||||
"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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
// 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, 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"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"text/template"
|
||||
|
||||
"git.unkin.net/unkin/bootapi/internal/catalog"
|
||||
"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
|
||||
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
|
||||
}
|
||||
|
||||
// RenderConfig carries the render-time infra values merged into each Data.
|
||||
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
|
||||
SSHAuthorizedKeys []string
|
||||
DefaultTemplate string
|
||||
}
|
||||
|
||||
const (
|
||||
ksExt = ".ks.tmpl"
|
||||
ipxeExt = ".ipxe.tmpl"
|
||||
)
|
||||
|
||||
// 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)
|
||||
ksNames := map[string]bool{}
|
||||
catFiles := map[string][]byte{}
|
||||
|
||||
if err := walkSet(embedded, ks, ipxe, ksNames, catFiles, true); err != nil {
|
||||
return nil, fmt.Errorf("parse embedded templates: %w", err)
|
||||
}
|
||||
if override != nil {
|
||||
if err := walkSet(override, ks, ipxe, ksNames, catFiles, false); err != nil {
|
||||
return nil, fmt.Errorf("parse override templates: %w", err)
|
||||
}
|
||||
}
|
||||
cat, err := catalog.Parse(catFiles)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Set{ks: ks, ipxe: ipxe, ksSet: ksNames, cat: cat}, nil
|
||||
}
|
||||
|
||||
// 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, ".", func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
walked = true
|
||||
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)
|
||||
}
|
||||
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
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if mustExist && !walked {
|
||||
return fmt.Errorf("no templates found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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 != "" && set.ksSet[cand] {
|
||||
return cand, true
|
||||
}
|
||||
}
|
||||
return e.cfg.DefaultTemplate, set.ksSet[e.cfg.DefaultTemplate]
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
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 = e.cfg.BaseURL + "/ks/" + h.Hostname
|
||||
}
|
||||
cbURL := ""
|
||||
if e.cfg.CallbackBaseURL != "" {
|
||||
cbURL = e.cfg.CallbackBaseURL + "/provisioned/" + 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,
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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 := 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
|
||||
}
|
||||
|
||||
// IPXEData is the value passed to iPXE templates.
|
||||
type IPXEData struct {
|
||||
Data
|
||||
// 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) {
|
||||
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"
|
||||
}
|
||||
id.RepoURL = strings.TrimSuffix(id.KernelURL, "/images/pxeboot/vmlinuz")
|
||||
return e.execIPXE(set, "boot", id)
|
||||
}
|
||||
|
||||
// RenderFallback renders a fallback iPXE script ("local" or "shell").
|
||||
func (e *Engine) RenderFallback(kind string) ([]byte, error) {
|
||||
return e.execIPXE(e.cur.Load(), "fallback-"+kind, IPXEData{})
|
||||
}
|
||||
|
||||
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 := 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 <dir>`) to reject a bad template/catalog before it ships.
|
||||
func (e *Engine) Validate() error {
|
||||
set := e.cur.Load()
|
||||
distros := set.cat.All()
|
||||
if len(distros) == 0 {
|
||||
return fmt.Errorf("catalog is empty: no distros to validate")
|
||||
}
|
||||
var errs []string
|
||||
for _, d := range distros {
|
||||
h := fixtureHost(d)
|
||||
ks, name, err := e.RenderKickstart(h)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Sprintf("%s: kickstart: %v", d.Name, err))
|
||||
} else if bad := unresolved(ks); bad != "" {
|
||||
errs = append(errs, fmt.Sprintf("%s: kickstart %q has unresolved value near %q", d.Name, name, bad))
|
||||
}
|
||||
ipxe, err := e.RenderIPXE(h)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Sprintf("%s: ipxe: %v", d.Name, err))
|
||||
} else if bad := unresolved(ipxe); bad != "" {
|
||||
errs = append(errs, fmt.Sprintf("%s: ipxe has unresolved value near %q", d.Name, bad))
|
||||
}
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
return fmt.Errorf("catalog validation failed:\n - %s", strings.Join(errs, "\n - "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// fixtureHost builds a representative host that selects distro d (via an exact
|
||||
// override) with a plausible version/network, for validation rendering.
|
||||
func fixtureHost(d *catalog.Distro) *model.Host {
|
||||
version := d.VersionDefault
|
||||
platform := d.Name
|
||||
if len(d.Match.Platforms) > 0 {
|
||||
platform = d.Match.Platforms[0]
|
||||
}
|
||||
return &model.Host{
|
||||
Hostname: "fixture", Domain: "example.net", Platform: platform,
|
||||
OSFamily: d.Match.Family, OSVersion: version, Arch: "x86_64",
|
||||
TemplateOverride: d.Name, PrimaryIP: "10.0.0.10",
|
||||
Interfaces: []model.Interface{{
|
||||
Name: "eth0", MAC: "aa:bb:cc:00:11:22", IP: "10.0.0.10",
|
||||
PrefixLen: 24, Netmask: "255.255.255.0", Gateway: "10.0.0.1", Primary: true,
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
// unresolved returns the surrounding text of the first Go-template "<no value>"
|
||||
// marker, or "" if none — a cheap check that the data model covered the template.
|
||||
func unresolved(b []byte) string {
|
||||
s := string(b)
|
||||
if i := strings.Index(s, "<no value>"); i >= 0 {
|
||||
start := max(0, i-30)
|
||||
return s[start : i+10]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func funcMap() template.FuncMap {
|
||||
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
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package render
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.unkin.net/unkin/bootapi/internal/model"
|
||||
"git.unkin.net/unkin/bootapi/templates"
|
||||
)
|
||||
|
||||
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",
|
||||
CallbackBaseURL: "http://bootapi.example.net",
|
||||
ArtifactBase: artifactBase,
|
||||
ProvisionToken: "prov-secret",
|
||||
DefaultDomain: "main.unkin.net",
|
||||
DefaultNS: []string{"198.18.200.7"},
|
||||
RootPasswordHash: "$6$rounds=4096$abc$deadbeef",
|
||||
SSHAuthorizedKeys: []string{"ssh-ed25519 AAAAC3xxx root@ops"},
|
||||
DefaultTemplate: "almalinux9",
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
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 -> skipped in network stanza
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderKickstartAlma(t *testing.T) {
|
||||
e := testEngine(t, nil)
|
||||
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")
|
||||
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")
|
||||
|
||||
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) {
|
||||
cfg := testCfg()
|
||||
cfg.RootPasswordHash = ""
|
||||
set, err := BuildSet(templates.FS, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, _, err := NewEngine(cfg, set).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, nil)
|
||||
cases := []struct {
|
||||
host *model.Host
|
||||
want string
|
||||
}{
|
||||
{&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 {
|
||||
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 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 "+artifactBase+"/almalinux/9/BaseOS/x86_64/os/images/pxeboot/vmlinuz")
|
||||
mustContain(t, s, "initrd "+artifactBase+"/almalinux/9/BaseOS/x86_64/os/images/pxeboot/initrd.img")
|
||||
mustContain(t, s, "inst.repo="+artifactBase+"/almalinux/9/BaseOS/x86_64/os")
|
||||
mustContain(t, s, "inst.ks=http://bootapi.example.net/ks/web01")
|
||||
mustContain(t, s, "inst.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, nil)
|
||||
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, os.DirFS(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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
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
|
||||
}
|
||||
|
||||
// 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 {
|
||||
reg *prometheus.Registry
|
||||
|
||||
httpRequests *prometheus.CounterVec // by endpoint,status
|
||||
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, git gitStats) *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"}),
|
||||
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, 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{}),
|
||||
)
|
||||
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 {
|
||||
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()))
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
// 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"
|
||||
"crypto/subtle"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"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 API and template engine into HTTP handlers.
|
||||
type Server struct {
|
||||
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 {
|
||||
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.
|
||||
func New(o Options) *Server {
|
||||
fb := o.UnknownMACFallback
|
||||
if fb == "" {
|
||||
fb = "local"
|
||||
}
|
||||
return &Server{
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
// End-of-kickstart callback: flips pxe_enabled off in NetBox. Token-guarded.
|
||||
r.Post("/provisioned/{ident}", s.handleProvisioned)
|
||||
|
||||
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
|
||||
}
|
||||
// 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()
|
||||
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")
|
||||
}
|
||||
|
||||
// handleProvisioned is the end-of-kickstart callback. The %post posts here with
|
||||
// the shared provision token when the install finishes; bootapi flips the host's
|
||||
// pxe_enabled custom field to false in NetBox so the next PXE boots local disk
|
||||
// instead of re-installing. This is bootapi's only NetBox write.
|
||||
func (s *Server) handleProvisioned(w http.ResponseWriter, r *http.Request) {
|
||||
if s.provisionToken == "" {
|
||||
http.Error(w, "provisioned callback disabled: no token configured", http.StatusServiceUnavailable)
|
||||
s.metrics.provisioned.WithLabelValues("disabled").Inc()
|
||||
return
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(bearer(r)), []byte(s.provisionToken)) != 1 {
|
||||
http.Error(w, "invalid or missing provision token", http.StatusUnauthorized)
|
||||
s.metrics.provisioned.WithLabelValues("unauthorized").Inc()
|
||||
return
|
||||
}
|
||||
ident := chi.URLParam(r, "ident")
|
||||
field := "name"
|
||||
if looksLikeMAC(ident) {
|
||||
field = "mac"
|
||||
}
|
||||
host, err := s.lookup(r.Context(), field, ident)
|
||||
if err != nil {
|
||||
if errors.Is(err, netbox.ErrNotFound) {
|
||||
http.Error(w, "no host in NetBox for "+ident, http.StatusNotFound)
|
||||
s.metrics.provisioned.WithLabelValues("notfound").Inc()
|
||||
return
|
||||
}
|
||||
http.Error(w, "netbox lookup failed", http.StatusBadGateway)
|
||||
s.metrics.provisioned.WithLabelValues("error").Inc()
|
||||
return
|
||||
}
|
||||
if err := s.nb.SetPXEEnabled(r.Context(), host.DeviceID, false); err != nil {
|
||||
slog.Error("provisioned: failed to clear pxe_enabled", "host", host.Hostname, "err", err)
|
||||
http.Error(w, "failed to update NetBox", http.StatusBadGateway)
|
||||
s.metrics.provisioned.WithLabelValues("error").Inc()
|
||||
return
|
||||
}
|
||||
s.metrics.provisioned.WithLabelValues("ok").Inc()
|
||||
slog.Info("host provisioned; pxe_enabled cleared", "host", host.Hostname)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// bearer extracts a token from "Authorization: Bearer <t>" or a bare "token"
|
||||
// header.
|
||||
func bearer(r *http.Request) string {
|
||||
if h := r.Header.Get("Authorization"); h != "" {
|
||||
if after, ok := strings.CutPrefix(h, "Bearer "); ok {
|
||||
return after
|
||||
}
|
||||
}
|
||||
return r.Header.Get("token")
|
||||
}
|
||||
|
||||
// lookup resolves a host by field ("mac" or "name"), recording metrics.
|
||||
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.nb.HostByMAC(ctx, value)
|
||||
} else {
|
||||
host, err = s.nb.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 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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
// 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)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
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"
|
||||
)
|
||||
|
||||
// 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 *fakeNB) 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 *fakeNB) 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 (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",
|
||||
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, nb netbox.API, fallback string) *Server {
|
||||
t.Helper()
|
||||
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)
|
||||
}
|
||||
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 {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
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 := &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")
|
||||
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, &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 {
|
||||
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, &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())
|
||||
}
|
||||
}
|
||||
|
||||
func TestIPXEQueryAlias(t *testing.T) {
|
||||
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=") {
|
||||
t.Fatalf("query-style ipxe failed: %d\n%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestKickstartByMAC(t *testing.T) {
|
||||
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 {
|
||||
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 := &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 {
|
||||
t.Fatalf("status = %d\n%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestKickstartUnknownIs404(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthAndReady(t *testing.T) {
|
||||
h := newTestServer(t, &fakeNB{}, "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 := &fakeNB{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 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"}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}}"
|
||||
@@ -0,0 +1,16 @@
|
||||
# Distro catalog entry: Fedora (family-level, matches any fedoraNN platform).
|
||||
# Boot images are proxied through the artifactapi "fedora" remote, whose tree
|
||||
# lives under releases/<ver>/Everything/<arch>/os/.
|
||||
name: fedora
|
||||
match:
|
||||
family: fedora
|
||||
kickstart: fedora
|
||||
version_default: "41"
|
||||
kernel_url: "{{.ArtifactBase}}/fedora/releases/{{.Version}}/Everything/{{.Arch}}/os/images/pxeboot/vmlinuz"
|
||||
initrd_url: "{{.ArtifactBase}}/fedora/releases/{{.Version}}/Everything/{{.Arch}}/os/images/pxeboot/initrd.img"
|
||||
kernel_args:
|
||||
- inst.text
|
||||
- net.ifnames=0
|
||||
vars:
|
||||
# Install-tree root; the kickstart appends <arch>/os/ under it.
|
||||
mirror: "{{.ArtifactBase}}/fedora/releases/{{.Version}}/Everything"
|
||||
@@ -0,0 +1,12 @@
|
||||
// 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, ipxe/*.ipxe.tmpl and
|
||||
// catalog/*.yaml (the distro catalog).
|
||||
//
|
||||
//go:embed kickstart ipxe catalog
|
||||
var FS embed.FS
|
||||
@@ -0,0 +1,20 @@
|
||||
{{- /*
|
||||
iPXE boot script for a known, PXE-enabled host. Chains the OS installer
|
||||
kernel+initrd (from the distro catalog) and points inst.ks= back at bootapi's
|
||||
/ks/<host> over plain HTTP, so an installer with no internal-CA trust can fetch
|
||||
it. Mirrors how Cobbler generated a per-MAC gPXE script carrying inst.ks=.
|
||||
|
||||
KernelURL/InitrdURL/RepoURL come from the selected catalog entry (artifactapi
|
||||
remote); KernelArgs are the catalog's extra args. KickstartURL uses
|
||||
BOOTAPI_BASE_URL (http://).
|
||||
*/ -}}
|
||||
#!ipxe
|
||||
echo bootapi: provisioning {{ .FQDN }} ({{ .Platform }})
|
||||
{{ if and .KernelURL .InitrdURL -}}
|
||||
kernel {{ .KernelURL }} initrd=initrd.img{{ if .RepoURL }} inst.repo={{ .RepoURL }}{{ end }} inst.ks={{ .KickstartURL }} ip=dhcp{{ range .KernelArgs }} {{ . }}{{ end }}
|
||||
initrd {{ .InitrdURL }}
|
||||
boot
|
||||
{{- else -}}
|
||||
echo bootapi: no boot images resolved for {{ .Platform }} (no catalog entry / BOOTAPI_BOOT_BASE_URL); booting local disk
|
||||
sanboot --no-describe --drive 0x80 || exit
|
||||
{{- end }}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,113 @@
|
||||
{{- /*
|
||||
AlmaLinux 9 kickstart, ported from the Cobbler default.ks contract.
|
||||
|
||||
Rendered by bootapi from NetBox data + render-time secrets + the distro catalog.
|
||||
Install source comes from the artifactapi almalinux remote (via the catalog
|
||||
mirror var). The %post installs the Puppet agent and points it at the k8s
|
||||
puppetserver (puppet.k8s.syd1.au.unkin.net / puppetca.k8s...), writes the
|
||||
puppet-initial PUPPETCA_URL env file, then posts back to bootapi so pxe_enabled
|
||||
flips off (Cobbler's netboot_enabled flow).
|
||||
|
||||
Data model: see docs/data-model.md. `.RootPasswordHash` and `.ProvisionToken`
|
||||
come from Vault/env at render time, never from NetBox.
|
||||
*/ -}}
|
||||
{{- $mirror := .DistroVars.mirror -}}
|
||||
#version=RHEL9
|
||||
# Rendered by bootapi for {{ .FQDN }} (platform {{ .Platform }}, role {{ default "none" .Role }})
|
||||
text
|
||||
eula --agreed
|
||||
firstboot --disable
|
||||
reboot
|
||||
|
||||
# --- install source (artifactapi almalinux remote, from the distro catalog) ---
|
||||
url --url={{ $mirror }}/BaseOS/{{ .Arch }}/os/
|
||||
repo --name=AppStream --baseurl={{ $mirror }}/AppStream/{{ .Arch }}/os/
|
||||
|
||||
# --- localization ---
|
||||
keyboard --xlayouts='us'
|
||||
lang en_AU.UTF-8
|
||||
timezone Australia/Sydney --utc
|
||||
|
||||
# --- security ---
|
||||
{{ if .RootPasswordHash -}}
|
||||
rootpw --iscrypted {{ .RootPasswordHash }}
|
||||
{{- else -}}
|
||||
rootpw --lock
|
||||
{{- end }}
|
||||
selinux --enforcing
|
||||
firewall --enabled --service=ssh
|
||||
authselect select sssd with-mkhomedir --force
|
||||
|
||||
# --- networking (static, from NetBox) ---
|
||||
{{- $primary := .PrimaryInterface }}
|
||||
{{- range .Interfaces }}
|
||||
{{- if .IP }}
|
||||
network --bootproto=static --device={{ .MAC }} --ip={{ .IP }} --netmask={{ .Netmask }}{{ if .Gateway }} --gateway={{ .Gateway }}{{ end }}{{ range $.Nameservers }} --nameserver={{ . }}{{ end }}{{ if and $primary (eq .MAC $primary.MAC) }} --hostname={{ $.FQDN }}{{ end }} --activate --onboot=on --noipv6
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
# --- storage ---
|
||||
ignoredisk --only-use=sda
|
||||
clearpart --all --initlabel --drives=sda
|
||||
bootloader --location=mbr --boot-drive=sda --append="crashkernel=auto"
|
||||
autopart --type=lvm --nohome
|
||||
|
||||
# --- packages ---
|
||||
%packages --ignoremissing --excludedocs
|
||||
@^minimal-environment
|
||||
openssh-server
|
||||
chrony
|
||||
vim-minimal
|
||||
tmux
|
||||
git
|
||||
-iwl*-firmware
|
||||
%end
|
||||
|
||||
# --- bootstrap: puppet (k8s) + end-of-install callback ---
|
||||
%post --log=/root/bootapi-post.log
|
||||
set -x
|
||||
|
||||
# chrony: keep time sane before any cert work.
|
||||
systemctl enable chronyd
|
||||
|
||||
{{ if .SSHAuthorizedKeys -}}
|
||||
# root authorized_keys (from render-time config, not NetBox).
|
||||
install -d -m0700 /root/.ssh
|
||||
cat > /root/.ssh/authorized_keys <<'EOF'
|
||||
{{ range .SSHAuthorizedKeys }}{{ . }}
|
||||
{{ end }}EOF
|
||||
chmod 0600 /root/.ssh/authorized_keys
|
||||
{{- end }}
|
||||
|
||||
# Install the Puppet 8 agent from the puppet platform repo.
|
||||
rpm -q puppet-agent >/dev/null 2>&1 || \
|
||||
dnf install -y https://yum.puppet.com/puppet8-release-el-9.noarch.rpm
|
||||
dnf install -y puppet-agent
|
||||
|
||||
# Point the agent at the k8s puppetserver / CA.
|
||||
PUPPET_BIN=/opt/puppetlabs/bin/puppet
|
||||
"$PUPPET_BIN" config set --section main certname "{{ .FQDN }}"
|
||||
"$PUPPET_BIN" config set --section main server "{{ .PuppetServer }}"
|
||||
"$PUPPET_BIN" config set --section main ca_server "{{ .PuppetCAServer }}"
|
||||
"$PUPPET_BIN" config set --section main report_server "{{ .PuppetServer }}"
|
||||
"$PUPPET_BIN" config set --section main environment production
|
||||
|
||||
# puppet-initial bootstrap unit reads PUPPETCA_URL from this EnvironmentFile.
|
||||
install -d -m0755 /etc/sysconfig
|
||||
cat > /etc/sysconfig/puppet-initial <<'EOF'
|
||||
PUPPETCA_URL={{ .PuppetCAURL }}
|
||||
EOF
|
||||
|
||||
# Enable the agent; first boot triggers firstrun (autosign handles the CSR).
|
||||
systemctl enable puppet
|
||||
|
||||
{{ if and .ProvisionToken .CallbackURL -}}
|
||||
# Tell bootapi the install is done so it clears pxe_enabled in NetBox and the
|
||||
# next PXE boots local disk. Runs over plain HTTP (no internal CA trust yet);
|
||||
# the token authenticates the call. Non-fatal if it fails (the local-disk
|
||||
# fallback still protects a re-provisioned host on the following boot).
|
||||
curl -fsS -m 15 -X POST \
|
||||
-H "Authorization: Bearer {{ .ProvisionToken }}" \
|
||||
"{{ .CallbackURL }}" || echo "bootapi: provisioned callback failed (non-fatal)"
|
||||
{{- end }}
|
||||
%end
|
||||
@@ -0,0 +1,80 @@
|
||||
{{- /*
|
||||
Fedora kickstart (family-level template: matches any "fedoraNN" platform slug
|
||||
via the catalog family match). Kept close to the AlmaLinux template so the two
|
||||
stay comparable; the differences are the install-tree layout (releases/.../
|
||||
Everything) and the puppet release RPM. Install source + boot images come from
|
||||
the artifactapi fedora remote via the distro catalog.
|
||||
*/ -}}
|
||||
{{- $mirror := .DistroVars.mirror -}}
|
||||
#version=F{{ default "" .OSVersion }}
|
||||
# Rendered by bootapi for {{ .FQDN }} (platform {{ .Platform }})
|
||||
text
|
||||
firstboot --disable
|
||||
reboot
|
||||
|
||||
# --- install source (artifactapi fedora remote, from the distro catalog) ---
|
||||
url --url={{ $mirror }}/{{ .Arch }}/os/
|
||||
|
||||
keyboard --xlayouts='us'
|
||||
lang en_AU.UTF-8
|
||||
timezone Australia/Sydney --utc
|
||||
|
||||
{{ if .RootPasswordHash -}}
|
||||
rootpw --iscrypted {{ .RootPasswordHash }}
|
||||
{{- else -}}
|
||||
rootpw --lock
|
||||
{{- end }}
|
||||
selinux --enforcing
|
||||
firewall --enabled --service=ssh
|
||||
|
||||
# --- networking (static, from NetBox) ---
|
||||
{{- $primary := .PrimaryInterface }}
|
||||
{{- range .Interfaces }}
|
||||
{{- if .IP }}
|
||||
network --bootproto=static --device={{ .MAC }} --ip={{ .IP }} --netmask={{ .Netmask }}{{ if .Gateway }} --gateway={{ .Gateway }}{{ end }}{{ range $.Nameservers }} --nameserver={{ . }}{{ end }}{{ if and $primary (eq .MAC $primary.MAC) }} --hostname={{ $.FQDN }}{{ end }} --activate --onboot=on --noipv6
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
# --- storage ---
|
||||
ignoredisk --only-use=sda
|
||||
clearpart --all --initlabel --drives=sda
|
||||
bootloader --location=mbr --boot-drive=sda
|
||||
autopart --type=lvm --nohome
|
||||
|
||||
%packages --ignoremissing
|
||||
@^minimal-environment
|
||||
openssh-server
|
||||
chrony
|
||||
git
|
||||
%end
|
||||
|
||||
%post --log=/root/bootapi-post.log
|
||||
set -x
|
||||
systemctl enable chronyd sshd
|
||||
{{ if .SSHAuthorizedKeys -}}
|
||||
install -d -m0700 /root/.ssh
|
||||
cat > /root/.ssh/authorized_keys <<'EOF'
|
||||
{{ range .SSHAuthorizedKeys }}{{ . }}
|
||||
{{ end }}EOF
|
||||
chmod 0600 /root/.ssh/authorized_keys
|
||||
{{- end }}
|
||||
dnf install -y "https://yum.puppet.com/puppet8-release-fedora-{{ default "40" .OSVersion }}.noarch.rpm" || true
|
||||
dnf install -y puppet-agent
|
||||
PUPPET_BIN=/opt/puppetlabs/bin/puppet
|
||||
"$PUPPET_BIN" config set --section main certname "{{ .FQDN }}"
|
||||
"$PUPPET_BIN" config set --section main server "{{ .PuppetServer }}"
|
||||
"$PUPPET_BIN" config set --section main ca_server "{{ .PuppetCAServer }}"
|
||||
|
||||
install -d -m0755 /etc/sysconfig
|
||||
cat > /etc/sysconfig/puppet-initial <<'EOF'
|
||||
PUPPETCA_URL={{ .PuppetCAURL }}
|
||||
EOF
|
||||
|
||||
systemctl enable puppet
|
||||
|
||||
{{ if and .ProvisionToken .CallbackURL -}}
|
||||
curl -fsS -m 15 -X POST \
|
||||
-H "Authorization: Bearer {{ .ProvisionToken }}" \
|
||||
"{{ .CallbackURL }}" || echo "bootapi: provisioned callback failed (non-fatal)"
|
||||
{{- end }}
|
||||
%end
|
||||
Reference in New Issue
Block a user