Files
pdbmux/config.go
T
unkin-agent 9d5e9d0ed8
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
config: drop estate-specific defaults and docs
Remove hardcoded internal PuppetDB URLs and site-specific wording so the
project is publishable as-is.

- backends have no default; require config file or PDBMUX_BACKENDS
- primary/prefer default to the first configured backend
- config init writes example.com placeholders
- Load no longer validates, so config init/version work unconfigured
- genericise README, package doc, help text and Dockerfile comment
2026-09-05 11:40:05 +10:00

272 lines
8.1 KiB
Go

package main
import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"gopkg.in/yaml.v3"
)
const (
appName = "pdbmux"
configFileName = "config.yaml"
envPrefix = "PDBMUX_"
// defaultListen is the default HTTP listen address.
defaultListen = ":8080"
defaultTimeout = 10 * time.Second
defaultFreshnessTTL = 30 * time.Second
)
// exampleBackends are the placeholder backends written by `config init`. They
// are a scaffold to edit, not a working configuration.
var exampleBackends = []Backend{
{Name: "primary", URL: "http://puppetdb1.example.com:8080"},
{Name: "secondary", URL: "http://puppetdb2.example.com:8080"},
}
// Backend is one upstream PuppetDB. URL is the base URL (scheme://host[:port]),
// without the /pdb/query/v4/... path — that is appended per request.
type Backend struct {
Name string `yaml:"name"`
URL string `yaml:"url"`
}
// Config holds every configurable value. Fields map 1:1 to config-file keys and
// env vars (PDBMUX_*). See Load for precedence.
type Config struct {
// Listen is the HTTP listen address (host:port).
Listen string `yaml:"listen"`
// Backends is the ordered list of upstream PuppetDBs to fan out to.
Backends []Backend `yaml:"backends"`
// Primary is the backend Name used for transparent pass-through of
// non-merged /pdb/query/v4/* paths.
Primary string `yaml:"primary"`
// Merge selects how /facts records are attributed to a backend when a
// certname appears in both: "freshness" (query /nodes report_timestamp,
// newer wins) or "static" (always prefer the Prefer backend).
Merge string `yaml:"merge"`
// Prefer names the backend that wins under static merge and as the
// tie-breaker/fallback under freshness merge.
Prefer string `yaml:"prefer"`
// Timeout bounds each upstream request.
Timeout time.Duration `yaml:"timeout"`
// FreshnessTTL is how long a per-certname freshness map (from /nodes) is
// cached under the "freshness" merge strategy.
FreshnessTTL time.Duration `yaml:"freshness_ttl"`
}
const (
mergeFreshness = "freshness"
mergeStatic = "static"
)
// DefaultConfig returns the built-in defaults. Backends have no default: they
// must come from the config file or PDBMUX_BACKENDS. Primary and Prefer default
// to the first configured backend (see normalize).
func DefaultConfig() Config {
return Config{
Listen: defaultListen,
Merge: mergeFreshness,
Timeout: defaultTimeout,
FreshnessTTL: defaultFreshnessTTL,
}
}
// ExampleConfig returns DefaultConfig with placeholder backends filled in, as
// written by `config init`.
func ExampleConfig() Config {
cfg := DefaultConfig()
cfg.Backends = append([]Backend(nil), exampleBackends...)
cfg.Primary = exampleBackends[0].Name
cfg.Prefer = exampleBackends[0].Name
return cfg
}
// ConfigDir returns the XDG_CONFIG_HOME/pdbmux directory.
func ConfigDir() string {
base := os.Getenv("XDG_CONFIG_HOME")
if base == "" {
home, _ := os.UserHomeDir()
base = filepath.Join(home, ".config")
}
return filepath.Join(base, appName)
}
// ConfigPath returns the full path to the config file.
func ConfigPath() string {
return filepath.Join(ConfigDir(), configFileName)
}
// Load reads the config file (if present), then applies env var overrides.
// Precedence (lowest -> highest): defaults < config file < env vars < flags
// (flags are applied by the caller). Backends can be overridden wholesale via
// PDBMUX_BACKENDS ("name=url,name=url").
//
// Load reports only read/parse errors; the result is not validated, so commands
// that do not serve (config init, version) still work on an unconfigured host.
// Callers that serve must call Validate after applying flags.
func Load() (Config, error) {
cfg := DefaultConfig()
path := ConfigPath()
data, err := os.ReadFile(path)
if err != nil && !os.IsNotExist(err) {
return cfg, fmt.Errorf("reading config %s: %w", path, err)
}
if err == nil {
if err := yaml.Unmarshal(data, &cfg); err != nil {
return cfg, fmt.Errorf("parsing config %s: %w", path, err)
}
}
applyEnv(&cfg, os.Getenv)
cfg.normalize()
return cfg, nil
}
// applyEnv overlays PDBMUX_* env vars onto cfg. getenv is injected for testing.
func applyEnv(cfg *Config, getenv func(string) string) {
if v := getenv(envPrefix + "LISTEN"); v != "" {
cfg.Listen = v
}
if v := getenv(envPrefix + "PRIMARY"); v != "" {
cfg.Primary = v
}
if v := getenv(envPrefix + "MERGE"); v != "" {
cfg.Merge = v
}
if v := getenv(envPrefix + "PREFER"); v != "" {
cfg.Prefer = v
}
if v := getenv(envPrefix + "TIMEOUT"); v != "" {
if d, err := time.ParseDuration(v); err == nil {
cfg.Timeout = d
}
}
if v := getenv(envPrefix + "FRESHNESS_TTL"); v != "" {
if d, err := time.ParseDuration(v); err == nil {
cfg.FreshnessTTL = d
}
}
if v := getenv(envPrefix + "BACKENDS"); v != "" {
if bs := parseBackends(v); len(bs) > 0 {
cfg.Backends = bs
}
}
}
// parseBackends parses "name=url,name=url" into Backends. Entries without an
// "=" are skipped. Used for the PDBMUX_BACKENDS env override.
func parseBackends(s string) []Backend {
var out []Backend
for _, part := range strings.Split(s, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
name, url, ok := strings.Cut(part, "=")
name, url = strings.TrimSpace(name), strings.TrimSpace(url)
if !ok || name == "" || url == "" {
continue
}
out = append(out, Backend{Name: name, URL: url})
}
return out
}
// normalize fills in values that default to something derived rather than
// constant: Primary and Prefer both fall back to the first configured backend.
func (c *Config) normalize() {
if len(c.Backends) == 0 {
return
}
if c.Primary == "" {
c.Primary = c.Backends[0].Name
}
if c.Prefer == "" {
c.Prefer = c.Backends[0].Name
}
}
// Validate checks the config is internally consistent and usable.
func (c Config) Validate() error {
if len(c.Backends) == 0 {
return fmt.Errorf("no backends configured: set %sBACKENDS to \"name=url,name=url\" or add a backends list to %s",
envPrefix, ConfigPath())
}
seen := map[string]bool{}
for _, b := range c.Backends {
if b.Name == "" || b.URL == "" {
return fmt.Errorf("backend requires both name and url: %+v", b)
}
if seen[b.Name] {
return fmt.Errorf("duplicate backend name %q", b.Name)
}
seen[b.Name] = true
}
if !seen[c.Primary] {
return fmt.Errorf("primary %q is not a configured backend", c.Primary)
}
switch c.Merge {
case mergeFreshness, mergeStatic:
default:
return fmt.Errorf("merge must be %q or %q, got %q", mergeFreshness, mergeStatic, c.Merge)
}
if !seen[c.Prefer] {
return fmt.Errorf("prefer %q is not a configured backend", c.Prefer)
}
if c.Timeout <= 0 {
return fmt.Errorf("timeout must be positive")
}
return nil
}
// PrimaryBackend returns the backend named by Primary (guaranteed present after
// Validate).
func (c Config) PrimaryBackend() Backend {
for _, b := range c.Backends {
if b.Name == c.Primary {
return b
}
}
return c.Backends[0]
}
// writeDefaultConfig creates the config dir and writes a default config file.
func writeDefaultConfig() error {
dir := ConfigDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("creating config dir: %w", err)
}
path := ConfigPath()
if _, err := os.Stat(path); err == nil {
return fmt.Errorf("config already exists at %s", path)
}
data, _ := yaml.Marshal(ExampleConfig())
header := []byte("# pdbmux configuration\n" +
"# A merging proxy presenting one PuppetDB v4 query surface over several\n" +
"# PuppetDB backends. The backend URLs below are placeholders — edit them.\n" +
"# Env overrides: PDBMUX_LISTEN, PDBMUX_PRIMARY, PDBMUX_MERGE, PDBMUX_PREFER,\n" +
"# PDBMUX_TIMEOUT, PDBMUX_FRESHNESS_TTL, PDBMUX_BACKENDS (name=url,name=url).\n\n")
if err := os.WriteFile(path, append(header, data...), 0o644); err != nil {
return fmt.Errorf("writing config: %w", err)
}
fmt.Println("Config written to", path)
return nil
}
// durationString renders a duration for `config show` (falls back to a plain
// seconds count for zero to avoid "0s" ambiguity in logs).
func durationString(d time.Duration) string {
if d == 0 {
return "0"
}
return strconv.FormatFloat(d.Seconds(), 'f', -1, 64) + "s"
}