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) + } + }) +}