From 91bc0abc9151ce54b2d2054c189b5c45827a928d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alby=20Hern=C3=A1ndez?= Date: Wed, 1 Jul 2026 13:59:58 +0100 Subject: [PATCH] feat(config): source every flag from a matching env var Any command-line flag now also reads from an environment variable derived from its name (PARAKEET_ prefix, upper snake case): --log-level maps to PARAKEET_LOG_LEVEL, --ffmpeg-timeout to PARAKEET_FFMPEG_TIMEOUT, etc. A single applyEnvDefaults helper walks flag.CommandLine after Parse: flags set explicitly on the CLI are left untouched, the rest fall back to their env var via flag.Value.Set so the flag's own type does the parsing. Precedence is CLI flag > env var > default. Invalid env values are ignored with a warning (the previous value is restored, since flag numeric Set clobbers to zero on parse error) so a typo never corrupts the config. New flags get an env var for free, with no extra wiring. Removes the bespoke envOr/envInt helpers that only covered PARAKEET_GPU and PARAKEET_GPU_DEVICE; both keep working through the generic mapping. Adds table tests for the mapping, precedence, typed parsing and invalid values, and rewrites the README env var section around the generic rule. --- README.md | 17 +++++--- main.go | 65 ++++++++++++++++++------------ main_test.go | 109 +++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 159 insertions(+), 32 deletions(-) create mode 100644 main_test.go diff --git a/README.md b/README.md index c38c29b..de8a4a1 100644 --- a/README.md +++ b/README.md @@ -334,14 +334,19 @@ chunk_%03d.wav`) or use a CPU image, which is bounded by system RAM instead. ### Environment Variables +Every command-line flag also reads from an environment variable: take the flag +name, uppercase it and replace dashes with underscores, then prefix it with +`PARAKEET_`. So `-log-level` maps to `PARAKEET_LOG_LEVEL`, `-ffmpeg-timeout` to +`PARAKEET_FFMPEG_TIMEOUT`, and so on. An explicit flag always overrides its env +var (precedence: **CLI flag > env var > default**); an invalid env value is +ignored with a warning and the default is kept. + +A few variables have no flag equivalent: + | Variable | Description | Default | | ------------------ | ------------------------------------------- | --------------------- | -| `ONNXRUNTIME_LIB` | Path to libonnxruntime.so | Auto-detected | -| `PARAKEET_API_KEY` | API key for `/v1/*` endpoint authentication | Empty (auth disabled) | -| `PARAKEET_GPU` | Execution provider when `-gpu` is unset: `cpu`/`cuda` | `cpu` | -| `PARAKEET_GPU_DEVICE` | GPU device index when `-gpu-device` is unset | `0` | - -An explicit `-gpu`/`-gpu-device` flag always overrides the corresponding environment variable. +| `ONNXRUNTIME_LIB` | Path to libonnxruntime.so | Auto-detected | +| `PARAKEET_API_KEY` | API key for `/v1/*` endpoint authentication | Empty (auth disabled) | ### Model Files diff --git a/main.go b/main.go index 17fbda8..3884331 100644 --- a/main.go +++ b/main.go @@ -9,7 +9,6 @@ import ( "log/slog" "os" "os/signal" - "strconv" "strings" "syscall" "time" @@ -17,6 +16,9 @@ import ( "parakeet/internal/server" ) +// envPrefix namespaces every environment variable derived from a command-line flag. +const envPrefix = "PARAKEET_" + func main() { cfg := server.Config{} @@ -28,10 +30,14 @@ func main() { flag.BoolVar(&cfg.FFmpegEnabled, "ffmpeg", true, "Enable ffmpeg fallback for non-WAV audio (requires ffmpeg in PATH)") flag.StringVar(&cfg.FFmpegPath, "ffmpeg-path", "", "Path to the ffmpeg binary (default: resolved from PATH)") flag.DurationVar(&cfg.FFmpegTimeout, "ffmpeg-timeout", 60*time.Second, "Maximum wall-clock time for a single ffmpeg conversion") - flag.StringVar(&cfg.GPUProvider, "gpu", envOr("PARAKEET_GPU", "cpu"), "Execution provider: cpu or cuda (env: PARAKEET_GPU)") - flag.IntVar(&cfg.GPUDeviceID, "gpu-device", envInt("PARAKEET_GPU_DEVICE", 0), "GPU device index for cuda (env: PARAKEET_GPU_DEVICE)") + flag.StringVar(&cfg.GPUProvider, "gpu", "cpu", "Execution provider: cpu or cuda") + flag.IntVar(&cfg.GPUDeviceID, "gpu-device", 0, "GPU device index for cuda") flag.Parse() + // Any flag not set on the command line falls back to its matching env var, + // e.g. --log-level -> PARAKEET_LOG_LEVEL. Precedence: CLI flag > env var > default. + applyEnvDefaults(flag.CommandLine) + setupLogger(cfg.LogFormat, cfg.LogLevel) srv, err := server.New(cfg) @@ -73,29 +79,36 @@ func main() { slog.Info("server stopped") } -// envOr returns the value of environment variable key, or fallback if unset. -// Used to source a flag default from the environment so an explicit flag always -// overrides it (flag-over-env precedence) without any extra resolution logic. -func envOr(key, fallback string) string { - if v := os.Getenv(key); v != "" { - return v - } - return fallback -} - -// envInt is envOr for integer-valued variables. A non-integer value is treated -// as unset (after a warning) so a typo never silently selects the wrong device. -func envInt(key string, fallback int) int { - v := os.Getenv(key) - if v == "" { - return fallback - } - n, err := strconv.Atoi(v) - if err != nil { - slog.Warn("ignoring invalid integer environment variable", "var", key, "value", v) - return fallback - } - return n +// applyEnvDefaults sources any flag not passed explicitly on the command line from +// its matching environment variable, mapping the flag name to upper snake case with +// the PARAKEET_ prefix (e.g. --log-level -> PARAKEET_LOG_LEVEL). This gives every +// flag an env var for free, so new flags need no extra wiring. Precedence stays +// CLI flag > env var > flag default: flags set on the CLI are skipped, and the +// value is parsed through the flag's own type so an invalid value is rejected +// (with a warning) instead of silently corrupting the config. +func applyEnvDefaults(fs *flag.FlagSet) { + // Flags set explicitly on the CLI win and must not be overridden by env. + setOnCLI := make(map[string]bool) + fs.Visit(func(f *flag.Flag) { setOnCLI[f.Name] = true }) + + fs.VisitAll(func(f *flag.Flag) { + if setOnCLI[f.Name] { + return + } + key := envPrefix + strings.ToUpper(strings.ReplaceAll(f.Name, "-", "_")) + val, ok := os.LookupEnv(key) + if !ok { + return + } + // Snapshot the current value: flag.Value.Set clobbers numeric flags to zero + // even when parsing fails, so restore it on error to keep the default. + prev := f.Value.String() + if err := f.Value.Set(val); err != nil { + slog.Warn("ignoring invalid environment variable", + "var", key, "value", val, "error", err) + _ = f.Value.Set(prev) + } + }) } func setupLogger(format, level string) { diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..62ce308 --- /dev/null +++ b/main_test.go @@ -0,0 +1,109 @@ +// SPDX-FileCopyrightText: 2026 Alby Hernández +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "flag" + "testing" + "time" +) + +// newTestFlags builds an isolated FlagSet mirroring the real flags so tests never +// touch the global flag.CommandLine. +func newTestFlags() (*flag.FlagSet, *struct { + port int + level string + ffmpeg bool + timeout time.Duration +}) { + fs := flag.NewFlagSet("test", flag.ContinueOnError) + vals := &struct { + port int + level string + ffmpeg bool + timeout time.Duration + }{} + fs.IntVar(&vals.port, "port", 5092, "") + fs.StringVar(&vals.level, "log-level", "info", "") + fs.BoolVar(&vals.ffmpeg, "ffmpeg", true, "") + fs.DurationVar(&vals.timeout, "ffmpeg-timeout", 60*time.Second, "") + return fs, vals +} + +func TestApplyEnvDefaults(t *testing.T) { + t.Run("env value fills a flag left at its default", func(t *testing.T) { + t.Setenv("PARAKEET_PORT", "8080") + fs, vals := newTestFlags() + if err := fs.Parse(nil); err != nil { + t.Fatalf("parse: %v", err) + } + applyEnvDefaults(fs) + if vals.port != 8080 { + t.Fatalf("port = %d, want 8080 (from env)", vals.port) + } + }) + + t.Run("explicit CLI flag beats the env var", func(t *testing.T) { + t.Setenv("PARAKEET_PORT", "8080") + fs, vals := newTestFlags() + if err := fs.Parse([]string{"-port", "9090"}); err != nil { + t.Fatalf("parse: %v", err) + } + applyEnvDefaults(fs) + if vals.port != 9090 { + t.Fatalf("port = %d, want 9090 (CLI overrides env)", vals.port) + } + }) + + t.Run("no env keeps the flag default", func(t *testing.T) { + fs, vals := newTestFlags() + if err := fs.Parse(nil); err != nil { + t.Fatalf("parse: %v", err) + } + applyEnvDefaults(fs) + if vals.port != 5092 { + t.Fatalf("port = %d, want 5092 (default)", vals.port) + } + }) + + t.Run("dashed flag name maps to upper snake case env var", func(t *testing.T) { + t.Setenv("PARAKEET_LOG_LEVEL", "debug") + t.Setenv("PARAKEET_FFMPEG_TIMEOUT", "30s") + fs, vals := newTestFlags() + if err := fs.Parse(nil); err != nil { + t.Fatalf("parse: %v", err) + } + applyEnvDefaults(fs) + if vals.level != "debug" { + t.Fatalf("log-level = %q, want %q", vals.level, "debug") + } + if vals.timeout != 30*time.Second { + t.Fatalf("ffmpeg-timeout = %s, want 30s", vals.timeout) + } + }) + + t.Run("typed flag parses env value through its own type", func(t *testing.T) { + t.Setenv("PARAKEET_FFMPEG", "false") + fs, vals := newTestFlags() + if err := fs.Parse(nil); err != nil { + t.Fatalf("parse: %v", err) + } + applyEnvDefaults(fs) + if vals.ffmpeg { + t.Fatal("ffmpeg = true, want false (from env)") + } + }) + + t.Run("invalid env value is ignored and the default survives", func(t *testing.T) { + t.Setenv("PARAKEET_PORT", "not-a-number") + fs, vals := newTestFlags() + if err := fs.Parse(nil); err != nil { + t.Fatalf("parse: %v", err) + } + applyEnvDefaults(fs) + if vals.port != 5092 { + t.Fatalf("port = %d, want 5092 (invalid env ignored)", vals.port) + } + }) +}