Address PR review: PXE gate + callback, git-sync templates, distro catalog, k8s targets, http+https
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful

Implements the six review comments on PR #1:

- Per-host PXE-enable gate: read NetBox pxe_enabled custom field; a known host
  with it false gets the safe local-boot script (Cobbler netboot_enabled). Add a
  token-guarded POST /provisioned/{ident} callback that clears pxe_enabled in
  NetBox, plus a %post snippet in the default kickstarts that calls it.
- Templates from a git repo: bootapi clones a templates repo and re-pulls every
  BOOTAPI_TEMPLATE_GIT_INTERVAL (default 3m), atomically swapping the template
  set (last-good kept on parse failure; embedded defaults are the startup
  fallback). Metrics for syncs/failures/generation.
- Distro catalog (catalog/*.yaml): NetBox host -> boot images/kickstart, so
  adding an OS is a YAML + template change. Ships almalinux + fedora entries
  (artifactapi remotes); debian/talos path documented.
- Boot images from the artifactapi almalinux/fedora remotes via the catalog.
- Bind resolvers, puppet server/CA and PUPPETCA_URL env file now target the k8s
  services (198.18.200.7; puppet(ca).k8s.syd1.au.unkin.net).
- Boot path served over plain HTTP (installers lack CA trust) with an optional
  parallel HTTPS listener; docs say do not 301 the boot endpoints.

New packages: internal/catalog, internal/gitsync. NetBox client gains a
pxe_enabled write (token needs that scope - noted in docs). `bootapi validate`
subcommand validates a template/catalog set for the templates-repo CI.

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

Claude-Session: https://claude.ai/code/session_015ur3i7D2azsMAWTSVABApv
This commit is contained in:
2026-07-28 22:34:44 +10:00
parent 274c480b09
commit 8f356346eb
32 changed files with 2119 additions and 357 deletions
+124 -57
View File
@@ -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
+42 -1
View File
@@ -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) {