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

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

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

go build/vet clean, go test -race green, golangci-lint v2 clean, pre-commit clean.

Claude-Session: https://claude.ai/code/session_015ur3i7D2azsMAWTSVABApv
This commit is contained in:
2026-07-28 17:20:06 +10:00
parent 98e69d2fcb
commit 274c480b09
37 changed files with 3290 additions and 1 deletions
+76
View File
@@ -0,0 +1,76 @@
// Command bootapi is the PXE/kickstart boot service: it renders kickstart files
// and iPXE boot scripts from NetBox device data and serves them to PXE-booting
// hosts, replacing Cobbler's provisioning side.
package main
import (
"context"
"log/slog"
"os"
"os/signal"
"syscall"
"git.unkin.net/unkin/bootapi/internal/config"
"git.unkin.net/unkin/bootapi/internal/netbox"
"git.unkin.net/unkin/bootapi/internal/render"
"git.unkin.net/unkin/bootapi/internal/server"
"git.unkin.net/unkin/bootapi/templates"
)
var version = "dev"
func main() {
slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, nil)))
slog.Info("starting bootapi", "version", version)
cfg, err := config.Load()
if err != nil {
slog.Error("load config", "err", err)
os.Exit(1)
}
if cfg.NetBoxURL == "" {
slog.Warn("BOOTAPI_NETBOX_URL is empty; every host lookup will fail and iPXE will serve the fallback")
}
if cfg.NetBoxToken == "" {
slog.Warn("no NetBox token set (BOOTAPI_NETBOX_TOKEN/_FILE); NetBox reads will likely be denied")
}
engine, err := render.NewEngine(templates.FS, cfg.TemplateDir, render.RenderConfig{
PuppetServer: cfg.PuppetServer,
PuppetCAServer: cfg.PuppetCAServer,
BaseURL: cfg.BaseURL,
BootBaseURL: cfg.BootBaseURL,
DefaultDomain: cfg.Domain,
DefaultNS: cfg.Nameservers,
RootPasswordHash: cfg.RootPasswordHash,
SSHAuthorizedKeys: cfg.SSHAuthorizedKeys,
DefaultTemplate: cfg.DefaultTemplate,
})
if err != nil {
slog.Error("load templates", "err", err)
os.Exit(1)
}
nb := netbox.New(netbox.Options{
BaseURL: cfg.NetBoxURL,
Token: cfg.NetBoxToken,
Timeout: cfg.NetBoxTimeout,
Insecure: cfg.NetBoxInsecure,
})
cache := netbox.NewCache(nb, cfg.CacheTTL)
srv := server.New(server.Options{
Resolver: cache,
Engine: engine,
Cache: cache,
UnknownMACFallback: cfg.UnknownMACFallback,
})
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
if err := srv.ListenAndServe(ctx, cfg.ListenAddr); err != nil {
slog.Error("server", "err", err)
os.Exit(1)
}
}