From 2b7f7d863a9e77d52d453e5de60a686103bafcab Mon Sep 17 00:00:00 2001 From: Samuel Enocsson Date: Fri, 22 May 2026 11:24:29 +0200 Subject: [PATCH] feat(config): load and validate env-var config --- go.mod | 7 ++++ go.sum | 8 +++++ internal/config/config.go | 52 ++++++++++++++++++++++++++++++ internal/config/config_test.go | 59 ++++++++++++++++++++++++++++++++++ 4 files changed, 126 insertions(+) create mode 100644 go.sum create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go diff --git a/go.mod b/go.mod index bd57448..0b1cead 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,10 @@ module github.com/shcizo/package-updater go 1.26.3 + +require ( + github.com/docker/docker v28.5.2+incompatible // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/prometheus/client_golang v1.23.2 // indirect + github.com/stretchr/testify v1.11.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..af14773 --- /dev/null +++ b/go.sum @@ -0,0 +1,8 @@ +github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= +github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..80807de --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,52 @@ +// Package config loads service configuration from environment variables. +package config + +import ( + "errors" + "fmt" + "os" + "time" +) + +// Config holds all runtime configuration for the service. +type Config struct { + APIKey string + StacksRoot string + Port string + LogLevel string + UpdateTimeout time.Duration + OptInLabel string +} + +// Load reads configuration from environment variables, applies defaults, +// and validates required fields. Returns an error if validation fails so +// the service can fail-fast at startup. +func Load() (*Config, error) { + cfg := &Config{ + APIKey: os.Getenv("UPDATER_API_KEY"), + StacksRoot: getenvDefault("STACKS_ROOT", "/home/shcizo/self-hosted"), + Port: getenvDefault("PORT", "8080"), + LogLevel: getenvDefault("LOG_LEVEL", "info"), + OptInLabel: getenvDefault("OPT_IN_LABEL", "se.shcizo.auto-update"), + } + + if cfg.APIKey == "" { + return nil, errors.New("UPDATER_API_KEY is required") + } + + timeoutStr := getenvDefault("UPDATE_TIMEOUT", "5m") + d, err := time.ParseDuration(timeoutStr) + if err != nil { + return nil, fmt.Errorf("UPDATE_TIMEOUT %q is not a valid duration: %w", timeoutStr, err) + } + cfg.UpdateTimeout = d + + return cfg, nil +} + +func getenvDefault(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..bd50ab7 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,59 @@ +package config_test + +import ( + "testing" + "time" + + "github.com/shcizo/package-updater/internal/config" + "github.com/stretchr/testify/require" +) + +func TestLoad_RequiresAPIKey(t *testing.T) { + t.Setenv("UPDATER_API_KEY", "") + _, err := config.Load() + require.Error(t, err) + require.Contains(t, err.Error(), "UPDATER_API_KEY") +} + +func TestLoad_AppliesDefaults(t *testing.T) { + t.Setenv("UPDATER_API_KEY", "secret") + t.Setenv("STACKS_ROOT", "") + t.Setenv("PORT", "") + t.Setenv("LOG_LEVEL", "") + t.Setenv("UPDATE_TIMEOUT", "") + t.Setenv("OPT_IN_LABEL", "") + + cfg, err := config.Load() + require.NoError(t, err) + require.Equal(t, "secret", cfg.APIKey) + require.Equal(t, "/home/shcizo/self-hosted", cfg.StacksRoot) + require.Equal(t, "8080", cfg.Port) + require.Equal(t, "info", cfg.LogLevel) + require.Equal(t, 5*time.Minute, cfg.UpdateTimeout) + require.Equal(t, "se.shcizo.auto-update", cfg.OptInLabel) +} + +func TestLoad_OverridesViaEnv(t *testing.T) { + t.Setenv("UPDATER_API_KEY", "secret") + t.Setenv("STACKS_ROOT", "/srv/stacks") + t.Setenv("PORT", "9090") + t.Setenv("LOG_LEVEL", "debug") + t.Setenv("UPDATE_TIMEOUT", "30s") + t.Setenv("OPT_IN_LABEL", "io.example.update") + + cfg, err := config.Load() + require.NoError(t, err) + require.Equal(t, "/srv/stacks", cfg.StacksRoot) + require.Equal(t, "9090", cfg.Port) + require.Equal(t, "debug", cfg.LogLevel) + require.Equal(t, 30*time.Second, cfg.UpdateTimeout) + require.Equal(t, "io.example.update", cfg.OptInLabel) +} + +func TestLoad_InvalidTimeoutErrors(t *testing.T) { + t.Setenv("UPDATER_API_KEY", "secret") + t.Setenv("UPDATE_TIMEOUT", "not-a-duration") + _, err := config.Load() + require.Error(t, err) + require.Contains(t, err.Error(), "UPDATE_TIMEOUT") +}