8f356346eb
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
231 lines
8.3 KiB
Go
231 lines
8.3 KiB
Go
// Package config loads bootapi server configuration from the environment,
|
|
// following the same env-first convention as encapi.
|
|
package config
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Config is the fully-resolved server configuration.
|
|
type Config struct {
|
|
// ListenAddr is the plain-HTTP bind address, e.g. ":8000". The boot path
|
|
// (iPXE + kickstart) is always served here so installers with no internal
|
|
// CA trust can reach it.
|
|
ListenAddr string
|
|
|
|
// TLSListenAddr, when set with TLSCertFile/TLSKeyFile, additionally serves
|
|
// HTTPS. Boot endpoints work on both; the plain-HTTP listener is mandatory,
|
|
// HTTPS is opt-in (see docs/endpoints.md).
|
|
TLSListenAddr string
|
|
TLSCertFile string
|
|
TLSKeyFile string
|
|
|
|
// NetBoxURL is the base URL of the NetBox API,
|
|
// e.g. "https://netbox.k8s.syd1.au.unkin.net".
|
|
NetBoxURL string
|
|
// NetBoxToken is the NetBox API token. Prefer NetBoxTokenFile in k8s. Needs
|
|
// WRITE scope on the device pxe_enabled custom field for the callback.
|
|
NetBoxToken string
|
|
// NetBoxTimeout bounds each NetBox HTTP request.
|
|
NetBoxTimeout time.Duration
|
|
// NetBoxInsecure disables TLS verification against NetBox (dev only).
|
|
NetBoxInsecure bool
|
|
|
|
// CacheTTL is how long a resolved host is cached in memory.
|
|
CacheTTL time.Duration
|
|
|
|
// TemplateDir, when set, is a directory of override templates layered on
|
|
// top of the embedded defaults (a ConfigMap mount). Ignored when a template
|
|
// git repo is configured.
|
|
TemplateDir string
|
|
// DefaultTemplate is the kickstart template used when no catalog/platform
|
|
// selection key matches.
|
|
DefaultTemplate string
|
|
|
|
// --- template git-sync (preferred over TemplateDir) ---
|
|
// TemplateGitURL, when set, makes bootapi clone a templates repo and re-pull
|
|
// it every TemplateGitInterval, atomically swapping the loaded set on change
|
|
// and keeping the last-good set on a parse failure.
|
|
TemplateGitURL string
|
|
TemplateGitBranch string
|
|
TemplateGitInterval time.Duration
|
|
// TemplateGitToken is an optional token for a private templates repo,
|
|
// injected into the HTTPS clone URL. Empty for a public repo.
|
|
TemplateGitToken string
|
|
|
|
// BaseURL is the http:// base PXE clients use to reach bootapi. It is baked
|
|
// into the iPXE inst.ks= and /ks URLs, so it MUST be reachable without CA
|
|
// trust (plain HTTP). e.g. "http://bootapi.k8s.syd1.au.unkin.net".
|
|
BaseURL string
|
|
// CallbackBaseURL is the base the end-of-kickstart callback uses. Defaults
|
|
// to BaseURL (plain HTTP, works before the internal CA is installed). Set to
|
|
// an https:// URL only if the kickstart installs the internal CA before the
|
|
// callback runs.
|
|
CallbackBaseURL string
|
|
|
|
// ArtifactBaseURL is the artifactapi remote base the distro catalog builds
|
|
// kernel/initrd URLs from,
|
|
// e.g. "https://artifactapi.k8s.syd1.au.unkin.net/api/v1/remote".
|
|
ArtifactBaseURL string
|
|
// BootBaseURL is a legacy fallback OS-tree base used only when no catalog
|
|
// entry matches a host. Normally empty (the catalog drives boot images).
|
|
BootBaseURL string
|
|
|
|
// ProvisionToken guards POST /provisioned. Empty disables the callback
|
|
// endpoint (fail closed). Prefer ProvisionTokenFile in k8s.
|
|
ProvisionToken string
|
|
|
|
// PuppetServer / PuppetCAServer are baked into kickstart %post so the
|
|
// freshly-installed host checks in to the k8s puppetserver.
|
|
PuppetServer string
|
|
PuppetCAServer string
|
|
// PuppetCAURL is written to the puppet-initial EnvironmentFile as
|
|
// PUPPETCA_URL (consumed by that RPM's systemd bootstrap unit).
|
|
PuppetCAURL string
|
|
|
|
// Domain is the default DNS domain applied when NetBox records none.
|
|
Domain string
|
|
// Nameservers is the default resolver list applied when NetBox records none.
|
|
Nameservers []string
|
|
|
|
// RootPasswordHash is a crypt(3) hash injected into kickstarts at render
|
|
// time (Vault in k8s). Empty locks the root account.
|
|
RootPasswordHash string
|
|
// SSHAuthorizedKeys are public keys installed for root at render time.
|
|
SSHAuthorizedKeys []string
|
|
|
|
// UnknownMACFallback selects the iPXE script for an unknown MAC: "local"
|
|
// (boot local disk, safe default) or "shell" (iPXE shell for debugging).
|
|
UnknownMACFallback string
|
|
}
|
|
|
|
// Load reads configuration from the environment, applying defaults. *_FILE
|
|
// variants (Vault-mounted secrets) win over their inline counterparts.
|
|
func Load() (*Config, error) {
|
|
cacheTTL, err := time.ParseDuration(getenv("BOOTAPI_CACHE_TTL", "30s"))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid BOOTAPI_CACHE_TTL: %w", err)
|
|
}
|
|
nbTimeout, err := time.ParseDuration(getenv("BOOTAPI_NETBOX_TIMEOUT", "5s"))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid BOOTAPI_NETBOX_TIMEOUT: %w", err)
|
|
}
|
|
gitInterval, err := time.ParseDuration(getenv("BOOTAPI_TEMPLATE_GIT_INTERVAL", "3m"))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid BOOTAPI_TEMPLATE_GIT_INTERVAL: %w", err)
|
|
}
|
|
|
|
token, err := readSecret("BOOTAPI_NETBOX_TOKEN")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rootHash, err := readSecret("BOOTAPI_ROOT_PASSWORD_HASH")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
provToken, err := readSecret("BOOTAPI_PROVISION_TOKEN")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
fallback := getenv("BOOTAPI_UNKNOWN_MAC_FALLBACK", "local")
|
|
if fallback != "local" && fallback != "shell" {
|
|
return nil, fmt.Errorf("invalid BOOTAPI_UNKNOWN_MAC_FALLBACK %q: want \"local\" or \"shell\"", fallback)
|
|
}
|
|
|
|
baseURL := strings.TrimRight(os.Getenv("BOOTAPI_BASE_URL"), "/")
|
|
callbackBase := strings.TrimRight(os.Getenv("BOOTAPI_CALLBACK_BASE_URL"), "/")
|
|
if callbackBase == "" {
|
|
callbackBase = baseURL
|
|
}
|
|
|
|
ns := splitList(os.Getenv("BOOTAPI_NAMESERVERS"))
|
|
if len(ns) == 0 {
|
|
ns = []string{"198.18.200.7"} // k8s bind-resolvers LB
|
|
}
|
|
|
|
return &Config{
|
|
ListenAddr: getenv("BOOTAPI_LISTEN_ADDR", ":8000"),
|
|
TLSListenAddr: getenv("BOOTAPI_TLS_LISTEN_ADDR", ""),
|
|
TLSCertFile: os.Getenv("BOOTAPI_TLS_CERT_FILE"),
|
|
TLSKeyFile: os.Getenv("BOOTAPI_TLS_KEY_FILE"),
|
|
NetBoxURL: strings.TrimRight(os.Getenv("BOOTAPI_NETBOX_URL"), "/"),
|
|
NetBoxToken: token,
|
|
NetBoxTimeout: nbTimeout,
|
|
NetBoxInsecure: getenv("BOOTAPI_NETBOX_INSECURE", "false") == "true",
|
|
CacheTTL: cacheTTL,
|
|
TemplateDir: os.Getenv("BOOTAPI_TEMPLATE_DIR"),
|
|
DefaultTemplate: getenv("BOOTAPI_DEFAULT_TEMPLATE", "almalinux9"),
|
|
TemplateGitURL: strings.TrimRight(os.Getenv("BOOTAPI_TEMPLATE_GIT_URL"), "/"),
|
|
TemplateGitBranch: getenv("BOOTAPI_TEMPLATE_GIT_BRANCH", "main"),
|
|
TemplateGitInterval: gitInterval,
|
|
TemplateGitToken: os.Getenv("BOOTAPI_TEMPLATE_GIT_TOKEN"),
|
|
BaseURL: baseURL,
|
|
CallbackBaseURL: callbackBase,
|
|
ArtifactBaseURL: strings.TrimRight(getenv("BOOTAPI_ARTIFACT_BASE_URL", "https://artifactapi.k8s.syd1.au.unkin.net/api/v1/remote"), "/"),
|
|
BootBaseURL: strings.TrimRight(os.Getenv("BOOTAPI_BOOT_BASE_URL"), "/"),
|
|
ProvisionToken: provToken,
|
|
PuppetServer: getenv("BOOTAPI_PUPPET_SERVER", "puppet.k8s.syd1.au.unkin.net"),
|
|
PuppetCAServer: getenv("BOOTAPI_PUPPET_CA_SERVER", "puppetca.k8s.syd1.au.unkin.net"),
|
|
PuppetCAURL: getenv("BOOTAPI_PUPPET_CA_URL", "puppetca.k8s.syd1.au.unkin.net"),
|
|
Domain: getenv("BOOTAPI_DOMAIN", "main.unkin.net"),
|
|
Nameservers: ns,
|
|
RootPasswordHash: rootHash,
|
|
SSHAuthorizedKeys: splitLines(os.Getenv("BOOTAPI_SSH_AUTHORIZED_KEYS")),
|
|
UnknownMACFallback: fallback,
|
|
}, nil
|
|
}
|
|
|
|
// readSecret returns the value of env key, or the trimmed contents of the file
|
|
// named by key+"_FILE" when that is set (the file wins).
|
|
func readSecret(key string) (string, error) {
|
|
v := os.Getenv(key)
|
|
if f := os.Getenv(key + "_FILE"); f != "" {
|
|
b, err := os.ReadFile(f)
|
|
if err != nil {
|
|
return "", fmt.Errorf("read %s_FILE %q: %w", key, f, err)
|
|
}
|
|
v = strings.TrimSpace(string(b))
|
|
}
|
|
return v, nil
|
|
}
|
|
|
|
func getenv(key, def string) string {
|
|
if v := os.Getenv(key); v != "" {
|
|
return v
|
|
}
|
|
return def
|
|
}
|
|
|
|
// splitList splits a comma-separated env value into a trimmed, non-empty slice.
|
|
func splitList(v string) []string {
|
|
if v == "" {
|
|
return nil
|
|
}
|
|
var out []string
|
|
for _, p := range strings.Split(v, ",") {
|
|
if p = strings.TrimSpace(p); p != "" {
|
|
out = append(out, p)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// splitLines splits a newline-separated env value (e.g. multiple SSH keys) into
|
|
// a trimmed, non-empty slice.
|
|
func splitLines(v string) []string {
|
|
if v == "" {
|
|
return nil
|
|
}
|
|
var out []string
|
|
for _, p := range strings.Split(v, "\n") {
|
|
if p = strings.TrimSpace(p); p != "" {
|
|
out = append(out, p)
|
|
}
|
|
}
|
|
return out
|
|
}
|