Merge pull request 'Support a config file alongside env vars in containers' (#10) from benvin/config-file-and-env into main

Reviewed-on: #10
This commit was merged in pull request #10.
This commit is contained in:
2026-09-05 13:42:17 +10:00
4 changed files with 356 additions and 37 deletions
+20 -7
View File
@@ -100,8 +100,15 @@ Each backend applies `order_by`/`limit`/`offset` to its own slice only, so
Precedence (lowest → highest): **defaults < config file < env vars (`PDBMUX_*`) < flags**.
Config file: `$XDG_CONFIG_HOME/pdbmux/config.yaml`. In a container there is no
config file — everything comes from `PDBMUX_*` env vars.
The config file is optional; a file, env vars, or both work equally well,
including in a container.
Which file is read: `--config <path>`, else `PDBMUX_CONFIG`, else the first that
exists of `$XDG_CONFIG_HOME/pdbmux/config.yaml` (or `$HOME/.config/pdbmux/config.yaml`),
then `/etc/pdbmux/config.yaml`. A path given via `--config`/`PDBMUX_CONFIG` **must**
exist — pdbmux fails rather than silently falling back — while a missing file on
the default search path is fine. `pdbmux config show` prints the file it loaded,
or the paths it searched.
```yaml
listen: ":8080"
@@ -122,6 +129,7 @@ the `/pdb/query/v4/...` path per request.
| Env var | Overrides |
|---|---|
| `PDBMUX_CONFIG` | config file path (not a file key) |
| `PDBMUX_LISTEN` | `listen` |
| `PDBMUX_PRIMARY` | `primary` |
| `PDBMUX_MERGE` | `merge` |
@@ -130,7 +138,10 @@ the `/pdb/query/v4/...` path per request.
| `PDBMUX_FRESHNESS_TTL` | `freshness_ttl` |
| `PDBMUX_BACKENDS` | whole backend list, as `name=url,name=url` |
Flags: `--listen`, `--primary`, `--merge`.
Flags: `--config`, `--listen`, `--primary`, `--merge`.
`config init` writes to `--config`/`PDBMUX_CONFIG` when set, else to
`$XDG_CONFIG_HOME/pdbmux/config.yaml`.
## Running
@@ -154,7 +165,9 @@ Container image only — no OS package. Every `v*` tag builds and pushes the ima
(`.woodpecker/docker.yaml`); registry and repository are pipeline settings. Tag
with `make patch` / `minor` / `major`.
A static (`CGO_ENABLED=0`) binary on a distroless base, configured entirely via
`PDBMUX_*` env vars; a container needs at minimum `PDBMUX_BACKENDS`. Stateless,
so run as many replicas as you like; use `/healthz` for liveness/readiness
probes.
A static (`CGO_ENABLED=0`) binary on a distroless base. Configure it with
`PDBMUX_*` env vars (at minimum `PDBMUX_BACKENDS`), or mount a config file — a
configmap at `/etc/pdbmux/config.yaml` is picked up with no env var at all, and
any other mount path works via `PDBMUX_CONFIG`. Env vars still override file
values, so the two mix. Stateless, so run as many replicas as you like; use
`/healthz` for liveness/readiness probes.
+68 -11
View File
@@ -15,6 +15,11 @@ const (
appName = "pdbmux"
configFileName = "config.yaml"
envPrefix = "PDBMUX_"
envConfigPath = envPrefix + "CONFIG"
// systemConfigDir is the last resort in the search order, and the path a
// container mount (configmap, secret) is expected to land on.
systemConfigDir = "/etc/" + appName
defaultListen = ":8080"
@@ -40,8 +45,13 @@ type Config struct {
Prefer string `yaml:"prefer"` // wins under static merge, and breaks ties under freshness merge
Timeout time.Duration `yaml:"timeout"`
FreshnessTTL time.Duration `yaml:"freshness_ttl"`
sourcePath string // file this config was read from, empty if none was found
}
// SourcePath returns the config file Load read, or "" when none was loaded.
func (c Config) SourcePath() string { return c.sourcePath }
const (
mergeFreshness = "freshness"
mergeStatic = "static"
@@ -68,6 +78,9 @@ func ConfigDir() string {
base := os.Getenv("XDG_CONFIG_HOME")
if base == "" {
home, _ := os.UserHomeDir()
if home == "" {
return systemConfigDir
}
base = filepath.Join(home, ".config")
}
return filepath.Join(base, appName)
@@ -77,19 +90,56 @@ func ConfigPath() string {
return filepath.Join(ConfigDir(), configFileName)
}
// configSearchPaths lists the default config locations, highest priority first.
func configSearchPaths() []string {
paths := []string{ConfigPath()}
if system := filepath.Join(systemConfigDir, configFileName); system != paths[0] {
paths = append(paths, system)
}
return paths
}
// explicitConfigPath returns the config path named by --config or PDBMUX_CONFIG,
// or "" when neither is set.
func explicitConfigPath(flagPath string) string {
if flagPath != "" {
return flagPath
}
return os.Getenv(envConfigPath)
}
// resolveConfigPath picks the config file to read: --config, else PDBMUX_CONFIG,
// else the first existing default search path. explicit reports whether the path
// was named outright, in which case a missing file is an error.
func resolveConfigPath(flagPath string) (path string, explicit bool) {
if p := explicitConfigPath(flagPath); p != "" {
return p, true
}
paths := configSearchPaths()
for _, p := range paths {
if st, err := os.Stat(p); err == nil && !st.IsDir() {
return p, false
}
}
return paths[0], false
}
// Precedence: defaults < config file < env vars < flags, and flags are applied by the caller.
func Load() (Config, error) {
func Load(flagPath string) (Config, error) {
cfg := DefaultConfig()
path := ConfigPath()
path, explicit := resolveConfigPath(flagPath)
data, err := os.ReadFile(path)
if err != nil && !os.IsNotExist(err) {
return cfg, fmt.Errorf("reading config %s: %w", path, err)
}
if err == nil {
switch {
case err == nil:
if err := yaml.Unmarshal(data, &cfg); err != nil {
return cfg, fmt.Errorf("parsing config %s: %w", path, err)
}
cfg.sourcePath = path
case os.IsNotExist(err) && !explicit:
// No config file anywhere on the search path: defaults + env only.
default:
return cfg, fmt.Errorf("reading config %s: %w", path, err)
}
applyEnv(&cfg, os.Getenv)
@@ -157,10 +207,19 @@ func (c *Config) normalize() {
}
}
// configHint names the file a user should edit: the one actually loaded, else
// the default write target.
func (c Config) configHint() string {
if c.sourcePath != "" {
return c.sourcePath
}
return ConfigPath()
}
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())
envPrefix, c.configHint())
}
seen := map[string]bool{}
for _, b := range c.Backends {
@@ -198,12 +257,10 @@ func (c Config) PrimaryBackend() Backend {
return c.Backends[0]
}
func writeDefaultConfig() error {
dir := ConfigDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
func writeDefaultConfig(path string) error {
if err := os.MkdirAll(filepath.Dir(path), 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)
}
+229 -4
View File
@@ -1,6 +1,7 @@
package main
import (
"bytes"
"os"
"path/filepath"
"strings"
@@ -42,7 +43,7 @@ func TestLoad_NoBackendsLoadsButFailsValidation(t *testing.T) {
clearEnv(t)
// Load itself must succeed so `config init` / `version` work unconfigured.
cfg, err := Load()
cfg, err := Load("")
if err != nil {
t.Fatalf("load: %v", err)
}
@@ -60,7 +61,7 @@ func TestLoad_PrimaryDefaultsToFirstBackend(t *testing.T) {
clearEnv(t)
t.Setenv(envPrefix+"BACKENDS", "a=http://localhost:18080,b=http://localhost:18081")
cfg, err := Load()
cfg, err := Load("")
if err != nil {
t.Fatal(err)
}
@@ -87,7 +88,7 @@ func TestLoad_FileAndEnvOverride(t *testing.T) {
// env beats file for listen.
t.Setenv(envPrefix+"LISTEN", "127.0.0.1:1234")
cfg, err := Load()
cfg, err := Load("")
if err != nil {
t.Fatalf("load: %v", err)
}
@@ -174,9 +175,233 @@ func TestExampleConfig_IsValidAndNeutral(t *testing.T) {
}
}
const testConfigBody = "listen: \":9999\"\nmerge: static\nprimary: old\nprefer: old\n" +
"backends:\n - name: old\n url: http://localhost:18080\n - name: new\n url: http://localhost:18081\n"
func writeConfigFile(t *testing.T, path string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(testConfigBody), 0o644); err != nil {
t.Fatal(err)
}
}
func TestLoad_FileOnly(t *testing.T) {
dir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", dir)
clearEnv(t)
path := filepath.Join(dir, appName, configFileName)
writeConfigFile(t, path)
cfg, err := Load("")
if err != nil {
t.Fatalf("load: %v", err)
}
if cfg.Listen != ":9999" || cfg.Merge != mergeStatic || len(cfg.Backends) != 2 {
t.Errorf("file values not applied: %+v", cfg)
}
if cfg.SourcePath() != path {
t.Errorf("source path = %q, want %q", cfg.SourcePath(), path)
}
}
func TestLoad_EnvOnly_DefaultPathMissing(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
clearEnv(t)
t.Setenv(envPrefix+"BACKENDS", "a=http://localhost:18080")
t.Setenv(envPrefix+"LISTEN", "127.0.0.1:1234")
cfg, err := Load("")
if err != nil {
t.Fatalf("a missing default config file must not be an error: %v", err)
}
if cfg.SourcePath() != "" {
t.Errorf("no file was loaded, source path should be empty, got %q", cfg.SourcePath())
}
if cfg.Listen != "127.0.0.1:1234" || len(cfg.Backends) != 1 {
t.Errorf("env values not applied: %+v", cfg)
}
if err := cfg.Validate(); err != nil {
t.Errorf("env-only config should validate: %v", err)
}
}
// A mounted config file must load with neither HOME nor XDG_CONFIG_HOME set.
func TestLoad_ExplicitPath(t *testing.T) {
mounted := filepath.Join(t.TempDir(), "mounted.yaml")
writeConfigFile(t, mounted)
for _, tc := range []struct {
name string
flag string
env string
}{
{"flag", mounted, ""},
{"env", "", mounted},
} {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", "")
t.Setenv("HOME", "")
clearEnv(t)
t.Setenv(envConfigPath, tc.env)
cfg, err := Load(tc.flag)
if err != nil {
t.Fatalf("load: %v", err)
}
if cfg.SourcePath() != mounted {
t.Errorf("source path = %q, want %q", cfg.SourcePath(), mounted)
}
if cfg.Listen != ":9999" {
t.Errorf("listen = %q, want :9999", cfg.Listen)
}
})
}
}
func TestLoad_ExplicitPathMissingIsError(t *testing.T) {
missing := filepath.Join(t.TempDir(), "typo.yaml")
for _, tc := range []struct {
name string
flag string
env string
}{
{"flag", missing, ""},
{"env", "", missing},
} {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
clearEnv(t)
t.Setenv(envConfigPath, tc.env)
_, err := Load(tc.flag)
if err == nil {
t.Fatal("an explicitly named config file that does not exist must fail loudly")
}
if !strings.Contains(err.Error(), missing) {
t.Errorf("error should name the missing path, got: %v", err)
}
})
}
}
func TestLoad_ExplicitFileStillLosesToEnv(t *testing.T) {
mounted := filepath.Join(t.TempDir(), "mounted.yaml")
writeConfigFile(t, mounted)
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
clearEnv(t)
t.Setenv(envConfigPath, mounted)
t.Setenv(envPrefix+"LISTEN", "127.0.0.1:1234")
t.Setenv(envPrefix+"MERGE", mergeFreshness)
cfg, err := Load("")
if err != nil {
t.Fatalf("load: %v", err)
}
if cfg.Listen != "127.0.0.1:1234" || cfg.Merge != mergeFreshness {
t.Errorf("env must beat the file: %+v", cfg)
}
if len(cfg.Backends) != 2 {
t.Errorf("unset env must leave file backends alone: %+v", cfg.Backends)
}
}
func TestResolveConfigPath_Precedence(t *testing.T) {
dir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", dir)
clearEnv(t)
defaultPath := filepath.Join(dir, appName, configFileName)
if got, explicit := resolveConfigPath(""); got != defaultPath || explicit {
t.Errorf("no file anywhere: got %q explicit=%v, want %q false", got, explicit, defaultPath)
}
writeConfigFile(t, defaultPath)
if got, explicit := resolveConfigPath(""); got != defaultPath || explicit {
t.Errorf("default search: got %q explicit=%v, want %q false", got, explicit, defaultPath)
}
t.Setenv(envConfigPath, "/from/env.yaml")
if got, explicit := resolveConfigPath(""); got != "/from/env.yaml" || !explicit {
t.Errorf("env should beat the search path: got %q explicit=%v", got, explicit)
}
if got, explicit := resolveConfigPath("/from/flag.yaml"); got != "/from/flag.yaml" || !explicit {
t.Errorf("flag should beat env: got %q explicit=%v", got, explicit)
}
}
func TestConfigSearchPaths_EndsAtSystemDir(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
paths := configSearchPaths()
want := filepath.Join(systemConfigDir, configFileName)
if len(paths) != 2 || paths[1] != want {
t.Errorf("search paths = %v, want the system path %q last", paths, want)
}
t.Setenv("XDG_CONFIG_HOME", "")
t.Setenv("HOME", "")
if paths := configSearchPaths(); len(paths) != 1 || paths[0] != want {
t.Errorf("without HOME/XDG the search path should be just %q, got %v", want, paths)
}
}
func TestPrintConfig_ReportsSource(t *testing.T) {
dir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", dir)
clearEnv(t)
out := captureStdout(t, func() {
cfg, err := Load("")
if err != nil {
t.Fatal(err)
}
printConfig(cfg)
})
if !strings.Contains(out, "none loaded") || !strings.Contains(out, filepath.Join(dir, appName, configFileName)) {
t.Errorf("config show should report nothing was loaded and what it searched, got:\n%s", out)
}
path := filepath.Join(dir, appName, configFileName)
writeConfigFile(t, path)
out = captureStdout(t, func() {
cfg, err := Load("")
if err != nil {
t.Fatal(err)
}
printConfig(cfg)
})
if !strings.Contains(out, path+" (loaded)") {
t.Errorf("config show should name the loaded file, got:\n%s", out)
}
}
func captureStdout(t *testing.T, f func()) string {
t.Helper()
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
orig := os.Stdout
os.Stdout = w
defer func() { os.Stdout = orig }()
f()
if err := w.Close(); err != nil {
t.Fatal(err)
}
var buf bytes.Buffer
if _, err := buf.ReadFrom(r); err != nil {
t.Fatal(err)
}
return buf.String()
}
func clearEnv(t *testing.T) {
t.Helper()
for _, k := range []string{"LISTEN", "PRIMARY", "MERGE", "PREFER", "TIMEOUT", "FRESHNESS_TTL", "BACKENDS"} {
for _, k := range []string{"CONFIG", "LISTEN", "PRIMARY", "MERGE", "PREFER", "TIMEOUT", "FRESHNESS_TTL", "BACKENDS"} {
t.Setenv(envPrefix+k, "")
}
}
+39 -15
View File
@@ -9,6 +9,7 @@ import (
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
@@ -18,19 +19,28 @@ import (
var version = "dev"
func main() {
cfg, err := Load()
if err != nil {
fmt.Fprintln(os.Stderr, "config error:", err)
os.Exit(1)
}
var (
listen string
primary string
merge string
cfg Config
configPath string
listen string
primary string
merge string
)
// Loaded lazily: --config is only known once cobra has parsed flags.
loadConfig := func() error {
c, err := Load(configPath)
if err != nil {
return err
}
cfg = c
return nil
}
serve := func(cmd *cobra.Command) error {
if err := loadConfig(); err != nil {
return err
}
if cmd.Flags().Changed("listen") {
cfg.Listen = listen
}
@@ -58,9 +68,10 @@ func main() {
}
pf := root.PersistentFlags()
pf.StringVar(&listen, "listen", cfg.Listen, "HTTP listen address (overrides config and PDBMUX_LISTEN)")
pf.StringVar(&primary, "primary", cfg.Primary, "Primary backend name for non-merged pass-through")
pf.StringVar(&merge, "merge", cfg.Merge, "Facts merge strategy: freshness or static")
pf.StringVar(&configPath, "config", "", "Config file path (overrides PDBMUX_CONFIG and the default search path)")
pf.StringVar(&listen, "listen", defaultListen, "HTTP listen address (overrides config and PDBMUX_LISTEN)")
pf.StringVar(&primary, "primary", "", "Primary backend name for non-merged pass-through")
pf.StringVar(&merge, "merge", mergeFreshness, "Facts merge strategy: freshness or static")
serveCmd := &cobra.Command{
Use: "serve",
@@ -73,15 +84,24 @@ func main() {
configCmd.AddCommand(
&cobra.Command{
Use: "init",
Short: "Write a default config file to " + ConfigPath(),
Short: "Write a default config file (--config path, else " + ConfigPath() + ")",
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error { return writeDefaultConfig() },
RunE: func(cmd *cobra.Command, args []string) error {
path := explicitConfigPath(configPath)
if path == "" {
path = ConfigPath()
}
return writeDefaultConfig(path)
},
},
&cobra.Command{
Use: "show",
Short: "Print the active configuration",
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
if err := loadConfig(); err != nil {
return err
}
printConfig(cfg)
return nil
},
@@ -137,7 +157,11 @@ func runServer(cfg Config) error {
}
func printConfig(cfg Config) {
fmt.Printf("config file : %s\n", ConfigPath())
if p := cfg.SourcePath(); p != "" {
fmt.Printf("config file : %s (loaded)\n", p)
} else {
fmt.Printf("config file : none loaded (searched %s)\n", strings.Join(configSearchPaths(), ", "))
}
fmt.Printf("listen : %s\n", cfg.Listen)
fmt.Printf("primary : %s\n", cfg.Primary)
fmt.Printf("merge : %s\n", cfg.Merge)