Files
bootapi/internal/config/config.go
T
unkin-agent 3c77895788
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
add POST /logs installer log relay to VictoriaLogs
A host being PXE-discovered or installed is not in Kubernetes, so vlagent
cannot collect its logs and a failed install leaves no record; the installer
environment has no internal-CA trust or credentials for the HTTPS log ingest,
and bootapi is already the plain-HTTP broker it can reach.

- add POST /logs, token-guarded like POST /provisioned, relaying ndjson to
  vlinsert's jsonline endpoint keyed on serial+phase
- stamp observed source IP and resolved NetBox device name into extra_fields
- return 202 on a sink failure so logs never block an install
- add BOOTAPI_VLINSERT_URL/_TIMEOUT and bootapi_log_relay metrics
2026-10-03 19:44:27 +10:00

253 lines
9.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 fetch a templates repo's branch
// tarball over HTTP (<repo>/archive/<branch>.tar.gz) and re-fetch
// 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,
// sent as a Gitea token header. 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
// VLInsertURL is the VictoriaLogs vlinsert base that POST /logs relays
// installer logs to. Empty disables the endpoint (it then 503s): a host
// being installed has no vlagent and no credentials for the HTTPS ingest,
// so bootapi relays for it.
VLInsertURL string
// VLInsertTimeout bounds the single best-effort forward attempt. Short on
// purpose — an install must not wait on the log sink.
VLInsertTimeout time.Duration
// 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)
}
vlTimeout, err := time.ParseDuration(getenv("BOOTAPI_VLINSERT_TIMEOUT", "5s"))
if err != nil {
return nil, fmt.Errorf("invalid BOOTAPI_VLINSERT_TIMEOUT: %w", err)
}
// Unlike the other URLs, an explicitly EMPTY value is meaningful here: it
// disables the log relay, so LookupEnv rather than getenv.
vlURL, ok := os.LookupEnv("BOOTAPI_VLINSERT_URL")
if !ok {
vlURL = "http://vlinsert-logs.logging.svc.cluster.local:9481"
}
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,
VLInsertURL: strings.TrimRight(vlURL, "/"),
VLInsertTimeout: vlTimeout,
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
}