Support a config file alongside env vars in containers
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful

- --config / PDBMUX_CONFIG select the file; /etc/pdbmux/config.yaml joins the default search order
This commit is contained in:
2026-09-05 12:29:14 +10:00
parent 4bb44fb331
commit 03174f5ea0
4 changed files with 356 additions and 37 deletions
+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)
}