From 39a76d5b274da592fa93a67c76ea98a9573b3c1e Mon Sep 17 00:00:00 2001 From: ashley-graham Date: Mon, 4 May 2026 16:58:43 -0600 Subject: [PATCH 1/7] Add config parsing dependencies --- go.mod | 20 ++++++++++++++++++-- go.sum | 42 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 59 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index fd78bbe..aef55ea 100644 --- a/go.mod +++ b/go.mod @@ -6,10 +6,11 @@ toolchain go1.24.2 require ( github.com/charmbracelet/log v0.4.0 + github.com/go-playground/validator/v10 v10.26.0 github.com/googleapis/gax-go/v2 v2.14.1 github.com/spf13/cobra v1.8.1 + github.com/spf13/viper v1.20.1 github.com/stretchr/testify v1.10.0 - gopkg.in/yaml.v3 v3.0.1 ) require ( @@ -18,17 +19,31 @@ require ( cloud.google.com/go/compute/metadata v0.6.0 // indirect cloud.google.com/go/iam v1.5.2 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/fsnotify/fsnotify v1.8.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.8 // indirect github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-viper/mapstructure/v2 v2.2.1 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/pelletier/go-toml/v2 v2.2.3 // indirect + github.com/sagikazarmark/locafero v0.7.0 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spf13/afero v1.12.0 // indirect + github.com/spf13/cast v1.7.1 // indirect github.com/stretchr/objx v0.5.2 // indirect + github.com/subosito/gotenv v1.6.0 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect go.opentelemetry.io/otel v1.35.0 // indirect go.opentelemetry.io/otel/metric v1.35.0 // indirect go.opentelemetry.io/otel/trace v1.35.0 // indirect + go.uber.org/atomic v1.9.0 // indirect + go.uber.org/multierr v1.9.0 // indirect golang.org/x/crypto v0.38.0 // indirect golang.org/x/net v0.40.0 // indirect golang.org/x/oauth2 v0.30.0 // indirect @@ -40,6 +55,7 @@ require ( google.golang.org/genproto/googleapis/rpc v0.0.0-20250512202823-5a2f75b736a9 // indirect google.golang.org/grpc v1.72.1 // indirect google.golang.org/protobuf v1.36.6 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) require ( @@ -56,7 +72,7 @@ require ( github.com/muesli/termenv v0.15.2 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/spf13/pflag v1.0.5 // indirect + github.com/spf13/pflag v1.0.6 // indirect golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect golang.org/x/sys v0.33.0 // indirect google.golang.org/api v0.233.0 diff --git a/go.sum b/go.sum index 5cdcf58..bd841f0 100644 --- a/go.sum +++ b/go.sum @@ -17,10 +17,17 @@ github.com/charmbracelet/lipgloss v0.10.0/go.mod h1:Wig9DSfvANsxqkRsqj6x87irdy12 github.com/charmbracelet/log v0.4.0 h1:G9bQAcx8rWA2T3pWvx7YtPTPwgqpk7D68BX21IRW8ZM= github.com/charmbracelet/log v0.4.0/go.mod h1:63bXt/djrizTec0l11H20t8FDSvA4CRZJ1KH22MdptM= github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= +github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= +github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -28,6 +35,16 @@ github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k= +github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= +github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= +github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -46,6 +63,8 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98= @@ -57,6 +76,8 @@ github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= +github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= +github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= @@ -66,14 +87,29 @@ github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUc github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo= +github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= +github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs= +github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4= +github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= +github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= -github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4= +github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 h1:x7wzEgXfnzJcHDwStJT+mxOz4etr2EcexjqhBvmoakw= @@ -90,6 +126,10 @@ go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5J go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w= go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= +go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= +go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= +go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= From 0f2fa7db01803268766e80831b2ed6da96b84c5b Mon Sep 17 00:00:00 2001 From: ashley-graham Date: Mon, 4 May 2026 16:59:30 -0600 Subject: [PATCH 2/7] Add shared config decoding and validation --- internal/goverseer/config/config.go | 36 ++-- internal/goverseer/config/config_test.go | 79 +++++++ internal/goverseer/config/decode.go | 263 +++++++++++++++++++++++ 3 files changed, 363 insertions(+), 15 deletions(-) create mode 100644 internal/goverseer/config/decode.go diff --git a/internal/goverseer/config/config.go b/internal/goverseer/config/config.go index 26f5195..06dec43 100644 --- a/internal/goverseer/config/config.go +++ b/internal/goverseer/config/config.go @@ -1,63 +1,69 @@ package config import ( - "os" - - "gopkg.in/yaml.v3" + "github.com/spf13/viper" ) // WatcherConfig is a custom type that handles dynamic unmarshalling type WatcherConfig struct { // Type is the type of watcher - Type string + Type string `mapstructure:"type" validate:"required,oneof=file time gce_metadata gcp_secrets"` // Config is the configuration for the watcher // The config values will be parsed by the watcher - Config map[string]interface{} + Config map[string]interface{} `mapstructure:"config"` } // ExecutionerConfig is a custom type that handles dynamic unmarshalling type ExecutionerConfig struct { // Type is the type of executioner - Type string + Type string `mapstructure:"type" validate:"required,oneof=log shell"` // Config is the configuration for the watcher // The config values will be parsed by the watcher - Config map[string]interface{} + Config map[string]interface{} `mapstructure:"config"` } // LoggerConfig is the configuration for the global logger type LoggerConfig struct { // Level is the log level - Level string + Level string `mapstructure:"level"` } // Config is the configuration for a watcher and executioner type Config struct { // Name is the name of the configuration, this will show up in logs - Name string + Name string `mapstructure:"name"` // Logger is the configuration for the logger - Logger LoggerConfig + Logger LoggerConfig `mapstructure:"logger"` // Watcher is the configuration for the watcher // it is dynamic because the configuration can be different for each watcher - Watcher WatcherConfig + Watcher WatcherConfig `mapstructure:"watcher" validate:"required"` // Executioner is the configuration for the executioner // it is dynamic because the configuration can be different for each executioner - Executioner ExecutionerConfig + Executioner ExecutionerConfig `mapstructure:"executioner" validate:"required"` } // FromFile reads a configuration file and unmarshals it into a Config struct func FromFile(path string) (*Config, error) { - cfgFile, err := os.ReadFile(path) - if err != nil { + v := viper.New() + v.SetConfigFile(path) + + if err := v.ReadInConfig(); err != nil { return nil, err } var cfg Config - if err := yaml.Unmarshal(cfgFile, &cfg); err != nil { + if err := checkConfigFields(v.AllSettings(), &cfg); err != nil { + return nil, err + } + if err := v.UnmarshalExact(&cfg); err != nil { + return nil, err + } + if err := Validate(&cfg); err != nil { return nil, err } diff --git a/internal/goverseer/config/config_test.go b/internal/goverseer/config/config_test.go index 940cc9f..d9e8812 100644 --- a/internal/goverseer/config/config_test.go +++ b/internal/goverseer/config/config_test.go @@ -37,6 +37,21 @@ executioner: name: Logger logger: level: debug +watcher: + type: time +executioner: + type: log +` + testConfigInvalidWatcherType = ` +name: InvalidWatcher +watcher: + type: unknown +executioner: + type: log +` + testConfigUnknownField = ` +name: UnknownField +unknown: true watcher: type: time executioner: @@ -72,10 +87,12 @@ func TestFromFile(t *testing.T) { // Check the watcher config assert.Equal(t, "time", config.Watcher.Type) assert.IsType(t, map[string]interface{}{"poll_seconds": 1}, config.Watcher.Config) + assert.Equal(t, 1, config.Watcher.Config["poll_seconds"]) // Check the executioner config assert.Equal(t, "log", config.Executioner.Type) assert.IsType(t, map[string]interface{}{"tag": "test"}, config.Executioner.Config) + assert.Equal(t, "test", config.Executioner.Config["tag"]) // Test with a config that's missing non-required Configs _, testConfig = writeTestConfigs(t, testConfigWatcherToLogNoConfig) @@ -94,4 +111,66 @@ func TestFromFile(t *testing.T) { "Parsing a config file with a valid logger config should not error") assert.Equal(t, "debug", config.Logger.Level, "An config file with logger configuration should parse correctly") + + // Test with an invalid watcher type + _, testConfig = writeTestConfigs(t, testConfigInvalidWatcherType) + config, err = FromFile(testConfig) + assert.Error(t, err, + "Parsing a config file with an invalid watcher type should error") + assert.Nil(t, config, + "Parsing a config file with an invalid watcher type should not return config") + + // Test with an unknown top-level field + _, testConfig = writeTestConfigs(t, testConfigUnknownField) + config, err = FromFile(testConfig) + assert.Error(t, err, + "Parsing a config file with an unknown top-level field should error") + assert.Nil(t, config, + "Parsing a config file with an unknown top-level field should not return config") +} + +func TestDecode(t *testing.T) { + type decodeConfig struct { + Name string `mapstructure:"name" validate:"required"` + Count int `mapstructure:"count" validate:"gte=1"` + Enabled bool `mapstructure:"enabled"` + } + + cfg := &decodeConfig{ + Count: 1, + } + + err := Decode(map[string]interface{}{ + "name": "test", + "enabled": true, + }, cfg) + assert.NoError(t, err, + "Decoding a valid dynamic config should not error") + assert.Equal(t, "test", cfg.Name, + "Decode should set configured fields") + assert.Equal(t, 1, cfg.Count, + "Decode should preserve default values for missing fields") + assert.Equal(t, true, cfg.Enabled, + "Decode should set configured booleans") + + cfg = &decodeConfig{ + Count: 1, + } + err = Decode(map[string]interface{}{ + "name": 9, + }, cfg) + assert.Error(t, err, + "Decoding a config with an invalid field type should error") + assert.Contains(t, err.Error(), "name must be a string") + + cfg = &decodeConfig{ + Count: 1, + } + err = Decode(map[string]interface{}{ + "name": "", + "count": 0, + }, cfg) + assert.Error(t, err, + "Decoding a config with an empty required string should error") + assert.Contains(t, err.Error(), "name must not be empty") } diff --git a/internal/goverseer/config/decode.go b/internal/goverseer/config/decode.go new file mode 100644 index 0000000..2e09b39 --- /dev/null +++ b/internal/goverseer/config/decode.go @@ -0,0 +1,263 @@ +package config + +import ( + "errors" + "fmt" + "reflect" + "strings" + + "github.com/go-playground/validator/v10" + "github.com/spf13/viper" +) + +var validate = newValidator() + +// Decode unmarshals a dynamic config map into a typed config struct and then +// validates the struct using validator tags. +func Decode(input interface{}, output interface{}) error { + cfgMap, err := configMap(input) + if err != nil { + return err + } + + v := viper.New() + if err := v.MergeConfigMap(cfgMap); err != nil { + return err + } + if err := checkConfigFields(cfgMap, output); err != nil { + return err + } + if err := v.UnmarshalExact(output); err != nil { + return err + } + + return Validate(output) +} + +// Validate runs configured validation rules against a typed config struct. +func Validate(output interface{}) error { + if err := validate.Struct(output); err != nil { + return formatValidationError(err) + } + + return nil +} + +func newValidator() *validator.Validate { + v := validator.New() + v.RegisterTagNameFunc(func(field reflect.StructField) string { + name := mapstructureName(field) + if name == "-" { + return "" + } + if name == "" { + return field.Name + } + return name + }) + return v +} + +func mapstructureName(field reflect.StructField) string { + return strings.SplitN(field.Tag.Get("mapstructure"), ",", 2)[0] +} + +func configMap(input interface{}) (map[string]interface{}, error) { + if input == nil { + return map[string]interface{}{}, nil + } + + normalized, err := normalizeValue(input) + if err != nil { + return nil, err + } + + cfgMap, ok := normalized.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("invalid config") + } + + return cfgMap, nil +} + +func normalizeValue(value interface{}) (interface{}, error) { + if value == nil { + return nil, nil + } + + switch typed := value.(type) { + case map[string]interface{}: + normalized := make(map[string]interface{}, len(typed)) + for key, nestedValue := range typed { + value, err := normalizeValue(nestedValue) + if err != nil { + return nil, err + } + if value != nil { + normalized[key] = value + } + } + return normalized, nil + case map[interface{}]interface{}: + normalized := make(map[string]interface{}, len(typed)) + for key, nestedValue := range typed { + keyString, ok := key.(string) + if !ok { + return nil, fmt.Errorf("config keys must be strings") + } + + value, err := normalizeValue(nestedValue) + if err != nil { + return nil, err + } + if value != nil { + normalized[keyString] = value + } + } + return normalized, nil + case []interface{}: + normalized := make([]interface{}, 0, len(typed)) + for _, nestedValue := range typed { + value, err := normalizeValue(nestedValue) + if err != nil { + return nil, err + } + normalized = append(normalized, value) + } + return normalized, nil + default: + return value, nil + } +} + +func checkConfigFields(cfgMap map[string]interface{}, output interface{}) error { + return checkStructConfigFields(cfgMap, output, "") +} + +func checkStructConfigFields(cfgMap map[string]interface{}, output interface{}, prefix string) error { + value := reflect.ValueOf(output) + if value.Kind() != reflect.Pointer || value.IsNil() { + return nil + } + + value = value.Elem() + if value.Kind() != reflect.Struct { + return nil + } + + valueType := value.Type() + for i := 0; i < valueType.NumField(); i++ { + field := valueType.Field(i) + if field.PkgPath != "" { + continue + } + + fieldName := mapstructureName(field) + if fieldName == "" || fieldName == "-" { + fieldName = field.Name + } + displayName := prefixedName(prefix, fieldName) + + rawValue, ok := cfgMap[fieldName] + if !ok { + continue + } + + if field.Type.Kind() == reflect.Struct { + nestedMap, ok := rawValue.(map[string]interface{}) + if !ok { + continue + } + if err := checkStructConfigFields(nestedMap, value.Field(i).Addr().Interface(), displayName); err != nil { + return err + } + continue + } + + if err := checkFieldTypeAndValue(field, rawValue, displayName); err != nil { + return err + } + } + + return nil +} + +func prefixedName(prefix string, name string) string { + if prefix == "" { + return name + } + return prefix + "." + name +} + +func checkFieldTypeAndValue(field reflect.StructField, rawValue interface{}, fieldName string) error { + switch field.Type.Kind() { + case reflect.String: + stringValue, ok := rawValue.(string) + if !ok { + return fmt.Errorf("%s must be a string", fieldName) + } + if stringValue == "" && hasValidationTag(field.Tag.Get("validate"), "required") { + return fmt.Errorf("%s must not be empty", fieldName) + } + case reflect.Int: + if _, ok := rawValue.(int); !ok { + return fmt.Errorf("%s must be an integer", fieldName) + } + case reflect.Bool: + if _, ok := rawValue.(bool); !ok { + return fmt.Errorf("%s must be a boolean", fieldName) + } + } + + return nil +} + +func hasValidationTag(tag string, name string) bool { + for _, rule := range strings.Split(tag, ",") { + ruleName := strings.SplitN(rule, "=", 2)[0] + if ruleName == name { + return true + } + } + return false +} + +func formatValidationError(err error) error { + var validationErrors validator.ValidationErrors + if !errors.As(err, &validationErrors) || len(validationErrors) == 0 { + return err + } + + fieldError := validationErrors[0] + fieldName := validationFieldName(fieldError) + + switch fieldError.Tag() { + case "required": + return fmt.Errorf("%s is required", fieldName) + case "gte": + if fieldError.Param() == "1" { + return fmt.Errorf("%s must be a positive integer", fieldName) + } + return fmt.Errorf("%s must be greater than or equal to %s", fieldName, fieldError.Param()) + case "gt": + return fmt.Errorf("%s must be greater than %s", fieldName, fieldError.Param()) + case "oneof": + return fmt.Errorf("%s must be one of %s", fieldName, fieldError.Param()) + default: + return fmt.Errorf("%s failed validation for %s", fieldName, fieldError.Tag()) + } +} + +func validationFieldName(fieldError validator.FieldError) string { + namespace := fieldError.Namespace() + if namespace == "" { + return fieldError.Field() + } + + parts := strings.Split(namespace, ".") + if len(parts) <= 1 { + return fieldError.Field() + } + + return strings.Join(parts[1:], ".") +} From 3ae19a5db0268baa0e5e040c92eb0ab2044ff102 Mon Sep 17 00:00:00 2001 From: ashley-graham Date: Mon, 4 May 2026 16:59:53 -0600 Subject: [PATCH 3/7] Migrate executioner config parsing --- .../log_executioner/log_executioner.go | 13 ++-- .../shell_executioner/shell_executioner.go | 60 +++---------------- 2 files changed, 11 insertions(+), 62 deletions(-) diff --git a/internal/goverseer/executioner/log_executioner/log_executioner.go b/internal/goverseer/executioner/log_executioner/log_executioner.go index 05cbccf..2451512 100644 --- a/internal/goverseer/executioner/log_executioner/log_executioner.go +++ b/internal/goverseer/executioner/log_executioner/log_executioner.go @@ -16,23 +16,18 @@ const ( // LogExecutionerConfig is the configuration for a log executioner type Config struct { // Tag is a tag to add to the logs, by default it is empty - Tag string + Tag string `mapstructure:"tag"` } // ParseConfig parses the config for a log executioner // It validates the config, sets defaults if missing, and returns the config -func ParseConfig(config interface{}) (*Config, error) { - cfgMap, ok := config.(map[string]interface{}) - if !ok { - return nil, fmt.Errorf("invalid config") - } - +func ParseConfig(input interface{}) (*Config, error) { lec := &Config{ Tag: DefaultTag, } - if tag, ok := cfgMap["tag"].(string); ok { - lec.Tag = tag + if err := config.Decode(input, lec); err != nil { + return nil, err } return lec, nil diff --git a/internal/goverseer/executioner/shell_executioner/shell_executioner.go b/internal/goverseer/executioner/shell_executioner/shell_executioner.go index dc7a838..3293580 100644 --- a/internal/goverseer/executioner/shell_executioner/shell_executioner.go +++ b/internal/goverseer/executioner/shell_executioner/shell_executioner.go @@ -32,79 +32,33 @@ const ( // Config is the configuration for a shell executioner type Config struct { // Command is the command to execute - Command string + Command string `mapstructure:"command" validate:"required"` // Shell is the shell to use when executing the command // Options can also be passed to the shell here - Shell string + Shell string `mapstructure:"shell" validate:"required"` // WorkDir is the directory in which the ShellExecutioner will store // the data to pass into the command - WorkDir string + WorkDir string `mapstructure:"work_dir" validate:"required"` // PersistWorkDir determines whether the data will persist after completion // This can be useful to enable when troubleshooting configured commands but // should generally remain disabled - PersistData bool + PersistData bool `mapstructure:"persist_data"` } // ParseConfig parses the config for a log executioner // It validates the config, sets defaults if missing, and returns the config -func ParseConfig(config interface{}) (*Config, error) { - cfgMap, ok := config.(map[string]interface{}) - if !ok { - return nil, fmt.Errorf("invalid config") - } - +func ParseConfig(input interface{}) (*Config, error) { cfg := &Config{ Shell: DefaultShell, PersistData: DefaultPersistData, WorkDir: DefaultWorkDir, } - // Command is required and must be a string - if command, ok := cfgMap["command"].(string); ok { - if command == "" { - return nil, fmt.Errorf("command must not be empty") - } - cfg.Command = command - } else if cfgMap["command"] != nil { - return nil, fmt.Errorf("command must be a string") - } else { - return nil, fmt.Errorf("command is required") - } - - // If shell is set, it should be a string - if cfgMap["shell"] != nil { - if shell, ok := cfgMap["shell"].(string); ok { - if shell == "" { - return nil, fmt.Errorf("shell must not be empty") - } - cfg.Shell = shell - } else if cfgMap["shell"] != nil { - return nil, fmt.Errorf("shell must be a string") - } - } - - // If persist_data is set, it should be a string - if cfgMap["persist_data"] != nil { - if persistData, ok := cfgMap["persist_data"].(bool); ok { - cfg.PersistData = persistData - } else if cfgMap["persist_data"] != nil { - return nil, fmt.Errorf("persist_data must be a boolean") - } - } - - // If work_dir is set, it should be a string - if cfgMap["work_dir"] != nil { - if workDir, ok := cfgMap["work_dir"].(string); ok { - if workDir == "" { - return nil, fmt.Errorf("work_dir must not be empty") - } - cfg.WorkDir = workDir - } else if cfgMap["work_dir"] != nil { - return nil, fmt.Errorf("work_dir must be a string") - } + if err := config.Decode(input, cfg); err != nil { + return nil, err } return cfg, nil From 00b6bc40d9ea8da0fa93bc903099591eeb0d3f63 Mon Sep 17 00:00:00 2001 From: ashley-graham Date: Mon, 4 May 2026 17:00:21 -0600 Subject: [PATCH 4/7] Migrate watcher config parsing --- .../watcher/file_watcher/file_watcher.go | 34 +----- .../gce_metadata_watcher.go | 76 ++----------- .../gcp_secrets_watcher.go | 100 ++---------------- .../watcher/time_watcher/time_watcher.go | 19 +--- 4 files changed, 31 insertions(+), 198 deletions(-) diff --git a/internal/goverseer/watcher/file_watcher/file_watcher.go b/internal/goverseer/watcher/file_watcher/file_watcher.go index 50d3886..4f530eb 100644 --- a/internal/goverseer/watcher/file_watcher/file_watcher.go +++ b/internal/goverseer/watcher/file_watcher/file_watcher.go @@ -1,7 +1,6 @@ package file_watcher import ( - "fmt" "os" "time" @@ -17,44 +16,21 @@ const ( // Config is the configuration for a file watcher type Config struct { // Path is the path to the file to watch - Path string + Path string `mapstructure:"path" validate:"required"` // PollSeconds is the number of seconds to wait between ticks - PollSeconds int + PollSeconds int `mapstructure:"poll_seconds" validate:"gte=1"` } // ParseConfig parses the config for a file watcher // It validates the config, sets defaults if missing, and returns the config -func ParseConfig(config interface{}) (*Config, error) { - cfgMap, ok := config.(map[string]interface{}) - if !ok { - return nil, fmt.Errorf("invalid config") - } - +func ParseConfig(input interface{}) (*Config, error) { cfg := &Config{ PollSeconds: DefaultPollSeconds, } - // Path is required and must be a string - if path, ok := cfgMap["path"].(string); ok { - if path == "" { - return nil, fmt.Errorf("path must not be empty") - } - cfg.Path = path - } else if cfgMap["path"] != nil { - return nil, fmt.Errorf("path must be a string") - } else { - return nil, fmt.Errorf("path is required") - } - - // If PollSeconds is set, it must be a positive number - if pollSeconds, ok := cfgMap["poll_seconds"].(int); ok { - if pollSeconds < 1 { - return nil, fmt.Errorf("poll_seconds must be greater than or equal to 1") - } - cfg.PollSeconds = pollSeconds - } else if cfgMap["poll_seconds"] != nil { - return nil, fmt.Errorf("poll_seconds must be an integer") + if err := config.Decode(input, cfg); err != nil { + return nil, err } return cfg, nil diff --git a/internal/goverseer/watcher/gce_metadata_watcher/gce_metadata_watcher.go b/internal/goverseer/watcher/gce_metadata_watcher/gce_metadata_watcher.go index b834225..b13c451 100644 --- a/internal/goverseer/watcher/gce_metadata_watcher/gce_metadata_watcher.go +++ b/internal/goverseer/watcher/gce_metadata_watcher/gce_metadata_watcher.go @@ -30,7 +30,7 @@ const ( // DefaultMetadataErrorWaitSeconds is the default number of seconds to wait // before retrying a failed metadata request - DefaultMetadataErrorWaitSeconds = 1 + DefaultMetadataErrorWaitSeconds = 10 ) // Config is the configuration for a GCE metadata watcher @@ -38,36 +38,31 @@ type Config struct { // Source is the metadata source to watch // Valid values are 'instance' and 'project' // Default is 'instance' - Source string + Source string `mapstructure:"source" validate:"oneof=instance project"` // Key is the key to watch in the GCE metadata // This is required config value - Key string + Key string `mapstructure:"key" validate:"required"` // Recursive is whether to recurse the metadata keys // Default is false - Recursive bool + Recursive bool `mapstructure:"recursive"` // MetadataUrl is the URL this watcher will use when reading from the GCE // metadata server // It can be useful to override during testing // e.g. http://localhost:8888/computeMetadata/v1 - MetadataUrl string + MetadataUrl string `mapstructure:"metadata_url" validate:"required"` // MetadataErrorWaitSeconds is the number of seconds to wait before retrying // a failed metadata request. This prevents hammering the metadata server. - // Default is 1 second - MetadataErrorWaitSeconds int + // Default is 10 seconds + MetadataErrorWaitSeconds int `mapstructure:"metadata_error_wait_seconds" validate:"gte=1"` } // ParseConfig parses the config for the watcher // It validates the config, sets defaults if missing, and returns the config -func ParseConfig(config interface{}) (*Config, error) { - cfgMap, ok := config.(map[string]interface{}) - if !ok { - return nil, fmt.Errorf("invalid config") - } - +func ParseConfig(input interface{}) (*Config, error) { cfg := &Config{ Source: DefaultSource, Recursive: DefaultRecursive, @@ -75,58 +70,8 @@ func ParseConfig(config interface{}) (*Config, error) { MetadataErrorWaitSeconds: DefaultMetadataErrorWaitSeconds, } - // If source is set, it should be one of the valid sources - if cfgMap["source"] != nil { - if source, ok := cfgMap["source"].(string); ok { - if source != ValidSourceInstance && source != ValidSourceProject { - return nil, fmt.Errorf("source must be one of %s or %s", ValidSourceInstance, ValidSourceProject) - } - cfg.Source = source - } else if cfgMap["source"] != nil { - return nil, fmt.Errorf("source must be a string") - } - } - - // If recursive is set, it should be a boolean - if cfgMap["recursive"] != nil { - if recursive, ok := cfgMap["recursive"].(bool); ok { - cfg.Recursive = recursive - } else if cfgMap["recursive"] != nil { - return nil, fmt.Errorf("recursive must be a boolean") - } - } - - // Key is required and must be a string - if key, ok := cfgMap["key"].(string); ok { - if key == "" { - return nil, fmt.Errorf("key must not be empty") - } - cfg.Key = key - } else if cfgMap["key"] != nil { - return nil, fmt.Errorf("key must be a string") - } else { - return nil, fmt.Errorf("key is required") - } - - // If metadata_url is set, it should be a string - if cfgMap["metadata_url"] != nil { - if metadataUrl, ok := cfgMap["metadata_url"].(string); ok { - if metadataUrl == "" { - return nil, fmt.Errorf("metadata_url must not be empty") - } - cfg.MetadataUrl = metadataUrl - } else if cfgMap["metadata_url"] != nil { - return nil, fmt.Errorf("metadata_url must be a string") - } - } - - // If metadata_error_wait_seconds is set, it should be an integer - if cfgMap["metadata_error_wait_seconds"] != nil { - if metadataErrorWaitSeconds, ok := cfgMap["metadata_error_wait_seconds"].(int); ok { - cfg.MetadataErrorWaitSeconds = metadataErrorWaitSeconds - } else if cfgMap["metadata_error_wait_seconds"] != nil { - return nil, fmt.Errorf("metadata_error_wait_seconds must be an integer") - } + if err := config.Decode(input, cfg); err != nil { + return nil, err } return cfg, nil @@ -156,6 +101,7 @@ func New(cfg config.Config) (*GceMetadataWatcher, error) { return &GceMetadataWatcher{ Config: Config{ + Source: pcfg.Source, Key: pcfg.Key, Recursive: pcfg.Recursive, MetadataUrl: pcfg.MetadataUrl, diff --git a/internal/goverseer/watcher/gcp_secrets_watcher/gcp_secrets_watcher.go b/internal/goverseer/watcher/gcp_secrets_watcher/gcp_secrets_watcher.go index d23e945..239bb8d 100644 --- a/internal/goverseer/watcher/gcp_secrets_watcher/gcp_secrets_watcher.go +++ b/internal/goverseer/watcher/gcp_secrets_watcher/gcp_secrets_watcher.go @@ -5,6 +5,7 @@ import ( "fmt" "time" + "github.com/simplifi/goverseer/internal/goverseer/config" "github.com/simplifi/goverseer/internal/goverseer/logger" secretmanager "cloud.google.com/go/secretmanager/apiv1" @@ -24,26 +25,26 @@ const ( type Config struct { // GCP project ID where the secret is located - ProjectID string + ProjectID string `mapstructure:"project_id" validate:"required"` // Name of the secret to watch in the specified project - SecretName string + SecretName string `mapstructure:"secret_name" validate:"required"` // Path to the GCP credentials file // If not set, the default ADC will be used - CredentialsFile string + CredentialsFile string `mapstructure:"credentials_file"` // Interval in seconds to poll the secret // Default is 60 seconds - CheckIntervalSeconds int + CheckIntervalSeconds int `mapstructure:"check_interval_seconds" validate:"gte=1"` // Number of seconds to wait // before retrying a failed secret access // Default is 5 seconds - SecretErrorWaitSeconds int + SecretErrorWaitSeconds int `mapstructure:"secret_error_wait_seconds" validate:"gte=1"` // Path to the file to update with the secrets' value - SecretsFilePath string + SecretsFilePath string `mapstructure:"secrets_file_path" validate:"required"` } // Defines an interface for creating Secret Manager clients @@ -87,94 +88,15 @@ type GcpSecretsWatcher struct { clientFactory SecretManagerClientFactory } -// Parses a required string field from config -// Returns an error if the field is missing or not a string -// Also checks if the string is empty -// (Used for project_id, secret_name, and secrets_file_path) -func parseRequiredString(cfgMap map[string]interface{}, fieldName string) (string, error) { - if raw, ok := cfgMap[fieldName]; ok { - if val, isString := raw.(string); isString { - if val == "" { - return "", fmt.Errorf("%s must not be empty", fieldName) - } - return val, nil - } - return "", fmt.Errorf("%s must be a string", fieldName) - } - return "", fmt.Errorf("%s is required", fieldName) -} - -// Parses an optional string field from config -// Returns an error if the field is not a string -// Also checks if the string is empty -// (Used for credentials_file) -func parseOptionalString(cfgMap map[string]interface{}, fieldName string) (string, error) { - if raw, ok := cfgMap[fieldName]; ok { - if val, isString := raw.(string); isString { - return val, nil - } - return "", fmt.Errorf("%s must be a string", fieldName) - } - return "", nil -} - -// Parses an optional positive integer field from config -// Returns an error if the field is not an integer -// Also checks if the integer is positive -// (Used for check_interval_seconds and secret_error_wait_seconds) -func parseOptionalPositiveInt(cfgMap map[string]interface{}, fieldName string) (int, error) { - if raw, ok := cfgMap[fieldName]; ok { - if val, isInt := raw.(int); isInt { - if val <= 0 { - return 0, fmt.Errorf("%s must be a positive integer", fieldName) - } - return val, nil - } - return 0, fmt.Errorf("%s must be an integer", fieldName) - } - return 0, nil -} - // Parses and validates the config for the watcher, // sets defaults if missing, and returns the config -func ParseConfig(config map[string]interface{}) (*Config, error) { +func ParseConfig(input interface{}) (*Config, error) { cfg := &Config{ CheckIntervalSeconds: DefaultCheckIntervalSeconds, SecretErrorWaitSeconds: DefaultSecretErrorWaitSeconds, } - var err error - - var val int - cfg.ProjectID, err = parseRequiredString(config, "project_id") - if err != nil { - return nil, err - } - - cfg.SecretName, err = parseRequiredString(config, "secret_name") - if err != nil { - return nil, err - } - - cfg.CredentialsFile, err = parseOptionalString(config, "credentials_file") - if err != nil { - return nil, err - } - - if val, err = parseOptionalPositiveInt(config, "check_interval_seconds"); err != nil { - return nil, err - } else if val != 0 { - cfg.CheckIntervalSeconds = val - } - - if val, err = parseOptionalPositiveInt(config, "secret_error_wait_seconds"); err != nil { - return nil, err - } else if val != 0 { - cfg.SecretErrorWaitSeconds = val - } - - cfg.SecretsFilePath, err = parseRequiredString(config, "secrets_file_path") - if err != nil { + if err := config.Decode(input, cfg); err != nil { return nil, err } @@ -182,8 +104,8 @@ func ParseConfig(config map[string]interface{}) (*Config, error) { } // Creates a new GcpSecretsWatcher based on the passed config -func New(config map[string]interface{}, factory ...SecretManagerClientFactory) (*GcpSecretsWatcher, error) { - cfg, err := ParseConfig(config) +func New(input interface{}, factory ...SecretManagerClientFactory) (*GcpSecretsWatcher, error) { + cfg, err := ParseConfig(input) if err != nil { return nil, err } diff --git a/internal/goverseer/watcher/time_watcher/time_watcher.go b/internal/goverseer/watcher/time_watcher/time_watcher.go index 306c3cb..fba76a8 100644 --- a/internal/goverseer/watcher/time_watcher/time_watcher.go +++ b/internal/goverseer/watcher/time_watcher/time_watcher.go @@ -1,7 +1,6 @@ package time_watcher import ( - "fmt" "time" "github.com/simplifi/goverseer/internal/goverseer/config" @@ -16,28 +15,18 @@ const ( // TimeWatcherConfig is the configuration for a time watcher type Config struct { // PollSeconds is the number of seconds to wait between ticks - PollSeconds int + PollSeconds int `mapstructure:"poll_seconds" validate:"gte=1"` } // ParseConfig parses the config for a time watcher // It validates the config, sets defaults if missing, and returns the config -func ParseConfig(config interface{}) (*Config, error) { - cfgMap, ok := config.(map[string]interface{}) - if !ok { - return nil, fmt.Errorf("invalid config") - } - +func ParseConfig(input interface{}) (*Config, error) { twc := &Config{ PollSeconds: DefaultPollSeconds, } - if pollSeconds, ok := cfgMap["poll_seconds"].(int); ok { - if pollSeconds < 1 { - return nil, fmt.Errorf("poll_seconds must be greater than or equal to 1") - } - twc.PollSeconds = pollSeconds - } else if cfgMap["poll_seconds"] != nil { - return nil, fmt.Errorf("poll_seconds must be an integer") + if err := config.Decode(input, twc); err != nil { + return nil, err } return twc, nil From 1d158f23bd0b27fa1e91e90245824b0dc5f713c5 Mon Sep 17 00:00:00 2001 From: ashley-graham Date: Mon, 4 May 2026 17:00:43 -0600 Subject: [PATCH 5/7] Document config validation behavior --- README.md | 7 +++++ docs/executioners/log_executioner.md | 4 +-- docs/executioners/shell_executioner.md | 14 +++++----- docs/watchers/file_watcher.md | 6 +++-- docs/watchers/gce_metadata_watcher.md | 11 ++++---- docs/watchers/gcp_secrets_watcher.md | 36 +++++++++++++++++++------- docs/watchers/time_watcher.md | 5 ++-- 7 files changed, 57 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 2593672..4fefd88 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,10 @@ The configuration options for `watcher.config` and `executioner.config` are determined by the selected type. See the documentation for the specific watcher and executioner type for more details on available configuration options. +Goverseer validates the configuration at startup. Unknown top-level fields, +unknown watcher/executioner options, invalid types, missing required fields, and +invalid values will cause startup to fail with a config error. + ## Building To build Goverseer, simply run `make build`. Once complete, you should see a @@ -54,4 +58,7 @@ binary in the root of your checkout. To run locally during development, run `go run ./cmd/goverseer --help`. +If adding watcher or executioner configs, please use typed config structs with +`mapstructure` and `validate` tags as well as `config.Decode`. + To run all tests, run `make test`. diff --git a/docs/executioners/log_executioner.md b/docs/executioners/log_executioner.md index 9266101..ac4b6c2 100644 --- a/docs/executioners/log_executioner.md +++ b/docs/executioners/log_executioner.md @@ -10,8 +10,8 @@ To use the Log Executioner, configure it in your Goverseer config file. The following configuration option is available: - `tag`: (Optional) This allows you to specify a custom tag that will be added - to the log message. This can be helpful for filtering and searching logs. - Defaults to no tag. + to the log message. This can be helpful for filtering and searching logs. Must + be a string if provided. Defaults to no tag. **Example Configuration:** diff --git a/docs/executioners/shell_executioner.md b/docs/executioners/shell_executioner.md index c15b99f..1112432 100644 --- a/docs/executioners/shell_executioner.md +++ b/docs/executioners/shell_executioner.md @@ -12,16 +12,18 @@ command via an environment variable named `GOVERSEER_DATA`. To use the Shell Executioner, you need to configure it in your Goverseer config file. The following configuration options are available: -- `command`: This is the shell command you want to execute. For example, - `echo "Data received: $GOVERSEER_DATA"`. +- `command`: (Required) This is the shell command you want to execute. Must be a + non-empty string. For example, `echo "Data received: $GOVERSEER_DATA"`. - `shell`: (Optional) This specifies the shell to use for executing the command. - Defaults to `/bin/sh -ec` if not provided. + Must be a non-empty string if provided. Defaults to `/bin/sh -ec` if not + provided. - `work_dir`: (Optional) This specifies the directory where the executioner - stores the data file. Defaults to the `/tmp` if not provided. + stores the data file. Must be a non-empty string if provided. Defaults to + `/tmp` if not provided. - `persist_data`: (Optional) This determines whether the command and data will persist after completion. This can be useful to enable when troubleshooting - configured commands but should generally remain disabled otherwise. Defaults - to `false` if not provided. + configured commands but should generally remain disabled otherwise. Must be a + boolean if provided. Defaults to `false` if not provided. **Example Configuration:** diff --git a/docs/watchers/file_watcher.md b/docs/watchers/file_watcher.md index 15975de..17cbf00 100644 --- a/docs/watchers/file_watcher.md +++ b/docs/watchers/file_watcher.md @@ -9,9 +9,11 @@ path to the changed file is passed to the executioner for processing. To use the File Watcher, you need to configure it in your Goverseer config file. The following configuration options are available: -- `path`: This is the path to the file that should be monitored for changes +- `path`: (Required) This is the path to the file that should be monitored for + changes. Must be a non-empty string. - `poll_seconds`: (Optional) This specifies the frequency in seconds for - checking if the file has been modified. Defaults to `5` if not provided. + checking if the file has been modified. Must be greater than or equal to `1`. + Defaults to `5` if not provided. **Example Configuration:** diff --git a/docs/watchers/gce_metadata_watcher.md b/docs/watchers/gce_metadata_watcher.md index c17cd64..e44a6a5 100644 --- a/docs/watchers/gce_metadata_watcher.md +++ b/docs/watchers/gce_metadata_watcher.md @@ -13,16 +13,17 @@ config file. The following configuration options are available: - `source`: (Optional) This is the source of the metadata value you want to monitor. It must be set to either `instance` or `project`. Defaults to `instance` if not provided. -- `key`: This is the GCE metadata key you want to monitor. For example, - `instance/attributes/my-key`. +- `key`: (Required) This is the GCE metadata key you want to monitor. Must be a + non-empty string. For example, `instance/attributes/my-key`. - `recursive`: (Optional) This determines whether to fetch metadata recursively. If set to `true`, all subkeys under the specified key will be monitored. Defaults to `false`. - `metadata_url`: (Optional) This allows overriding the default GCE Metadata - server URL. Useful for testing with a local server. Defaults to - `http://metadata.google.internal/computeMetadata/v1`. + server URL. Must be a non-empty string if provided. Useful for testing with a + local server. Defaults to `http://metadata.google.internal/computeMetadata/v1`. - `metadata_error_wait_seconds`: (Optional) This determines the wait time in - seconds before retrying after a metadata fetch error. Defaults to `10`. + seconds before retrying after a metadata fetch error. Must be greater than or + equal to `1`. Defaults to `10`. **Example Configuration:** diff --git a/docs/watchers/gcp_secrets_watcher.md b/docs/watchers/gcp_secrets_watcher.md index 8153e36..0600202 100644 --- a/docs/watchers/gcp_secrets_watcher.md +++ b/docs/watchers/gcp_secrets_watcher.md @@ -1,21 +1,39 @@ # GCP Secrets Manager Watcher -The GCP Secrets Manager Watcher allows you to monitor a secret stored in Google Cloud Secrets Manager for changes. When a change in the secret's payload is detected, Goverseer can trigger an executioner to take action based on the updated secret value. The changed secret value (as a map of project ID to the secret payload) is passed to the executioner for processing. +The GCP Secrets Manager Watcher allows you to monitor a secret stored in Google +Cloud Secrets Manager for changes. When a change in the secret's payload is +detected, Goverseer can trigger an executioner to take action based on the +updated secret value. The changed secret value is passed to the executioner for +processing. ## Configuration -To use the GCP Secrets Manager Watcher, you need to configure it in your Goverseer config file. The following configuration options are available under the `config` section of your watcher definition: +To use the GCP Secrets Manager Watcher, you need to configure it in your +Goverseer config file. The following configuration options are available under +the `config` section of your watcher definition: -- `project_id`: (Required) The GCP project that will be monitored for a change in its secrets manager. -- `secret_name`: (Required) The name of the secret to watch within each of the specified projects (e.g., `nomad-license-key`). -- `secrets_file_path`: (Required) The path for the file that needs to be updated when a secret changes. -- `credentials_file`: (Optional) Path for the credentials file if needing to test locally or use a service account's credentials instead of the ADC approach assumed. -- `check_interval_seconds`: (Optional) The interval in seconds at which the watcher will poll the Secret Manager for changes. Defaults to `60` seconds. -- `secret_error_wait_seconds`: (Optional) The number of seconds to wait before retrying after a failed attempt to access the secret. Defaults to `5` seconds. +- `project_id`: (Required) The GCP project that will be monitored for a change + in its Secrets Manager. Must be a non-empty string. +- `secret_name`: (Required) The name of the secret to watch within the specified + project (e.g., `nomad-license-key`). Must be a non-empty string. +- `secrets_file_path`: (Required) The path for the file that needs to be updated + when a secret changes. Must be a non-empty string. +- `credentials_file`: (Optional) Path for the credentials file if needing to + test locally or use a service account's credentials instead of the ADC + approach assumed. +- `check_interval_seconds`: (Optional) The interval in seconds at which the + watcher will poll the Secret Manager for changes. Must be greater than or + equal to `1`. Defaults to `60` seconds. +- `secret_error_wait_seconds`: (Optional) The number of seconds to wait before + retrying after a failed attempt to access the secret. Must be greater than or + equal to `1`. Defaults to `5` seconds. **Example Configuration:** -This is a sample configuration for watching changes to the Nomad license key in a GCP Project's Secret Manager. With Goverseer running on the Nomad client, the license key on that file would be updated whenever there is a change in secret manager. +This is a sample configuration for watching changes to the Nomad license key in +a GCP Project's Secret Manager. With Goverseer running on the Nomad client, the +license key on that file would be updated whenever there is a change in Secret +Manager. ```yaml name: nomad-license-watcher-dev diff --git a/docs/watchers/time_watcher.md b/docs/watchers/time_watcher.md index 234bce0..4c422a1 100644 --- a/docs/watchers/time_watcher.md +++ b/docs/watchers/time_watcher.md @@ -8,8 +8,9 @@ useful for testing and debugging. To use the Time Watcher, configure it in your Goverseer config file. The following configuration option is available: -- `poll_seconds`: This specifies the interval, in seconds, at which the watcher - will trigger the executioner. +- `poll_seconds`: (Optional) This specifies the interval, in seconds, at which + the watcher will trigger the executioner. Must be greater than or equal to + `1`. Defaults to `1` if not provided. **Example Configuration:** From 62d7e46ef27f63e878b3fe698a9dbd6dd498cef2 Mon Sep 17 00:00:00 2001 From: ashley-graham Date: Tue, 7 Jul 2026 15:44:05 -0600 Subject: [PATCH 6/7] Remove viper dependency except for mapstructure need --- go.mod | 5 +- internal/goverseer/config/config.go | 21 ++- internal/goverseer/config/decode.go | 204 +++++++++++++++------------- 3 files changed, 122 insertions(+), 108 deletions(-) diff --git a/go.mod b/go.mod index aef55ea..d343ccb 100644 --- a/go.mod +++ b/go.mod @@ -6,11 +6,12 @@ toolchain go1.24.2 require ( github.com/charmbracelet/log v0.4.0 + github.com/go-viper/mapstructure/v2 v2.2.1 github.com/go-playground/validator/v10 v10.26.0 github.com/googleapis/gax-go/v2 v2.14.1 github.com/spf13/cobra v1.8.1 - github.com/spf13/viper v1.20.1 github.com/stretchr/testify v1.10.0 + gopkg.in/yaml.v3 v3.0.1 ) require ( @@ -25,7 +26,6 @@ require ( github.com/go-logr/stdr v1.2.2 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/go-viper/mapstructure/v2 v2.2.1 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect github.com/leodido/go-urn v1.4.0 // indirect @@ -55,7 +55,6 @@ require ( google.golang.org/genproto/googleapis/rpc v0.0.0-20250512202823-5a2f75b736a9 // indirect google.golang.org/grpc v1.72.1 // indirect google.golang.org/protobuf v1.36.6 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) require ( diff --git a/internal/goverseer/config/config.go b/internal/goverseer/config/config.go index 06dec43..eae3f02 100644 --- a/internal/goverseer/config/config.go +++ b/internal/goverseer/config/config.go @@ -1,7 +1,9 @@ package config import ( - "github.com/spf13/viper" + "os" + + "gopkg.in/yaml.v3" ) // WatcherConfig is a custom type that handles dynamic unmarshalling @@ -49,21 +51,18 @@ type Config struct { // FromFile reads a configuration file and unmarshals it into a Config struct func FromFile(path string) (*Config, error) { - v := viper.New() - v.SetConfigFile(path) - - if err := v.ReadInConfig(); err != nil { + raw, err := os.ReadFile(path) + if err != nil { return nil, err } - var cfg Config - if err := checkConfigFields(v.AllSettings(), &cfg); err != nil { + var cfgMap map[string]interface{} + if err := yaml.Unmarshal(raw, &cfgMap); err != nil { return nil, err } - if err := v.UnmarshalExact(&cfg); err != nil { - return nil, err - } - if err := Validate(&cfg); err != nil { + + var cfg Config + if err := Decode(cfgMap, &cfg); err != nil { return nil, err } diff --git a/internal/goverseer/config/decode.go b/internal/goverseer/config/decode.go index 2e09b39..57e8645 100644 --- a/internal/goverseer/config/decode.go +++ b/internal/goverseer/config/decode.go @@ -7,7 +7,7 @@ import ( "strings" "github.com/go-playground/validator/v10" - "github.com/spf13/viper" + "github.com/go-viper/mapstructure/v2" ) var validate = newValidator() @@ -20,24 +20,25 @@ func Decode(input interface{}, output interface{}) error { return err } - v := viper.New() - if err := v.MergeConfigMap(cfgMap); err != nil { - return err - } - if err := checkConfigFields(cfgMap, output); err != nil { + decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{ + TagName: "mapstructure", + Result: output, + ErrorUnused: true, + }) + if err != nil { return err } - if err := v.UnmarshalExact(output); err != nil { - return err + if err := decoder.Decode(cfgMap); err != nil { + return formatDecodeError(err) } - return Validate(output) + return formatValidationErrorForInput(output, cfgMap) } // Validate runs configured validation rules against a typed config struct. func Validate(output interface{}) error { if err := validate.Struct(output); err != nil { - return formatValidationError(err) + return formatValidationError(err, nil) } return nil @@ -130,121 +131,136 @@ func normalizeValue(value interface{}) (interface{}, error) { } } -func checkConfigFields(cfgMap map[string]interface{}, output interface{}) error { - return checkStructConfigFields(cfgMap, output, "") +func formatValidationErrorForInput(output interface{}, cfgMap map[string]interface{}) error { + if err := validate.Struct(output); err != nil { + return formatValidationError(err, cfgMap) + } + + return nil } -func checkStructConfigFields(cfgMap map[string]interface{}, output interface{}, prefix string) error { - value := reflect.ValueOf(output) - if value.Kind() != reflect.Pointer || value.IsNil() { - return nil +func formatValidationError(err error, cfgMap map[string]interface{}) error { + var validationErrors validator.ValidationErrors + if !errors.As(err, &validationErrors) || len(validationErrors) == 0 { + return err } - value = value.Elem() - if value.Kind() != reflect.Struct { - return nil - } + fieldError := validationErrors[0] + fieldName := validationFieldName(fieldError) - valueType := value.Type() - for i := 0; i < valueType.NumField(); i++ { - field := valueType.Field(i) - if field.PkgPath != "" { - continue + switch fieldError.Tag() { + case "required": + if fieldError.Kind() == reflect.String { + if cfgMap != nil && !configPathExists(cfgMap, fieldName) { + return fmt.Errorf("%s is required", fieldName) + } + return fmt.Errorf("%s must not be empty", fieldName) } - - fieldName := mapstructureName(field) - if fieldName == "" || fieldName == "-" { - fieldName = field.Name + return fmt.Errorf("%s is required", fieldName) + case "gte": + if fieldError.Param() == "1" { + return fmt.Errorf("%s must be a positive integer", fieldName) } - displayName := prefixedName(prefix, fieldName) + return fmt.Errorf("%s must be greater than or equal to %s", fieldName, fieldError.Param()) + case "gt": + return fmt.Errorf("%s must be greater than %s", fieldName, fieldError.Param()) + case "oneof": + return fmt.Errorf("%s must be one of %s", fieldName, fieldError.Param()) + default: + return fmt.Errorf("%s failed validation for %s", fieldName, fieldError.Tag()) + } +} - rawValue, ok := cfgMap[fieldName] +func configPathExists(cfgMap map[string]interface{}, path string) bool { + if path == "" { + return false + } + + current := interface{}(cfgMap) + for _, part := range strings.Split(path, ".") { + nextMap, ok := current.(map[string]interface{}) if !ok { - continue + return false } - if field.Type.Kind() == reflect.Struct { - nestedMap, ok := rawValue.(map[string]interface{}) - if !ok { - continue - } - if err := checkStructConfigFields(nestedMap, value.Field(i).Addr().Interface(), displayName); err != nil { - return err - } - continue + value, ok := nextMap[part] + if !ok { + return false } - if err := checkFieldTypeAndValue(field, rawValue, displayName); err != nil { - return err - } + current = value } - return nil + return true } -func prefixedName(prefix string, name string) string { - if prefix == "" { - return name +func formatDecodeError(err error) error { + message := err.Error() + const prefix = "decoding failed due to the following error(s):\n\n" + if strings.HasPrefix(message, prefix) { + message = strings.TrimPrefix(message, prefix) } - return prefix + "." + name -} -func checkFieldTypeAndValue(field reflect.StructField, rawValue interface{}, fieldName string) error { - switch field.Type.Kind() { - case reflect.String: - stringValue, ok := rawValue.(string) - if !ok { - return fmt.Errorf("%s must be a string", fieldName) - } - if stringValue == "" && hasValidationTag(field.Tag.Get("validate"), "required") { - return fmt.Errorf("%s must not be empty", fieldName) - } - case reflect.Int: - if _, ok := rawValue.(int); !ok { - return fmt.Errorf("%s must be an integer", fieldName) + lines := strings.Split(message, "\n") + formatted := make([]string, 0, len(lines)) + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue } - case reflect.Bool: - if _, ok := rawValue.(bool); !ok { - return fmt.Errorf("%s must be a boolean", fieldName) + + if message, ok := formatDecodeLine(line); ok { + formatted = append(formatted, message) + continue } + + return err } - return nil -} + if len(formatted) == 0 { + return err + } -func hasValidationTag(tag string, name string) bool { - for _, rule := range strings.Split(tag, ",") { - ruleName := strings.SplitN(rule, "=", 2)[0] - if ruleName == name { - return true - } + if len(formatted) == 1 { + return errors.New(formatted[0]) } - return false + + return errors.New(strings.Join(formatted, "; ")) } -func formatValidationError(err error) error { - var validationErrors validator.ValidationErrors - if !errors.As(err, &validationErrors) || len(validationErrors) == 0 { - return err +func formatDecodeLine(line string) (string, bool) { + if !strings.HasPrefix(line, "'") { + return "", false } - fieldError := validationErrors[0] - fieldName := validationFieldName(fieldError) + fieldEnd := strings.Index(line[1:], "'") + if fieldEnd < 0 { + return "", false + } - switch fieldError.Tag() { - case "required": - return fmt.Errorf("%s is required", fieldName) - case "gte": - if fieldError.Param() == "1" { - return fmt.Errorf("%s must be a positive integer", fieldName) - } - return fmt.Errorf("%s must be greater than or equal to %s", fieldName, fieldError.Param()) - case "gt": - return fmt.Errorf("%s must be greater than %s", fieldName, fieldError.Param()) - case "oneof": - return fmt.Errorf("%s must be one of %s", fieldName, fieldError.Param()) + fieldName := line[1 : fieldEnd+1] + rest := strings.TrimSpace(line[fieldEnd+2:]) + const expectedPrefix = "expected type '" + if !strings.HasPrefix(rest, expectedPrefix) { + return "", false + } + + rest = strings.TrimPrefix(rest, expectedPrefix) + typeEnd := strings.Index(rest, "'") + if typeEnd < 0 { + return "", false + } + + expectedType := rest[:typeEnd] + switch expectedType { + case "string": + return fmt.Sprintf("%s must be a string", fieldName), true + case "bool": + return fmt.Sprintf("%s must be a boolean", fieldName), true + case "int", "int8", "int16", "int32", "int64", "uint", "uint8", "uint16", "uint32", "uint64": + return fmt.Sprintf("%s must be an integer", fieldName), true default: - return fmt.Errorf("%s failed validation for %s", fieldName, fieldError.Tag()) + return fmt.Sprintf("%s must be a %s", fieldName, expectedType), true } } From a877c2c83f3615a6fe15c12c9b6044582b4e38ba Mon Sep 17 00:00:00 2001 From: ashley-graham Date: Tue, 7 Jul 2026 15:52:43 -0600 Subject: [PATCH 7/7] Push updates after go mod tidy --- go.mod | 11 +---------- go.sum | 25 ------------------------- 2 files changed, 1 insertion(+), 35 deletions(-) diff --git a/go.mod b/go.mod index d343ccb..6ac579f 100644 --- a/go.mod +++ b/go.mod @@ -6,8 +6,8 @@ toolchain go1.24.2 require ( github.com/charmbracelet/log v0.4.0 - github.com/go-viper/mapstructure/v2 v2.2.1 github.com/go-playground/validator/v10 v10.26.0 + github.com/go-viper/mapstructure/v2 v2.2.1 github.com/googleapis/gax-go/v2 v2.14.1 github.com/spf13/cobra v1.8.1 github.com/stretchr/testify v1.10.0 @@ -20,7 +20,6 @@ require ( cloud.google.com/go/compute/metadata v0.6.0 // indirect cloud.google.com/go/iam v1.5.2 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect - github.com/fsnotify/fsnotify v1.8.0 // indirect github.com/gabriel-vasile/mimetype v1.4.8 // indirect github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect @@ -29,21 +28,13 @@ require ( github.com/google/s2a-go v0.1.9 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect github.com/leodido/go-urn v1.4.0 // indirect - github.com/pelletier/go-toml/v2 v2.2.3 // indirect - github.com/sagikazarmark/locafero v0.7.0 // indirect - github.com/sourcegraph/conc v0.3.0 // indirect - github.com/spf13/afero v1.12.0 // indirect - github.com/spf13/cast v1.7.1 // indirect github.com/stretchr/objx v0.5.2 // indirect - github.com/subosito/gotenv v1.6.0 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect go.opentelemetry.io/otel v1.35.0 // indirect go.opentelemetry.io/otel/metric v1.35.0 // indirect go.opentelemetry.io/otel/trace v1.35.0 // indirect - go.uber.org/atomic v1.9.0 // indirect - go.uber.org/multierr v1.9.0 // indirect golang.org/x/crypto v0.38.0 // indirect golang.org/x/net v0.40.0 // indirect golang.org/x/oauth2 v0.30.0 // indirect diff --git a/go.sum b/go.sum index bd841f0..3b2eef7 100644 --- a/go.sum +++ b/go.sum @@ -17,15 +17,10 @@ github.com/charmbracelet/lipgloss v0.10.0/go.mod h1:Wig9DSfvANsxqkRsqj6x87irdy12 github.com/charmbracelet/log v0.4.0 h1:G9bQAcx8rWA2T3pWvx7YtPTPwgqpk7D68BX21IRW8ZM= github.com/charmbracelet/log v0.4.0/go.mod h1:63bXt/djrizTec0l11H20t8FDSvA4CRZJ1KH22MdptM= github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= -github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= -github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= -github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= -github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= @@ -76,8 +71,6 @@ github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= -github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= -github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= @@ -87,29 +80,15 @@ github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUc github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo= -github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k= -github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= -github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= -github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs= -github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4= -github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= -github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4= -github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= -github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 h1:x7wzEgXfnzJcHDwStJT+mxOz4etr2EcexjqhBvmoakw= @@ -126,10 +105,6 @@ go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5J go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w= go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= -go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= -go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= -go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= -go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=