Address PR review: PXE gate + callback, git-sync templates, distro catalog, k8s targets, http+https
Implements the six review comments on PR #1: - Per-host PXE-enable gate: read NetBox pxe_enabled custom field; a known host with it false gets the safe local-boot script (Cobbler netboot_enabled). Add a token-guarded POST /provisioned/{ident} callback that clears pxe_enabled in NetBox, plus a %post snippet in the default kickstarts that calls it. - Templates from a git repo: bootapi clones a templates repo and re-pulls every BOOTAPI_TEMPLATE_GIT_INTERVAL (default 3m), atomically swapping the template set (last-good kept on parse failure; embedded defaults are the startup fallback). Metrics for syncs/failures/generation. - Distro catalog (catalog/*.yaml): NetBox host -> boot images/kickstart, so adding an OS is a YAML + template change. Ships almalinux + fedora entries (artifactapi remotes); debian/talos path documented. - Boot images from the artifactapi almalinux/fedora remotes via the catalog. - Bind resolvers, puppet server/CA and PUPPETCA_URL env file now target the k8s services (198.18.200.7; puppet(ca).k8s.syd1.au.unkin.net). - Boot path served over plain HTTP (installers lack CA trust) with an optional parallel HTTPS listener; docs say do not 301 the boot endpoints. New packages: internal/catalog, internal/gitsync. NetBox client gains a pxe_enabled write (token needs that scope - noted in docs). `bootapi validate` subcommand validates a template/catalog set for the templates-repo CI. go build/vet clean, go test -race green, golangci-lint v2 clean, pre-commit clean. Claude-Session: https://claude.ai/code/session_015ur3i7D2azsMAWTSVABApv
This commit is contained in:
+124
-57
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user