diff --git a/README.md b/README.md index bfc7870..3e87dc5 100644 --- a/README.md +++ b/README.md @@ -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 `, 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. diff --git a/config.go b/config.go index 28fa472..a643e2b 100644 --- a/config.go +++ b/config.go @@ -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) } diff --git a/config_test.go b/config_test.go index 9c567c9..5a3cdaf 100644 --- a/config_test.go +++ b/config_test.go @@ -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, "") } } diff --git a/main.go b/main.go index 40562ba..df91aa1 100644 --- a/main.go +++ b/main.go @@ -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)