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