diff --git a/.github/workflows/modules-ci.yml b/.github/workflows/modules-ci.yml index c09a2675..f1b65e30 100644 --- a/.github/workflows/modules-ci.yml +++ b/.github/workflows/modules-ci.yml @@ -68,8 +68,8 @@ jobs: fi fi - # Format for matrix - MODULES_JSON=$(echo "$MODIFIED_MODULES" | tr ' ' '\n' | jq -R . | jq -s .) + # Format for matrix - filter out empty entries + MODULES_JSON=$(echo "$MODIFIED_MODULES" | tr ' ' '\n' | grep -v '^$' | jq -R . | jq -s .) { echo "matrix<_ + +# Database connections +DB_PRIMARY_DRIVER=postgres +DB_PRIMARY_DSN=postgres://user:pass@localhost/primary +DB_PRIMARY_MAX_OPEN_CONNECTIONS=25 + +DB_SECONDARY_DRIVER=mysql +DB_SECONDARY_DSN=mysql://user:pass@localhost/secondary +DB_SECONDARY_MAX_OPEN_CONNECTIONS=10 + +# Cache instances +CACHE_SESSION_DRIVER=redis +CACHE_SESSION_ADDR=localhost:6379 +CACHE_SESSION_DB=0 + +CACHE_OBJECTS_DRIVER=redis +CACHE_OBJECTS_ADDR=localhost:6379 +CACHE_OBJECTS_DB=1 + +# HTTP servers +HTTP_API_PORT=8080 +HTTP_API_HOST=0.0.0.0 + +HTTP_ADMIN_PORT=8081 +HTTP_ADMIN_HOST=127.0.0.1 +``` + +#### Configuration Struct Requirements + +For instance-aware configuration to work, configuration structs must have `env` struct tags: + +```go +type ConnectionConfig struct { + Driver string `json:"driver" yaml:"driver" env:"DRIVER"` + DSN string `json:"dsn" yaml:"dsn" env:"DSN"` + MaxOpenConnections int `json:"max_open_connections" yaml:"max_open_connections" env:"MAX_OPEN_CONNECTIONS"` + MaxIdleConnections int `json:"max_idle_connections" yaml:"max_idle_connections" env:"MAX_IDLE_CONNECTIONS"` +} +``` + +The `env` tag specifies the environment variable name that will be combined with the instance prefix. + +#### Complete Example + +Here's a complete example showing how to use instance-aware configuration for multiple database connections: + +```go +package main + +import ( + "fmt" + "os" + + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/modular/modules/database" +) + +func main() { + // Set up environment variables for multiple database connections + os.Setenv("DB_PRIMARY_DRIVER", "postgres") + os.Setenv("DB_PRIMARY_DSN", "postgres://localhost/primary") + os.Setenv("DB_SECONDARY_DRIVER", "mysql") + os.Setenv("DB_SECONDARY_DSN", "mysql://localhost/secondary") + os.Setenv("DB_CACHE_DRIVER", "sqlite") + os.Setenv("DB_CACHE_DSN", ":memory:") + + // Create application + app := modular.NewStdApplication( + modular.NewStdConfigProvider(&AppConfig{}), + logger, + ) + + // Register database module (it automatically sets up instance-aware config) + app.RegisterModule(database.NewModule()) + + // Initialize application + err := app.Init() + if err != nil { + panic(err) + } + + // Get database manager + var dbManager *database.Module + app.GetService("database.manager", &dbManager) + + // Access different database connections + primaryDB, _ := dbManager.GetConnection("primary") // Uses DB_PRIMARY_* + secondaryDB, _ := dbManager.GetConnection("secondary") // Uses DB_SECONDARY_* + cacheDB, _ := dbManager.GetConnection("cache") // Uses DB_CACHE_* +} +``` + +#### Manual Instance Configuration + +You can also manually configure instances without automatic module support: + +```go +// Define configuration with instances +type MyConfig struct { + Services map[string]ServiceConfig `json:"services" yaml:"services"` +} + +type ServiceConfig struct { + URL string `json:"url" yaml:"url" env:"URL"` + Timeout int `json:"timeout" yaml:"timeout" env:"TIMEOUT"` + APIKey string `json:"api_key" yaml:"api_key" env:"API_KEY"` +} + +// Set up environment variables +os.Setenv("SVC_AUTH_URL", "https://auth.example.com") +os.Setenv("SVC_AUTH_TIMEOUT", "30") +os.Setenv("SVC_AUTH_API_KEY", "auth-key-123") + +os.Setenv("SVC_PAYMENT_URL", "https://payment.example.com") +os.Setenv("SVC_PAYMENT_TIMEOUT", "60") +os.Setenv("SVC_PAYMENT_API_KEY", "payment-key-456") + +// Create instance-aware feeder +feeder := modular.NewInstanceAwareEnvFeeder(func(instanceKey string) string { + return "SVC_" + strings.ToUpper(instanceKey) + "_" +}) + +// Configure each service instance +config := &MyConfig{ + Services: map[string]ServiceConfig{ + "auth": {}, + "payment": {}, + }, +} + +// Feed each instance +for name, serviceConfig := range config.Services { + configPtr := &serviceConfig + if err := feeder.FeedKey(name, configPtr); err != nil { + return fmt.Errorf("failed to configure service %s: %w", name, err) + } + config.Services[name] = *configPtr +} +``` + +#### Best Practices + +1. **Consistent Naming**: Use consistent prefix patterns across your application + ```bash + DB__ # Database connections + CACHE__ # Cache instances + HTTP__ # HTTP servers + ``` + +2. **Uppercase Instance Keys**: Convert instance keys to uppercase for environment variables + ```go + prefixFunc := func(instanceKey string) string { + return "DB_" + strings.ToUpper(instanceKey) + "_" + } + ``` + +3. **Environment Variable Documentation**: Document expected environment variables + ```bash + # Required environment variables: + DB_PRIMARY_DRIVER=postgres + DB_PRIMARY_DSN=postgres://... + DB_READONLY_DRIVER=postgres + DB_READONLY_DSN=postgres://... + ``` + +4. **Graceful Defaults**: Provide sensible defaults for non-critical configuration + ```go + type ConnectionConfig struct { + Driver string `env:"DRIVER"` + DSN string `env:"DSN"` + MaxOpenConnections int `env:"MAX_OPEN_CONNECTIONS" default:"25"` + } + ``` + +5. **Validation**: Implement validation for instance configurations + ```go + func (c *ConnectionConfig) Validate() error { + if c.Driver == "" { + return errors.New("driver is required") + } + if c.DSN == "" { + return errors.New("DSN is required") + } + return nil + } + ``` + +#### Benefits + +Instance-aware configuration provides several key benefits: + +- **🔄 Backward Compatibility**: All existing functionality is preserved +- **🏗️ Extensible Design**: Easy to add to any module configuration +- **🔧 Multiple Patterns**: Supports both single and multi-instance configurations +- **📦 Module Support**: Enhanced support across database, cache, and HTTP server modules +- **✅ No Conflicts**: Different instances don't interfere with each other +- **🎯 Consistent Naming**: Predictable environment variable patterns +- **⚙️ Automatic Configuration**: Modules handle instance-aware configuration automatically + ## Multi-tenancy Support ### Tenant Context diff --git a/config_feeders.go b/config_feeders.go index 20773766..5c2e2bae 100644 --- a/config_feeders.go +++ b/config_feeders.go @@ -19,3 +19,18 @@ type ComplexFeeder interface { Feeder FeedKey(string, interface{}) error } + +// InstanceAwareFeeder provides functionality for feeding multiple instances of the same configuration type +type InstanceAwareFeeder interface { + ComplexFeeder + // FeedInstances feeds multiple instances from a map[string]ConfigType + FeedInstances(instances interface{}) error +} + +// InstancePrefixFunc is a function that generates a prefix for an instance key +type InstancePrefixFunc = feeders.InstancePrefixFunc + +// NewInstanceAwareEnvFeeder creates a new instance-aware environment variable feeder +func NewInstanceAwareEnvFeeder(prefixFunc InstancePrefixFunc) InstanceAwareFeeder { + return feeders.NewInstanceAwareEnvFeeder(prefixFunc) +} diff --git a/examples/instance-aware-db/README.md b/examples/instance-aware-db/README.md new file mode 100644 index 00000000..868fa0c6 --- /dev/null +++ b/examples/instance-aware-db/README.md @@ -0,0 +1,47 @@ +# Instance-Aware Database Configuration Example + +This example demonstrates the new instance-aware environment variable configuration system for multiple database connections. + +## Features Demonstrated + +- Multiple database connections (primary, secondary, cache) +- Instance-specific environment variable mapping +- Automatic configuration from environment variables +- Consistent naming convention + +## Environment Variables + +The example uses the following environment variable pattern: + +```bash +DB__= +``` + +For example: +- `DB_PRIMARY_DRIVER=sqlite` +- `DB_PRIMARY_DSN=./primary.db` +- `DB_SECONDARY_DRIVER=sqlite` +- `DB_SECONDARY_DSN=./secondary.db` +- `DB_CACHE_DRIVER=sqlite` +- `DB_CACHE_DSN=:memory:` + +## Running the Example + +```bash +go run main.go +``` + +The example will: +1. Set up environment variables programmatically +2. Initialize the modular application with database module +3. Demonstrate multiple database connections +4. Show how each connection is configured independently +5. Clean up resources + +## Key Benefits + +1. **Separation of Concerns**: Each database instance has its own environment variables +2. **No Conflicts**: Different database connections don't interfere with each other +3. **Consistent Naming**: Predictable environment variable names +4. **Easy Configuration**: Simple to set up in different environments +5. **Automatic Mapping**: No manual configuration code needed \ No newline at end of file diff --git a/examples/instance-aware-db/go.mod b/examples/instance-aware-db/go.mod new file mode 100644 index 00000000..6b7fef2b --- /dev/null +++ b/examples/instance-aware-db/go.mod @@ -0,0 +1,35 @@ +module github.com/GoCodeAlone/modular/examples/instance-aware-db + +go 1.24.2 + +replace github.com/GoCodeAlone/modular => ../.. + +replace github.com/GoCodeAlone/modular/modules/database => ../../modules/database + +require ( + github.com/GoCodeAlone/modular v1.3.0 + github.com/GoCodeAlone/modular/modules/database v0.0.0-00010101000000-000000000000 +) + +require ( + github.com/BurntSushi/toml v1.5.0 // indirect + github.com/aws/aws-sdk-go-v2 v1.36.3 // indirect + github.com/aws/aws-sdk-go-v2/config v1.29.14 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.17.67 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 // indirect + github.com/aws/aws-sdk-go-v2/feature/rds/auth v1.5.11 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.25.3 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.33.19 // indirect + github.com/aws/smithy-go v1.22.2 // indirect + github.com/golobby/cast v1.3.3 // indirect + github.com/golobby/config/v3 v3.4.2 // indirect + github.com/golobby/dotenv v1.3.2 // indirect + github.com/golobby/env/v2 v2.2.4 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/examples/instance-aware-db/go.sum b/examples/instance-aware-db/go.sum new file mode 100644 index 00000000..17ed265c --- /dev/null +++ b/examples/instance-aware-db/go.sum @@ -0,0 +1,96 @@ +github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= +github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/aws/aws-sdk-go-v2 v1.36.3 h1:mJoei2CxPutQVxaATCzDUjcZEjVRdpsiiXi2o38yqWM= +github.com/aws/aws-sdk-go-v2 v1.36.3/go.mod h1:LLXuLpgzEbD766Z5ECcRmi8AzSwfZItDtmABVkRLGzg= +github.com/aws/aws-sdk-go-v2/config v1.29.14 h1:f+eEi/2cKCg9pqKBoAIwRGzVb70MRKqWX4dg1BDcSJM= +github.com/aws/aws-sdk-go-v2/config v1.29.14/go.mod h1:wVPHWcIFv3WO89w0rE10gzf17ZYy+UVS1Geq8Iei34g= +github.com/aws/aws-sdk-go-v2/credentials v1.17.67 h1:9KxtdcIA/5xPNQyZRgUSpYOE6j9Bc4+D7nZua0KGYOM= +github.com/aws/aws-sdk-go-v2/credentials v1.17.67/go.mod h1:p3C44m+cfnbv763s52gCqrjaqyPikj9Sg47kUVaNZQQ= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 h1:x793wxmUWVDhshP8WW2mlnXuFrO4cOd3HLBroh1paFw= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30/go.mod h1:Jpne2tDnYiFascUEs2AWHJL9Yp7A5ZVy3TNyxaAjD6M= +github.com/aws/aws-sdk-go-v2/feature/rds/auth v1.5.11 h1:qDk85oQdhwP4NR1RpkN+t40aN46/K96hF9J1vDRrkKM= +github.com/aws/aws-sdk-go-v2/feature/rds/auth v1.5.11/go.mod h1:f3MkXuZsT+wY24nLIP+gFUuIVQkpVopxbpUD/GUZK0Q= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 h1:ZK5jHhnrioRkUNOc+hOgQKlUL5JeC3S6JgLxtQ+Rm0Q= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34/go.mod h1:p4VfIceZokChbA9FzMbRGz5OV+lekcVtHlPKEO0gSZY= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 h1:SZwFm17ZUNNg5Np0ioo/gq8Mn6u9w19Mri8DnJ15Jf0= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34/go.mod h1:dFZsC0BLo346mvKQLWmoJxT+Sjp+qcVR1tRVHQGOH9Q= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 h1:eAh2A4b5IzM/lum78bZ590jy36+d/aFLgKF/4Vd1xPE= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3/go.mod h1:0yKJC/kb8sAnmlYa6Zs3QVYqaC8ug2AbnNChv5Ox3uA= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 h1:dM9/92u2F1JbDaGooxTq18wmmFzbJRfXfVfy96/1CXM= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15/go.mod h1:SwFBy2vjtA0vZbjjaFtfN045boopadnoVPhu4Fv66vY= +github.com/aws/aws-sdk-go-v2/service/sso v1.25.3 h1:1Gw+9ajCV1jogloEv1RRnvfRFia2cL6c9cuKV2Ps+G8= +github.com/aws/aws-sdk-go-v2/service/sso v1.25.3/go.mod h1:qs4a9T5EMLl/Cajiw2TcbNt2UNo/Hqlyp+GiuG4CFDI= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1 h1:hXmVKytPfTy5axZ+fYbR5d0cFmC3JvwLm5kM83luako= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1/go.mod h1:MlYRNmYu/fGPoxBQVvBYr9nyr948aY/WLUvwBMBJubs= +github.com/aws/aws-sdk-go-v2/service/sts v1.33.19 h1:1XuUZ8mYJw9B6lzAkXhqHlJd/XvaX32evhproijJEZY= +github.com/aws/aws-sdk-go-v2/service/sts v1.33.19/go.mod h1:cQnB8CUnxbMU82JvlqjKR2HBOm3fe9pWorWBza6MBJ4= +github.com/aws/smithy-go v1.22.2 h1:6D9hW43xKFrRx/tXXfAlIZc4JI+yQe6snnWcQyxSyLQ= +github.com/aws/smithy-go v1.22.2/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/golobby/cast v1.3.3 h1:s2Lawb9RMz7YyYf8IrfMQY4IFmA1R/lgfmj97Vc6fig= +github.com/golobby/cast v1.3.3/go.mod h1:0oDO5IT84HTXcbLDf1YXuk0xtg/cRDrxhbpWKxwtJCY= +github.com/golobby/config/v3 v3.4.2 h1:oIOSo24mC0A8f93ZTL24NDNw0hZ3Tbb34wc1ckn2CsA= +github.com/golobby/config/v3 v3.4.2/go.mod h1:3go9UVPb3bBNrH7qidd4vd1HbsAAwIYqcQJgGmAa044= +github.com/golobby/dotenv v1.3.2 h1:9vA8XqXXIB3cX/5xQ1CTbOCPegioHtHXIxeFng+uOqQ= +github.com/golobby/dotenv v1.3.2/go.mod h1:9MMVXqzLNluhVxCv3X/DLYBNUb289f05tr+df1+7278= +github.com/golobby/env/v2 v2.2.4 h1:sjdTe+bScPRWUIA1AQH95RHv52jM5Mns2XHwLyEbkzk= +github.com/golobby/env/v2 v2.2.4/go.mod h1:HDJW+dHHwLxkb8FZMjBTBiZUFl1iAA4F9YX15kBC84c= +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/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +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/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +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.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= +golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/libc v1.65.7 h1:Ia9Z4yzZtWNtUIuiPuQ7Qf7kxYrxP1/jeHZzG8bFu00= +modernc.org/libc v1.65.7/go.mod h1:011EQibzzio/VX3ygj1qGFt5kMjP0lHb0qCW5/D/pQU= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.37.1 h1:EgHJK/FPoqC+q2YBXg7fUmES37pCHFc97sI7zSayBEs= +modernc.org/sqlite v1.37.1/go.mod h1:XwdRtsE1MpiBcL54+MbKcaDvcuej+IYSMfLN6gSKV8g= diff --git a/examples/instance-aware-db/main.go b/examples/instance-aware-db/main.go new file mode 100644 index 00000000..d817862c --- /dev/null +++ b/examples/instance-aware-db/main.go @@ -0,0 +1,207 @@ +package main + +import ( + "fmt" + "log/slog" + "os" + + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/modular/feeders" + "github.com/GoCodeAlone/modular/modules/database" +) + +func main() { + // This example demonstrates how to use instance-aware environment variable configuration + // for multiple database connections + + fmt.Println("=== Instance-Aware Database Configuration Example ===") + + // Set up environment variables for multiple database connections + // In a real application, these would be set externally + envVars := map[string]string{ + "DB_PRIMARY_DRIVER": "sqlite", + "DB_PRIMARY_DSN": "./primary.db", + "DB_SECONDARY_DRIVER": "sqlite", + "DB_SECONDARY_DSN": "./secondary.db", + "DB_CACHE_DRIVER": "sqlite", + "DB_CACHE_DSN": ":memory:", + } + + fmt.Println("Setting up environment variables:") + for key, value := range envVars { + os.Setenv(key, value) + fmt.Printf(" %s=%s\n", key, value) + } + + // Clean up environment variables at the end + defer func() { + for key := range envVars { + os.Unsetenv(key) + } + }() + + // Configure feeders - just basic env feeding since we don't need a YAML file + modular.ConfigFeeders = []modular.Feeder{ + feeders.NewEnvFeeder(), // Regular env feeding for app config + // Instance-aware feeding is handled automatically by the database module + } + + // Create application + logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})) + app := modular.NewStdApplication( + modular.NewStdConfigProvider(&AppConfig{}), + logger, + ) + + // Register the database module + dbModule := database.NewModule() + app.RegisterModule(dbModule) + + // Initialize the application + fmt.Println("\nInitializing application...") + if err := app.Init(); err != nil { + fmt.Printf("Failed to initialize application: %v\n", err) + os.Exit(1) + } + + // After init, configure the database connections that should be loaded from env vars + if err := setupDatabaseConnections(app, dbModule); err != nil { + fmt.Printf("Failed to setup database connections: %v\n", err) + os.Exit(1) + } + + // Get the database module to demonstrate multiple connections + var dbManager *database.Module + if err := app.GetService("database.manager", &dbManager); err != nil { + fmt.Printf("Failed to get database manager: %v\n", err) + os.Exit(1) + } + + fmt.Println("\nAvailable database connections:") + connections := dbManager.GetConnections() + for _, connName := range connections { + fmt.Printf(" - %s\n", connName) + + if db, exists := dbManager.GetConnection(connName); exists { + if err := db.Ping(); err != nil { + fmt.Printf(" ❌ Failed to ping %s: %v\n", connName, err) + } else { + fmt.Printf(" ✅ %s connection is healthy\n", connName) + } + } + } + + // Start the application + fmt.Println("\nStarting application...") + if err := app.Start(); err != nil { + fmt.Printf("Failed to start application: %v\n", err) + os.Exit(1) + } + + // Demonstrate using different connections + fmt.Println("\nDemonstrating multiple database connections:") + + // Use primary connection + if primaryDB, exists := dbManager.GetConnection("primary"); exists { + fmt.Println("Using primary database...") + if _, err := primaryDB.Exec("CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)"); err != nil { + fmt.Printf(" ❌ Failed to create table in primary: %v\n", err) + } else { + fmt.Println(" ✅ Created table in primary database") + } + } + + // Use secondary connection + if secondaryDB, exists := dbManager.GetConnection("secondary"); exists { + fmt.Println("Using secondary database...") + if _, err := secondaryDB.Exec("CREATE TABLE IF NOT EXISTS logs (id INTEGER PRIMARY KEY, message TEXT)"); err != nil { + fmt.Printf(" ❌ Failed to create table in secondary: %v\n", err) + } else { + fmt.Println(" ✅ Created table in secondary database") + } + } + + // Use cache connection + if cacheDB, exists := dbManager.GetConnection("cache"); exists { + fmt.Println("Using cache database...") + if _, err := cacheDB.Exec("CREATE TABLE IF NOT EXISTS cache (key TEXT PRIMARY KEY, value TEXT)"); err != nil { + fmt.Printf(" ❌ Failed to create table in cache: %v\n", err) + } else { + fmt.Println(" ✅ Created table in cache database") + } + } + + // Stop the application + fmt.Println("\nStopping application...") + if err := app.Stop(); err != nil { + fmt.Printf("Failed to stop application: %v\n", err) + os.Exit(1) + } + + fmt.Println("✅ Application stopped successfully") + fmt.Println("\n=== Key Benefits of Instance-Aware Configuration ===") + fmt.Println("1. Multiple database connections with separate environment variables") + fmt.Println("2. Consistent naming convention (DB__)") + fmt.Println("3. Automatic configuration from environment variables") + fmt.Println("4. No conflicts between different database instances") + fmt.Println("5. Easy to configure in different environments (dev, test, prod)") +} + +// AppConfig demonstrates basic application configuration +type AppConfig struct { + AppName string `yaml:"appName" env:"APP_NAME" default:"Instance-Aware DB Example"` + Environment string `yaml:"environment" env:"ENVIRONMENT" default:"development"` +} + +// Validate implements basic validation +func (c *AppConfig) Validate() error { + // Add any validation logic here + return nil +} + +// setupDatabaseConnections configures the database connections that should be loaded from environment variables +func setupDatabaseConnections(app modular.Application, dbModule *database.Module) error { + // Get the database configuration section + configProvider, err := app.GetConfigSection(dbModule.Name()) + if err != nil { + return fmt.Errorf("failed to get database config section: %w", err) + } + + config, ok := configProvider.GetConfig().(*database.Config) + if !ok { + return fmt.Errorf("invalid database config type") + } + + // Set up the connections that should be configured from environment variables + config.Connections = map[string]database.ConnectionConfig{ + "primary": {}, // Will be populated from DB_PRIMARY_* env vars + "secondary": {}, // Will be populated from DB_SECONDARY_* env vars + "cache": {}, // Will be populated from DB_CACHE_* env vars + } + config.Default = "primary" + + // Apply instance-aware configuration + if iaProvider, ok := configProvider.(*modular.InstanceAwareConfigProvider); ok { + prefixFunc := iaProvider.GetInstancePrefixFunc() + if prefixFunc != nil { + feeder := modular.NewInstanceAwareEnvFeeder(prefixFunc) + instanceConfigs := config.GetInstanceConfigs() + + // Feed each instance with environment variables + for instanceKey, instanceConfig := range instanceConfigs { + if err := feeder.FeedKey(instanceKey, instanceConfig); err != nil { + return fmt.Errorf("failed to feed instance config for %s: %w", instanceKey, err) + } + } + + // Update the original config with the fed instances + for name, instance := range instanceConfigs { + if connPtr, ok := instance.(*database.ConnectionConfig); ok { + config.Connections[name] = *connPtr + } + } + } + } + + return nil +} \ No newline at end of file diff --git a/feeders/instance_aware_env.go b/feeders/instance_aware_env.go new file mode 100644 index 00000000..e288deca --- /dev/null +++ b/feeders/instance_aware_env.go @@ -0,0 +1,164 @@ +package feeders + +import ( + "fmt" + "os" + "reflect" + "strings" +) + +// Static errors for err113 compliance +var ( + ErrInstancesMustBeMap = fmt.Errorf("instances must be a map") +) + +// InstancePrefixFunc is a function that generates a prefix for an instance key +type InstancePrefixFunc func(instanceKey string) string + +// InstanceAwareEnvFeeder is a feeder that can handle environment variables for multiple instances +// of the same configuration type using instance-specific prefixes +type InstanceAwareEnvFeeder struct { + prefixFunc InstancePrefixFunc +} + +// Ensure InstanceAwareEnvFeeder implements both interfaces +var _ interface { + Feed(interface{}) error + FeedKey(string, interface{}) error + FeedInstances(interface{}) error +} = (*InstanceAwareEnvFeeder)(nil) + +// NewInstanceAwareEnvFeeder creates a new instance-aware environment variable feeder +func NewInstanceAwareEnvFeeder(prefixFunc InstancePrefixFunc) *InstanceAwareEnvFeeder { + return &InstanceAwareEnvFeeder{ + prefixFunc: prefixFunc, + } +} + +// Feed implements the basic Feeder interface for single instances (backward compatibility) +func (f *InstanceAwareEnvFeeder) Feed(structure interface{}) error { + inputType := reflect.TypeOf(structure) + if inputType == nil { + return ErrEnvInvalidStructure + } + + if inputType.Kind() != reflect.Ptr { + return ErrEnvInvalidStructure + } + + if inputType.Elem().Kind() != reflect.Struct { + return ErrEnvInvalidStructure + } + + // For single instance, use no prefix + return f.feedStructWithPrefix(reflect.ValueOf(structure).Elem(), "") +} + +// FeedKey implements the ComplexFeeder interface for instance-specific feeding +func (f *InstanceAwareEnvFeeder) FeedKey(instanceKey string, structure interface{}) error { + inputType := reflect.TypeOf(structure) + if inputType == nil { + return ErrEnvInvalidStructure + } + + if inputType.Kind() != reflect.Ptr { + return ErrEnvInvalidStructure + } + + if inputType.Elem().Kind() != reflect.Struct { + return ErrEnvInvalidStructure + } + + // Generate prefix for this instance + prefix := "" + if f.prefixFunc != nil { + prefix = f.prefixFunc(instanceKey) + } + + return f.feedStructWithPrefix(reflect.ValueOf(structure).Elem(), prefix) +} + +// FeedInstances feeds multiple instances of the same configuration type +func (f *InstanceAwareEnvFeeder) FeedInstances(instances interface{}) error { + instancesValue := reflect.ValueOf(instances) + if instancesValue.Kind() != reflect.Map { + return ErrInstancesMustBeMap + } + + // Iterate through map entries + for _, key := range instancesValue.MapKeys() { + instanceKey := key.String() + instance := instancesValue.MapIndex(key) + + // Create a pointer to the instance for modification + instancePtr := reflect.New(instance.Type()) + instancePtr.Elem().Set(instance) + + // Feed this instance with its specific prefix + if err := f.FeedKey(instanceKey, instancePtr.Interface()); err != nil { + return fmt.Errorf("failed to feed instance '%s': %w", instanceKey, err) + } + + // Update the map with the modified instance + instancesValue.SetMapIndex(key, instancePtr.Elem()) + } + + return nil +} + +// feedStructWithPrefix feeds a struct with environment variables using the specified prefix +func (f *InstanceAwareEnvFeeder) feedStructWithPrefix(rv reflect.Value, prefix string) error { + return f.processStructFieldsWithPrefix(rv, prefix) +} + +// processStructFieldsWithPrefix iterates through struct fields with prefix +func (f *InstanceAwareEnvFeeder) processStructFieldsWithPrefix(rv reflect.Value, prefix string) error { + for i := 0; i < rv.NumField(); i++ { + field := rv.Field(i) + fieldType := rv.Type().Field(i) + + if err := f.processFieldWithPrefix(field, &fieldType, prefix); err != nil { + return fmt.Errorf("error in field '%s': %w", fieldType.Name, err) + } + } + return nil +} + +// processFieldWithPrefix handles a single struct field with prefix +func (f *InstanceAwareEnvFeeder) processFieldWithPrefix(field reflect.Value, fieldType *reflect.StructField, prefix string) error { + // Handle nested structs + switch field.Kind() { + case reflect.Struct: + return f.processStructFieldsWithPrefix(field, prefix) + case reflect.Pointer: + if !field.IsZero() && field.Elem().Kind() == reflect.Struct { + return f.processStructFieldsWithPrefix(field.Elem(), prefix) + } + case reflect.Invalid, reflect.Bool, reflect.Int, reflect.Int8, reflect.Int16, + reflect.Int32, reflect.Int64, reflect.Uint, reflect.Uint8, reflect.Uint16, + reflect.Uint32, reflect.Uint64, reflect.Uintptr, reflect.Float32, reflect.Float64, + reflect.Complex64, reflect.Complex128, reflect.Array, reflect.Chan, reflect.Func, + reflect.Interface, reflect.Map, reflect.Slice, reflect.String, reflect.UnsafePointer: + // Check for env tag for primitive types and other non-struct types + if envTag, exists := fieldType.Tag.Lookup("env"); exists { + return f.setFieldFromEnvWithPrefix(field, envTag, prefix) + } + } + + return nil +} + +// setFieldFromEnvWithPrefix sets a field value from an environment variable with prefix +func (f *InstanceAwareEnvFeeder) setFieldFromEnvWithPrefix(field reflect.Value, envTag, prefix string) error { + // Build environment variable name with prefix + envName := strings.ToUpper(envTag) + if prefix != "" { + envName = strings.ToUpper(prefix) + envName + } + + // Get and apply environment variable if exists + if envValue := os.Getenv(envName); envValue != "" { + return setFieldValue(field, envValue) + } + return nil +} diff --git a/feeders/instance_aware_env_test.go b/feeders/instance_aware_env_test.go new file mode 100644 index 00000000..ccf08844 --- /dev/null +++ b/feeders/instance_aware_env_test.go @@ -0,0 +1,259 @@ +package feeders + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestInstanceAwareEnvFeeder tests the new instance-aware environment variable feeder +func TestInstanceAwareEnvFeeder(t *testing.T) { + // Sample config structure for testing multiple database connections + type DatabaseConnectionConfig struct { + Driver string `env:"DRIVER"` + DSN string `env:"DSN"` + User string `env:"USER"` + Password string `env:"PASSWORD"` + } + + type MultiDatabaseConfig struct { + Connections map[string]DatabaseConnectionConfig `json:"connections" yaml:"connections"` + } + + tests := []struct { + name string + envVars map[string]string + expectedConfig MultiDatabaseConfig + instancePrefix func(instanceKey string) string + }{ + { + name: "multiple_database_connections_with_instance_prefixes", + envVars: map[string]string{ + "MAIN_DRIVER": "postgres", + "MAIN_DSN": "postgres://localhost/main", + "MAIN_USER": "main_user", + "MAIN_PASSWORD": "main_pass", + + "READONLY_DRIVER": "mysql", + "READONLY_DSN": "mysql://localhost/readonly", + "READONLY_USER": "readonly_user", + "READONLY_PASSWORD": "readonly_pass", + + "CACHE_DRIVER": "redis", + "CACHE_DSN": "redis://localhost/cache", + "CACHE_USER": "cache_user", + "CACHE_PASSWORD": "cache_pass", + }, + instancePrefix: func(instanceKey string) string { + return instanceKey + "_" + }, + expectedConfig: MultiDatabaseConfig{ + Connections: map[string]DatabaseConnectionConfig{ + "main": { + Driver: "postgres", + DSN: "postgres://localhost/main", + User: "main_user", + Password: "main_pass", + }, + "readonly": { + Driver: "mysql", + DSN: "mysql://localhost/readonly", + User: "readonly_user", + Password: "readonly_pass", + }, + "cache": { + Driver: "redis", + DSN: "redis://localhost/cache", + User: "cache_user", + Password: "cache_pass", + }, + }, + }, + }, + { + name: "module_and_instance_prefixes", + envVars: map[string]string{ + "DB_MAIN_DRIVER": "postgres", + "DB_MAIN_DSN": "postgres://localhost/main", + "DB_BACKUP_DRIVER": "postgres", + "DB_BACKUP_DSN": "postgres://localhost/backup", + }, + instancePrefix: func(instanceKey string) string { + return "DB_" + instanceKey + "_" + }, + expectedConfig: MultiDatabaseConfig{ + Connections: map[string]DatabaseConnectionConfig{ + "main": { + Driver: "postgres", + DSN: "postgres://localhost/main", + }, + "backup": { + Driver: "postgres", + DSN: "postgres://localhost/backup", + }, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Clear environment + clearTestEnv(t) + + // Set up environment variables + for key, value := range tt.envVars { + err := os.Setenv(key, value) + require.NoError(t, err) + } + + // Clean up after test + defer func() { + for key := range tt.envVars { + os.Unsetenv(key) + } + }() + + // Create config with connections + config := &MultiDatabaseConfig{ + Connections: make(map[string]DatabaseConnectionConfig), + } + + // Add connection instances + for instanceKey := range tt.expectedConfig.Connections { + config.Connections[instanceKey] = DatabaseConnectionConfig{} + } + + // Create and use the instance-aware feeder + feeder := NewInstanceAwareEnvFeeder(tt.instancePrefix) + err := feeder.FeedInstances(config.Connections) + require.NoError(t, err) + + // Verify the configuration was populated correctly + assert.Equal(t, tt.expectedConfig, *config) + }) + } +} + +// TestInstanceAwareEnvFeederWithSingleInstance tests backward compatibility +func TestInstanceAwareEnvFeederWithSingleInstance(t *testing.T) { + type Config struct { + Host string `env:"HOST"` + Port int `env:"PORT"` + } + + // Set up environment + clearTestEnv(t) + err := os.Setenv("HOST", "localhost") + require.NoError(t, err) + err = os.Setenv("PORT", "8080") + require.NoError(t, err) + + defer func() { + os.Unsetenv("HOST") + os.Unsetenv("PORT") + }() + + config := &Config{} + feeder := NewInstanceAwareEnvFeeder(nil) // No prefix function for single instance + err = feeder.Feed(config) + require.NoError(t, err) + + assert.Equal(t, "localhost", config.Host) + assert.Equal(t, 8080, config.Port) +} + +// TestInstanceAwareEnvFeederErrors tests error conditions +func TestInstanceAwareEnvFeederErrors(t *testing.T) { + tests := []struct { + name string + input interface{} + expectError bool + }{ + { + name: "nil_input", + input: nil, + expectError: true, + }, + { + name: "non_pointer_input", + input: struct{}{}, + expectError: true, + }, + { + name: "non_struct_pointer", + input: new(string), + expectError: true, + }, + { + name: "valid_struct_pointer", + input: &struct { + Field string `env:"FIELD"` + }{}, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + feeder := NewInstanceAwareEnvFeeder(nil) + err := feeder.Feed(tt.input) + + if tt.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +// TestInstanceAwareEnvFeederComplexFeeder tests the ComplexFeeder interface +func TestInstanceAwareEnvFeederComplexFeeder(t *testing.T) { + type ConnectionConfig struct { + Host string `env:"HOST"` + Port int `env:"PORT"` + } + + // Set up environment for database instance + clearTestEnv(t) + err := os.Setenv("DATABASE_HOST", "db.example.com") + require.NoError(t, err) + err = os.Setenv("DATABASE_PORT", "5432") + require.NoError(t, err) + + defer func() { + os.Unsetenv("DATABASE_HOST") + os.Unsetenv("DATABASE_PORT") + }() + + config := &ConnectionConfig{} + feeder := NewInstanceAwareEnvFeeder(func(instanceKey string) string { + return instanceKey + "_" + }) + + // Test FeedKey method (ComplexFeeder interface) + err = feeder.FeedKey("database", config) + require.NoError(t, err) + + assert.Equal(t, "db.example.com", config.Host) + assert.Equal(t, 5432, config.Port) +} + +// clearTestEnv clears relevant test environment variables +func clearTestEnv(t *testing.T) { + envVars := []string{ + "DRIVER", "DSN", "USER", "PASSWORD", "HOST", "PORT", + "MAIN_DRIVER", "MAIN_DSN", "MAIN_USER", "MAIN_PASSWORD", + "READONLY_DRIVER", "READONLY_DSN", "READONLY_USER", "READONLY_PASSWORD", + "CACHE_DRIVER", "CACHE_DSN", "CACHE_USER", "CACHE_PASSWORD", + "DB_MAIN_DRIVER", "DB_MAIN_DSN", "DB_BACKUP_DRIVER", "DB_BACKUP_DSN", + "DATABASE_HOST", "DATABASE_PORT", + } + + for _, envVar := range envVars { + os.Unsetenv(envVar) + } +} diff --git a/instance_aware_config.go b/instance_aware_config.go new file mode 100644 index 00000000..930236a9 --- /dev/null +++ b/instance_aware_config.go @@ -0,0 +1,31 @@ +package modular + +// InstanceAwareConfigProvider handles configuration for multiple instances of the same type +type InstanceAwareConfigProvider struct { + cfg any + instancePrefixFunc InstancePrefixFunc +} + +// NewInstanceAwareConfigProvider creates a new instance-aware configuration provider +func NewInstanceAwareConfigProvider(cfg any, prefixFunc InstancePrefixFunc) *InstanceAwareConfigProvider { + return &InstanceAwareConfigProvider{ + cfg: cfg, + instancePrefixFunc: prefixFunc, + } +} + +// GetConfig returns the configuration object +func (p *InstanceAwareConfigProvider) GetConfig() any { + return p.cfg +} + +// GetInstancePrefixFunc returns the instance prefix function +func (p *InstanceAwareConfigProvider) GetInstancePrefixFunc() InstancePrefixFunc { + return p.instancePrefixFunc +} + +// InstanceAwareConfigSupport indicates that a configuration supports instance-aware feeding +type InstanceAwareConfigSupport interface { + // GetInstanceConfigs returns a map of instance configurations that should be fed with instance-aware feeders + GetInstanceConfigs() map[string]interface{} +} diff --git a/module_env_config_test.go b/module_env_config_test.go new file mode 100644 index 00000000..5a33ffef --- /dev/null +++ b/module_env_config_test.go @@ -0,0 +1,173 @@ +package modular + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestEnvMappingForModules tests that module configurations can be populated from environment variables +func TestEnvMappingForModules(t *testing.T) { + // Test various module configurations to ensure env mapping works + t.Run("cache_config_env_mapping", func(t *testing.T) { + type CacheConfig struct { + Engine string `env:"ENGINE"` + DefaultTTL int `env:"DEFAULT_TTL"` + CleanupInterval int `env:"CLEANUP_INTERVAL"` + MaxItems int `env:"MAX_ITEMS"` + RedisURL string `env:"REDIS_URL"` + RedisPassword string `env:"REDIS_PASSWORD"` + RedisDB int `env:"REDIS_DB"` + } + + // Clear environment + envVars := []string{"ENGINE", "DEFAULT_TTL", "CLEANUP_INTERVAL", "MAX_ITEMS", + "REDIS_URL", "REDIS_PASSWORD", "REDIS_DB"} + for _, env := range envVars { + os.Unsetenv(env) + } + + // Set up environment variables + testEnvVars := map[string]string{ + "ENGINE": "redis", + "DEFAULT_TTL": "3600", + "CLEANUP_INTERVAL": "300", + "MAX_ITEMS": "10000", + "REDIS_URL": "redis://localhost:6379", + "REDIS_PASSWORD": "secret123", + "REDIS_DB": "5", + } + + for key, value := range testEnvVars { + err := os.Setenv(key, value) + require.NoError(t, err) + } + + defer func() { + for key := range testEnvVars { + os.Unsetenv(key) + } + }() + + // Test regular env feeder + config := &CacheConfig{} + feeder := NewInstanceAwareEnvFeeder(nil) // Use as regular feeder + err := feeder.Feed(config) + require.NoError(t, err) + + // Verify configuration was populated + assert.Equal(t, "redis", config.Engine) + assert.Equal(t, 3600, config.DefaultTTL) + assert.Equal(t, 300, config.CleanupInterval) + assert.Equal(t, 10000, config.MaxItems) + assert.Equal(t, "redis://localhost:6379", config.RedisURL) + assert.Equal(t, "secret123", config.RedisPassword) + assert.Equal(t, 5, config.RedisDB) + }) + + t.Run("httpserver_config_env_mapping", func(t *testing.T) { + type HTTPServerConfig struct { + Host string `env:"HOST"` + Port int `env:"PORT"` + ReadTimeout int `env:"READ_TIMEOUT"` + WriteTimeout int `env:"WRITE_TIMEOUT"` + IdleTimeout int `env:"IDLE_TIMEOUT"` + ShutdownTimeout int `env:"SHUTDOWN_TIMEOUT"` + } + + // Clear environment + envVars := []string{"HOST", "PORT", "READ_TIMEOUT", "WRITE_TIMEOUT", "IDLE_TIMEOUT", "SHUTDOWN_TIMEOUT"} + for _, env := range envVars { + os.Unsetenv(env) + } + + // Set up environment variables + testEnvVars := map[string]string{ + "HOST": "localhost", + "PORT": "8080", + "READ_TIMEOUT": "30", + "WRITE_TIMEOUT": "30", + "IDLE_TIMEOUT": "60", + "SHUTDOWN_TIMEOUT": "10", + } + + for key, value := range testEnvVars { + err := os.Setenv(key, value) + require.NoError(t, err) + } + + defer func() { + for key := range testEnvVars { + os.Unsetenv(key) + } + }() + + // Test regular env feeder + config := &HTTPServerConfig{} + feeder := NewInstanceAwareEnvFeeder(nil) // Use as regular feeder + err := feeder.Feed(config) + require.NoError(t, err) + + // Verify configuration was populated + assert.Equal(t, "localhost", config.Host) + assert.Equal(t, 8080, config.Port) + assert.Equal(t, 30, config.ReadTimeout) + assert.Equal(t, 30, config.WriteTimeout) + assert.Equal(t, 60, config.IdleTimeout) + assert.Equal(t, 10, config.ShutdownTimeout) + }) + + t.Run("instance_aware_httpserver_configs", func(t *testing.T) { + type HTTPServerConfig struct { + Host string `env:"HOST"` + Port int `env:"PORT"` + } + + // Clear environment + clearEnvVars := []string{"HOST", "PORT", "API_HOST", "API_PORT", "ADMIN_HOST", "ADMIN_PORT"} + for _, env := range clearEnvVars { + os.Unsetenv(env) + } + + // Set up environment variables for multiple server instances + testEnvVars := map[string]string{ + "API_HOST": "api.example.com", + "API_PORT": "8080", + "ADMIN_HOST": "admin.example.com", + "ADMIN_PORT": "9090", + } + + for key, value := range testEnvVars { + err := os.Setenv(key, value) + require.NoError(t, err) + } + + defer func() { + for key := range testEnvVars { + os.Unsetenv(key) + } + }() + + // Test instance-aware configuration + configs := map[string]HTTPServerConfig{ + "api": {}, + "admin": {}, + } + + feeder := NewInstanceAwareEnvFeeder(func(instanceKey string) string { + return instanceKey + "_" + }) + + err := feeder.FeedInstances(configs) + require.NoError(t, err) + + // Verify each instance was configured correctly + assert.Equal(t, "api.example.com", configs["api"].Host) + assert.Equal(t, 8080, configs["api"].Port) + + assert.Equal(t, "admin.example.com", configs["admin"].Host) + assert.Equal(t, 9090, configs["admin"].Port) + }) +} diff --git a/modules/auth/config.go b/modules/auth/config.go index 310cca6c..8b0e9cad 100644 --- a/modules/auth/config.go +++ b/modules/auth/config.go @@ -6,58 +6,58 @@ import ( // Config represents the authentication module configuration type Config struct { - JWT JWTConfig `yaml:"jwt"` - Session SessionConfig `yaml:"session"` - OAuth2 OAuth2Config `yaml:"oauth2"` - Password PasswordConfig `yaml:"password"` + JWT JWTConfig `yaml:"jwt" env:"JWT"` + Session SessionConfig `yaml:"session" env:"SESSION"` + OAuth2 OAuth2Config `yaml:"oauth2" env:"OAUTH2"` + Password PasswordConfig `yaml:"password" env:"PASSWORD"` } // JWTConfig contains JWT-related configuration type JWTConfig struct { - Secret string `yaml:"secret" required:"true"` - Expiration time.Duration `yaml:"expiration" default:"24h"` - RefreshExpiration time.Duration `yaml:"refresh_expiration" default:"168h"` // 7 days - Issuer string `yaml:"issuer" default:"modular-auth"` - Algorithm string `yaml:"algorithm" default:"HS256"` + Secret string `yaml:"secret" required:"true" env:"SECRET"` + Expiration time.Duration `yaml:"expiration" default:"24h" env:"EXPIRATION"` + RefreshExpiration time.Duration `yaml:"refresh_expiration" default:"168h" env:"REFRESH_EXPIRATION"` // 7 days + Issuer string `yaml:"issuer" default:"modular-auth" env:"ISSUER"` + Algorithm string `yaml:"algorithm" default:"HS256" env:"ALGORITHM"` } // SessionConfig contains session-related configuration type SessionConfig struct { - Store string `yaml:"store" default:"memory"` // memory, redis, database - CookieName string `yaml:"cookie_name" default:"session_id"` - MaxAge time.Duration `yaml:"max_age" default:"24h"` - Secure bool `yaml:"secure" default:"true"` - HTTPOnly bool `yaml:"http_only" default:"true"` - SameSite string `yaml:"same_site" default:"strict"` // strict, lax, none - Domain string `yaml:"domain"` - Path string `yaml:"path" default:"/"` + Store string `yaml:"store" default:"memory" env:"STORE"` // memory, redis, database + CookieName string `yaml:"cookie_name" default:"session_id" env:"COOKIE_NAME"` + MaxAge time.Duration `yaml:"max_age" default:"24h" env:"MAX_AGE"` + Secure bool `yaml:"secure" default:"true" env:"SECURE"` + HTTPOnly bool `yaml:"http_only" default:"true" env:"HTTP_ONLY"` + SameSite string `yaml:"same_site" default:"strict" env:"SAME_SITE"` // strict, lax, none + Domain string `yaml:"domain" env:"DOMAIN"` + Path string `yaml:"path" default:"/" env:"PATH"` } // OAuth2Config contains OAuth2/OIDC configuration type OAuth2Config struct { - Providers map[string]OAuth2Provider `yaml:"providers"` + Providers map[string]OAuth2Provider `yaml:"providers" env:"PROVIDERS"` } // OAuth2Provider represents an OAuth2 provider configuration type OAuth2Provider struct { - ClientID string `yaml:"client_id" required:"true"` - ClientSecret string `yaml:"client_secret" required:"true"` - RedirectURL string `yaml:"redirect_url" required:"true"` - Scopes []string `yaml:"scopes"` - AuthURL string `yaml:"auth_url"` - TokenURL string `yaml:"token_url"` - UserInfoURL string `yaml:"user_info_url"` + ClientID string `yaml:"client_id" required:"true" env:"CLIENT_ID"` + ClientSecret string `yaml:"client_secret" required:"true" env:"CLIENT_SECRET"` + RedirectURL string `yaml:"redirect_url" required:"true" env:"REDIRECT_URL"` + Scopes []string `yaml:"scopes" env:"SCOPES"` + AuthURL string `yaml:"auth_url" env:"AUTH_URL"` + TokenURL string `yaml:"token_url" env:"TOKEN_URL"` + UserInfoURL string `yaml:"user_info_url" env:"USER_INFO_URL"` } // PasswordConfig contains password-related configuration type PasswordConfig struct { - Algorithm string `yaml:"algorithm" default:"bcrypt"` // bcrypt, argon2 - MinLength int `yaml:"min_length" default:"8"` - RequireUpper bool `yaml:"require_upper" default:"true"` - RequireLower bool `yaml:"require_lower" default:"true"` - RequireDigit bool `yaml:"require_digit" default:"true"` - RequireSpecial bool `yaml:"require_special" default:"false"` - BcryptCost int `yaml:"bcrypt_cost" default:"12"` + Algorithm string `yaml:"algorithm" default:"bcrypt" env:"ALGORITHM"` // bcrypt, argon2 + MinLength int `yaml:"min_length" default:"8" env:"MIN_LENGTH"` + RequireUpper bool `yaml:"require_upper" default:"true" env:"REQUIRE_UPPER"` + RequireLower bool `yaml:"require_lower" default:"true" env:"REQUIRE_LOWER"` + RequireDigit bool `yaml:"require_digit" default:"true" env:"REQUIRE_DIGIT"` + RequireSpecial bool `yaml:"require_special" default:"false" env:"REQUIRE_SPECIAL"` + BcryptCost int `yaml:"bcrypt_cost" default:"12" env:"BCRYPT_COST"` } // Validate validates the authentication configuration diff --git a/modules/cache/config.go b/modules/cache/config.go index 0bd969ed..c8400f0b 100644 --- a/modules/cache/config.go +++ b/modules/cache/config.go @@ -3,22 +3,22 @@ package cache // CacheConfig defines the configuration for the cache module type CacheConfig struct { // Engine specifies the cache engine to use ("memory" or "redis") - Engine string `json:"engine" yaml:"engine" validate:"oneof=memory redis"` + Engine string `json:"engine" yaml:"engine" env:"ENGINE" validate:"oneof=memory redis"` // DefaultTTL is the default time-to-live for cache entries in seconds - DefaultTTL int `json:"defaultTTL" yaml:"defaultTTL" validate:"min=1"` + DefaultTTL int `json:"defaultTTL" yaml:"defaultTTL" env:"DEFAULT_TTL" validate:"min=1"` // CleanupInterval is how often to clean up expired items (in seconds) - CleanupInterval int `json:"cleanupInterval" yaml:"cleanupInterval" validate:"min=1"` + CleanupInterval int `json:"cleanupInterval" yaml:"cleanupInterval" env:"CLEANUP_INTERVAL" validate:"min=1"` // MaxItems is the maximum number of items to store in memory cache - MaxItems int `json:"maxItems" yaml:"maxItems" validate:"min=1"` + MaxItems int `json:"maxItems" yaml:"maxItems" env:"MAX_ITEMS" validate:"min=1"` // Redis-specific configuration - RedisURL string `json:"redisURL" yaml:"redisURL"` - RedisPassword string `json:"redisPassword" yaml:"redisPassword"` - RedisDB int `json:"redisDB" yaml:"redisDB" validate:"min=0"` + RedisURL string `json:"redisURL" yaml:"redisURL" env:"REDIS_URL"` + RedisPassword string `json:"redisPassword" yaml:"redisPassword" env:"REDIS_PASSWORD"` + RedisDB int `json:"redisDB" yaml:"redisDB" env:"REDIS_DB" validate:"min=0"` // ConnectionMaxAge is the maximum age of a connection in seconds - ConnectionMaxAge int `json:"connectionMaxAge" yaml:"connectionMaxAge" validate:"min=1"` + ConnectionMaxAge int `json:"connectionMaxAge" yaml:"connectionMaxAge" env:"CONNECTION_MAX_AGE" validate:"min=1"` } diff --git a/modules/chimux/config.go b/modules/chimux/config.go index faaca2b6..324c7e89 100644 --- a/modules/chimux/config.go +++ b/modules/chimux/config.go @@ -2,13 +2,13 @@ package chimux // ChiMuxConfig holds the configuration for the chimux module type ChiMuxConfig struct { - AllowedOrigins []string `yaml:"allowed_origins" default:"[\"*\"]" desc:"List of allowed origins for CORS requests."` // List of allowed origins for CORS requests. - AllowedMethods []string `yaml:"allowed_methods" default:"[\"GET\",\"POST\",\"PUT\",\"DELETE\",\"OPTIONS\"]" desc:"List of allowed HTTP methods."` // List of allowed HTTP methods. - AllowedHeaders []string `yaml:"allowed_headers" default:"[\"Origin\",\"Accept\",\"Content-Type\",\"X-Requested-With\",\"Authorization\"]" desc:"List of allowed request headers."` // List of allowed request headers. - AllowCredentials bool `yaml:"allow_credentials" default:"false" desc:"Allow credentials in CORS requests."` // Allow credentials in CORS requests. - MaxAge int `yaml:"max_age" default:"300" desc:"Maximum age for CORS preflight cache in seconds."` // Maximum age for CORS preflight cache in seconds. - Timeout int `yaml:"timeout" default:"60000" desc:"Default request timeout."` // Default request timeout. - BasePath string `yaml:"basepath" desc:"A base path prefix for all routes registered through this module."` // A base path prefix for all routes registered through this module. + AllowedOrigins []string `yaml:"allowed_origins" default:"[\"*\"]" desc:"List of allowed origins for CORS requests." env:"ALLOWED_ORIGINS"` // List of allowed origins for CORS requests. + AllowedMethods []string `yaml:"allowed_methods" default:"[\"GET\",\"POST\",\"PUT\",\"DELETE\",\"OPTIONS\"]" desc:"List of allowed HTTP methods." env:"ALLOWED_METHODS"` // List of allowed HTTP methods. + AllowedHeaders []string `yaml:"allowed_headers" default:"[\"Origin\",\"Accept\",\"Content-Type\",\"X-Requested-With\",\"Authorization\"]" desc:"List of allowed request headers." env:"ALLOWED_HEADERS"` // List of allowed request headers. + AllowCredentials bool `yaml:"allow_credentials" default:"false" desc:"Allow credentials in CORS requests." env:"ALLOW_CREDENTIALS"` // Allow credentials in CORS requests. + MaxAge int `yaml:"max_age" default:"300" desc:"Maximum age for CORS preflight cache in seconds." env:"MAX_AGE"` // Maximum age for CORS preflight cache in seconds. + Timeout int `yaml:"timeout" default:"60000" desc:"Default request timeout." env:"TIMEOUT"` // Default request timeout. + BasePath string `yaml:"basepath" desc:"A base path prefix for all routes registered through this module." env:"BASE_PATH"` // A base path prefix for all routes registered through this module. } // Validate implements the modular.ConfigValidator interface diff --git a/modules/database/config.go b/modules/database/config.go index 966a1259..289e5cb1 100644 --- a/modules/database/config.go +++ b/modules/database/config.go @@ -9,25 +9,36 @@ type Config struct { Default string `json:"default" yaml:"default"` } +// GetInstanceConfigs returns the connections map for instance-aware configuration +func (c *Config) GetInstanceConfigs() map[string]interface{} { + instances := make(map[string]interface{}) + for name, connection := range c.Connections { + // Create a copy to avoid modifying the original + connCopy := connection + instances[name] = &connCopy + } + return instances +} + // ConnectionConfig represents configuration for a single database connection type ConnectionConfig struct { // Driver specifies the database driver to use (e.g., "postgres", "mysql") - Driver string `json:"driver" yaml:"driver"` + Driver string `json:"driver" yaml:"driver" env:"DRIVER"` // DSN is the database connection string - DSN string `json:"dsn" yaml:"dsn"` + DSN string `json:"dsn" yaml:"dsn" env:"DSN"` // MaxOpenConnections sets the maximum number of open connections to the database - MaxOpenConnections int `json:"max_open_connections" yaml:"max_open_connections"` + MaxOpenConnections int `json:"max_open_connections" yaml:"max_open_connections" env:"MAX_OPEN_CONNECTIONS"` // MaxIdleConnections sets the maximum number of idle connections in the pool - MaxIdleConnections int `json:"max_idle_connections" yaml:"max_idle_connections"` + MaxIdleConnections int `json:"max_idle_connections" yaml:"max_idle_connections" env:"MAX_IDLE_CONNECTIONS"` // ConnectionMaxLifetime sets the maximum amount of time a connection may be reused (in seconds) - ConnectionMaxLifetime int `json:"connection_max_lifetime" yaml:"connection_max_lifetime"` + ConnectionMaxLifetime int `json:"connection_max_lifetime" yaml:"connection_max_lifetime" env:"CONNECTION_MAX_LIFETIME"` // ConnectionMaxIdleTime sets the maximum amount of time a connection may be idle (in seconds) - ConnectionMaxIdleTime int `json:"connection_max_idle_time" yaml:"connection_max_idle_time"` + ConnectionMaxIdleTime int `json:"connection_max_idle_time" yaml:"connection_max_idle_time" env:"CONNECTION_MAX_IDLE_TIME"` // AWSIAMAuth contains AWS IAM authentication configuration AWSIAMAuth *AWSIAMAuthConfig `json:"aws_iam_auth,omitempty" yaml:"aws_iam_auth,omitempty"` @@ -36,15 +47,15 @@ type ConnectionConfig struct { // AWSIAMAuthConfig represents AWS IAM authentication configuration type AWSIAMAuthConfig struct { // Enabled indicates whether AWS IAM authentication is enabled - Enabled bool `json:"enabled" yaml:"enabled"` + Enabled bool `json:"enabled" yaml:"enabled" env:"AWS_IAM_AUTH_ENABLED"` // Region specifies the AWS region for the RDS instance - Region string `json:"region" yaml:"region"` + Region string `json:"region" yaml:"region" env:"AWS_IAM_AUTH_REGION"` // DBUser specifies the database username for IAM authentication - DBUser string `json:"db_user" yaml:"db_user"` + DBUser string `json:"db_user" yaml:"db_user" env:"AWS_IAM_AUTH_DB_USER"` // TokenRefreshInterval specifies how often to refresh the IAM token (in seconds) // Default is 10 minutes (600 seconds), tokens expire after 15 minutes - TokenRefreshInterval int `json:"token_refresh_interval" yaml:"token_refresh_interval"` + TokenRefreshInterval int `json:"token_refresh_interval" yaml:"token_refresh_interval" env:"AWS_IAM_AUTH_TOKEN_REFRESH"` } diff --git a/modules/database/config_env_test.go b/modules/database/config_env_test.go new file mode 100644 index 00000000..08c97550 --- /dev/null +++ b/modules/database/config_env_test.go @@ -0,0 +1,194 @@ +package database + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/GoCodeAlone/modular" +) + +// TestConnectionConfigEnvMapping tests environment variable mapping for database connections +func TestConnectionConfigEnvMapping(t *testing.T) { + tests := []struct { + name string + instanceKey string + envVars map[string]string + expectedConfig ConnectionConfig + instancePrefix func(instanceKey string) string + }{ + { + name: "postgres_connection_with_env_vars", + instanceKey: "main", + envVars: map[string]string{ + "MAIN_DRIVER": "postgres", + "MAIN_DSN": "postgres://user:pass@localhost:5432/maindb?sslmode=disable", + "MAIN_MAX_OPEN_CONNECTIONS": "25", + "MAIN_MAX_IDLE_CONNECTIONS": "5", + "MAIN_CONNECTION_MAX_LIFETIME": "3600", + "MAIN_CONNECTION_MAX_IDLE_TIME": "300", + "MAIN_AWS_IAM_AUTH_ENABLED": "true", + "MAIN_AWS_IAM_AUTH_REGION": "us-west-2", + "MAIN_AWS_IAM_AUTH_DB_USER": "iam_user", + "MAIN_AWS_IAM_AUTH_TOKEN_REFRESH": "600", + }, + instancePrefix: func(instanceKey string) string { + return instanceKey + "_" + }, + expectedConfig: ConnectionConfig{ + Driver: "postgres", + DSN: "postgres://user:pass@localhost:5432/maindb?sslmode=disable", + MaxOpenConnections: 25, + MaxIdleConnections: 5, + ConnectionMaxLifetime: 3600, + ConnectionMaxIdleTime: 300, + AWSIAMAuth: &AWSIAMAuthConfig{ + Enabled: true, + Region: "us-west-2", + DBUser: "iam_user", + TokenRefreshInterval: 600, + }, + }, + }, + { + name: "mysql_connection_minimal_config", + instanceKey: "backup", + envVars: map[string]string{ + "BACKUP_DRIVER": "mysql", + "BACKUP_DSN": "mysql://backup:secret@backup-host:3306/backupdb", + }, + instancePrefix: func(instanceKey string) string { + return instanceKey + "_" + }, + expectedConfig: ConnectionConfig{ + Driver: "mysql", + DSN: "mysql://backup:secret@backup-host:3306/backupdb", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Clear environment + clearTestEnvVars(t) + + // Set up environment variables + for key, value := range tt.envVars { + err := os.Setenv(key, value) + require.NoError(t, err) + } + + // Clean up after test + defer func() { + for key := range tt.envVars { + os.Unsetenv(key) + } + }() + + // Create config + config := &ConnectionConfig{} + + // Initialize AWSIAMAuth if needed for this test + if tt.expectedConfig.AWSIAMAuth != nil { + config.AWSIAMAuth = &AWSIAMAuthConfig{} + } + + // Create and use the instance-aware feeder + feeder := modular.NewInstanceAwareEnvFeeder(tt.instancePrefix) + + err := feeder.FeedKey(tt.instanceKey, config) + require.NoError(t, err) + + // Verify the configuration was populated correctly + assert.Equal(t, tt.expectedConfig, *config) + }) + } +} + +// TestMultipleDatabaseConnectionsWithEnvVars tests multiple database connections +func TestMultipleDatabaseConnectionsWithEnvVars(t *testing.T) { + // Clear environment + clearTestEnvVars(t) + + // Set up environment variables for multiple connections + envVars := map[string]string{ + // Main database + "MAIN_DRIVER": "postgres", + "MAIN_DSN": "postgres://localhost:5432/main", + + // Read-only replica + "READONLY_DRIVER": "postgres", + "READONLY_DSN": "postgres://readonly:5432/main", + + // Cache database + "CACHE_DRIVER": "redis", + "CACHE_DSN": "redis://localhost:6379/0", + } + + for key, value := range envVars { + err := os.Setenv(key, value) + require.NoError(t, err) + } + + defer func() { + for key := range envVars { + os.Unsetenv(key) + } + }() + + // Create configuration with multiple connections + config := &Config{ + Default: "main", + Connections: map[string]ConnectionConfig{ + "main": {}, + "readonly": {}, + "cache": {}, + }, + } + + // Create instance-aware feeder + feeder := modular.NewInstanceAwareEnvFeeder(func(instanceKey string) string { + return instanceKey + "_" + }) + + // Feed the connections + err := feeder.FeedInstances(config.Connections) + require.NoError(t, err) + + // Verify each connection was configured correctly + assert.Equal(t, "postgres", config.Connections["main"].Driver) + assert.Equal(t, "postgres://localhost:5432/main", config.Connections["main"].DSN) + + assert.Equal(t, "postgres", config.Connections["readonly"].Driver) + assert.Equal(t, "postgres://readonly:5432/main", config.Connections["readonly"].DSN) + + assert.Equal(t, "redis", config.Connections["cache"].Driver) + assert.Equal(t, "redis://localhost:6379/0", config.Connections["cache"].DSN) +} + +// TestDatabaseModuleWithInstanceAwareConfig tests the database module with instance-aware configuration +func TestDatabaseModuleWithInstanceAwareConfig(t *testing.T) { + // This test will be implemented after we update the module to support instance-aware configuration + t.Skip("Will be implemented after module update") +} + +// clearTestEnvVars clears test environment variables +func clearTestEnvVars(t *testing.T) { + envVars := []string{ + "DRIVER", "DSN", "MAX_OPEN_CONNECTIONS", "MAX_IDLE_CONNECTIONS", + "CONNECTION_MAX_LIFETIME", "CONNECTION_MAX_IDLE_TIME", + "AWS_IAM_AUTH_ENABLED", "AWS_IAM_AUTH_REGION", "AWS_IAM_AUTH_DB_USER", "AWS_IAM_AUTH_TOKEN_REFRESH", + "MAIN_DRIVER", "MAIN_DSN", "MAIN_MAX_OPEN_CONNECTIONS", "MAIN_MAX_IDLE_CONNECTIONS", + "MAIN_CONNECTION_MAX_LIFETIME", "MAIN_CONNECTION_MAX_IDLE_TIME", + "MAIN_AWS_IAM_AUTH_ENABLED", "MAIN_AWS_IAM_AUTH_REGION", "MAIN_AWS_IAM_AUTH_DB_USER", "MAIN_AWS_IAM_AUTH_TOKEN_REFRESH", + "BACKUP_DRIVER", "BACKUP_DSN", + "READONLY_DRIVER", "READONLY_DSN", + "CACHE_DRIVER", "CACHE_DSN", + } + + for _, envVar := range envVars { + os.Unsetenv(envVar) + } +} diff --git a/modules/database/go.mod b/modules/database/go.mod index 957247ec..603ba12e 100644 --- a/modules/database/go.mod +++ b/modules/database/go.mod @@ -2,6 +2,8 @@ module github.com/GoCodeAlone/modular/modules/database go 1.24.2 +replace github.com/GoCodeAlone/modular => ../.. + require ( github.com/GoCodeAlone/modular v1.3.0 github.com/aws/aws-sdk-go-v2 v1.36.3 diff --git a/modules/database/go.sum b/modules/database/go.sum index 9b5e317c..e4c2a209 100644 --- a/modules/database/go.sum +++ b/modules/database/go.sum @@ -1,8 +1,6 @@ github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/GoCodeAlone/modular v1.3.0 h1:KVny0S447hTUXYY8Y7BltL94poN/ZtaH3v2V7jU7d3o= -github.com/GoCodeAlone/modular v1.3.0/go.mod h1:dD1xYmBQdtYahsrdwP1DAe2Tz6SkCXA8foairMuY3Pk= github.com/aws/aws-sdk-go-v2 v1.36.3 h1:mJoei2CxPutQVxaATCzDUjcZEjVRdpsiiXi2o38yqWM= github.com/aws/aws-sdk-go-v2 v1.36.3/go.mod h1:LLXuLpgzEbD766Z5ECcRmi8AzSwfZItDtmABVkRLGzg= github.com/aws/aws-sdk-go-v2/config v1.29.14 h1:f+eEi/2cKCg9pqKBoAIwRGzVb70MRKqWX4dg1BDcSJM= diff --git a/modules/database/integration_test.go b/modules/database/integration_test.go new file mode 100644 index 00000000..767511f7 --- /dev/null +++ b/modules/database/integration_test.go @@ -0,0 +1,182 @@ +package database + +import ( + "context" + "log/slog" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/GoCodeAlone/modular" +) + +// TestDatabaseModuleWithInstanceAwareConfiguration tests the module with instance-aware env configuration +func TestDatabaseModuleWithInstanceAwareConfiguration(t *testing.T) { + // Clear environment + clearTestEnvVars(t) + + // Set up environment variables for multiple database connections using the DB_ prefix pattern + envVars := map[string]string{ + "DB_MAIN_DRIVER": "sqlite", + "DB_MAIN_DSN": ":memory:", + + "DB_READONLY_DRIVER": "sqlite", + "DB_READONLY_DSN": ":memory:", + + "DB_CACHE_DRIVER": "sqlite", + "DB_CACHE_DSN": ":memory:", + } + + for key, value := range envVars { + err := os.Setenv(key, value) + require.NoError(t, err) + } + + defer func() { + for key := range envVars { + os.Unsetenv(key) + } + }() + + // Create a mock application + logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelError})) + app := modular.NewStdApplication(nil, logger) + + // Create database module and register it + module := NewModule() + err := module.RegisterConfig(app) + require.NoError(t, err) + + // Get the configuration and set up connections that should be fed from environment variables + configProvider, err := app.GetConfigSection(module.Name()) + require.NoError(t, err) + + config, ok := configProvider.GetConfig().(*Config) + require.True(t, ok, "Config should be of type *Config") + + // Set up empty connections - these should be populated by the instance-aware feeder + config.Connections = map[string]ConnectionConfig{ + "main": {}, + "readonly": {}, + "cache": {}, + } + + // Test the instance-aware configuration manually + // (In real usage, this would be done automatically during app.LoadConfig()) + iaProvider, ok := configProvider.(*modular.InstanceAwareConfigProvider) + require.True(t, ok, "Should be instance-aware config provider") + + prefixFunc := iaProvider.GetInstancePrefixFunc() + require.NotNil(t, prefixFunc, "Should have prefix function") + + feeder := modular.NewInstanceAwareEnvFeeder(prefixFunc) + instanceConfigs := config.GetInstanceConfigs() + + // Feed each instance + for instanceKey, instanceConfig := range instanceConfigs { + err = feeder.FeedKey(instanceKey, instanceConfig) + require.NoError(t, err) + } + + // Update the original config with the fed instances + for name, instance := range instanceConfigs { + if connPtr, ok := instance.(*ConnectionConfig); ok { + config.Connections[name] = *connPtr + } + } + + // Verify connections were configured from environment variables + assert.Equal(t, "sqlite", config.Connections["main"].Driver) + assert.Equal(t, ":memory:", config.Connections["main"].DSN) + + assert.Equal(t, "sqlite", config.Connections["readonly"].Driver) + assert.Equal(t, ":memory:", config.Connections["readonly"].DSN) + + assert.Equal(t, "sqlite", config.Connections["cache"].Driver) + assert.Equal(t, ":memory:", config.Connections["cache"].DSN) + + // Initialize the module + err = module.Init(app) + require.NoError(t, err) + + // Start the module + ctx := context.Background() + err = module.Start(ctx) + require.NoError(t, err) + + // Verify all connections are available + connections := module.GetConnections() + assert.Len(t, connections, 3) + assert.Contains(t, connections, "main") + assert.Contains(t, connections, "readonly") + assert.Contains(t, connections, "cache") + + // Verify we can get each connection + mainDB, exists := module.GetConnection("main") + assert.True(t, exists) + assert.NotNil(t, mainDB) + + readonlyDB, exists := module.GetConnection("readonly") + assert.True(t, exists) + assert.NotNil(t, readonlyDB) + + cacheDB, exists := module.GetConnection("cache") + assert.True(t, exists) + assert.NotNil(t, cacheDB) + + // Clean up + err = module.Stop(ctx) + require.NoError(t, err) +} + +// TestInstanceAwareConfigurationIntegration tests integration with config system +func TestInstanceAwareConfigurationIntegration(t *testing.T) { + // This test demonstrates how to use instance-aware configuration in practice + // Clear environment + clearTestEnvVars(t) + + envVars := map[string]string{ + "DB_PRIMARY_DRIVER": "sqlite", + "DB_PRIMARY_DSN": ":memory:", + "DB_SECONDARY_DRIVER": "sqlite", + "DB_SECONDARY_DSN": ":memory:", + } + + for key, value := range envVars { + err := os.Setenv(key, value) + require.NoError(t, err) + } + + defer func() { + for key := range envVars { + os.Unsetenv(key) + } + }() + + // Create configuration + config := &Config{ + Default: "primary", + Connections: map[string]ConnectionConfig{ + "primary": {}, + "secondary": {}, + }, + } + + // Create instance-aware feeder with module prefix + feeder := modular.NewInstanceAwareEnvFeeder(func(instanceKey string) string { + return "DB_" + instanceKey + "_" + }) + + // Feed the configuration + err := feeder.FeedInstances(config.Connections) + require.NoError(t, err) + + // Verify configuration + assert.Equal(t, "sqlite", config.Connections["primary"].Driver) + assert.Equal(t, ":memory:", config.Connections["primary"].DSN) + + assert.Equal(t, "sqlite", config.Connections["secondary"].Driver) + assert.Equal(t, ":memory:", config.Connections["secondary"].DSN) +} diff --git a/modules/database/interface_matching_test.go b/modules/database/interface_matching_test.go index 00fc48a7..5d11ceff 100644 --- a/modules/database/interface_matching_test.go +++ b/modules/database/interface_matching_test.go @@ -3,6 +3,7 @@ package database import ( "context" "database/sql" + "fmt" "reflect" "strings" "testing" @@ -138,7 +139,7 @@ func (m *RealisticConsumer) Init(app modular.Application) error { var db DatabaseExecutor err := app.GetService("database.service", &db) if err != nil { - return err + return fmt.Errorf("failed to get database service: %w", err) } m.db = db return nil diff --git a/modules/database/module.go b/modules/database/module.go index bb5e43f9..3fd0a11c 100644 --- a/modules/database/module.go +++ b/modules/database/module.go @@ -9,6 +9,11 @@ import ( "github.com/GoCodeAlone/modular" ) +// Static errors for err113 compliance +var ( + ErrNoDefaultService = errors.New("no default database service available") +) + // Module name constant const Name = "database" @@ -20,17 +25,23 @@ type lazyDefaultService struct { func (l *lazyDefaultService) Connect() error { service := l.module.GetDefaultService() if service == nil { - return errors.New("no default database service available") + return ErrNoDefaultService + } + if err := service.Connect(); err != nil { + return fmt.Errorf("failed to connect: %w", err) } - return service.Connect() + return nil } func (l *lazyDefaultService) Close() error { service := l.module.GetDefaultService() if service == nil { - return errors.New("no default database service available") + return ErrNoDefaultService + } + if err := service.Close(); err != nil { + return fmt.Errorf("failed to close: %w", err) } - return service.Close() + return nil } func (l *lazyDefaultService) DB() *sql.DB { @@ -44,9 +55,12 @@ func (l *lazyDefaultService) DB() *sql.DB { func (l *lazyDefaultService) Ping(ctx context.Context) error { service := l.module.GetDefaultService() if service == nil { - return errors.New("no default database service available") + return ErrNoDefaultService } - return service.Ping(ctx) + if err := service.Ping(ctx); err != nil { + return fmt.Errorf("failed to ping: %w", err) + } + return nil } func (l *lazyDefaultService) Stats() sql.DBStats { @@ -60,49 +74,73 @@ func (l *lazyDefaultService) Stats() sql.DBStats { func (l *lazyDefaultService) ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error) { service := l.module.GetDefaultService() if service == nil { - return nil, errors.New("no default database service available") + return nil, ErrNoDefaultService + } + result, err := service.ExecContext(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("failed to execute query: %w", err) } - return service.ExecContext(ctx, query, args...) + return result, nil } func (l *lazyDefaultService) Exec(query string, args ...interface{}) (sql.Result, error) { service := l.module.GetDefaultService() if service == nil { - return nil, errors.New("no default database service available") + return nil, ErrNoDefaultService } - return service.Exec(query, args...) + result, err := service.Exec(query, args...) + if err != nil { + return nil, fmt.Errorf("failed to execute query: %w", err) + } + return result, nil } func (l *lazyDefaultService) PrepareContext(ctx context.Context, query string) (*sql.Stmt, error) { service := l.module.GetDefaultService() if service == nil { - return nil, errors.New("no default database service available") + return nil, ErrNoDefaultService } - return service.PrepareContext(ctx, query) + stmt, err := service.PrepareContext(ctx, query) + if err != nil { + return nil, fmt.Errorf("failed to prepare statement: %w", err) + } + return stmt, nil } func (l *lazyDefaultService) Prepare(query string) (*sql.Stmt, error) { service := l.module.GetDefaultService() if service == nil { - return nil, errors.New("no default database service available") + return nil, ErrNoDefaultService + } + stmt, err := service.Prepare(query) + if err != nil { + return nil, fmt.Errorf("failed to prepare statement: %w", err) } - return service.Prepare(query) + return stmt, nil } func (l *lazyDefaultService) QueryContext(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error) { service := l.module.GetDefaultService() if service == nil { - return nil, errors.New("no default database service available") + return nil, ErrNoDefaultService } - return service.QueryContext(ctx, query, args...) + rows, err := service.QueryContext(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("failed to query: %w", err) + } + return rows, nil } func (l *lazyDefaultService) Query(query string, args ...interface{}) (*sql.Rows, error) { service := l.module.GetDefaultService() if service == nil { - return nil, errors.New("no default database service available") + return nil, ErrNoDefaultService + } + rows, err := service.Query(query, args...) + if err != nil { + return nil, fmt.Errorf("failed to query: %w", err) } - return service.Query(query, args...) + return rows, nil } func (l *lazyDefaultService) QueryRowContext(ctx context.Context, query string, args ...interface{}) *sql.Row { @@ -124,17 +162,25 @@ func (l *lazyDefaultService) QueryRow(query string, args ...interface{}) *sql.Ro func (l *lazyDefaultService) BeginTx(ctx context.Context, opts *sql.TxOptions) (*sql.Tx, error) { service := l.module.GetDefaultService() if service == nil { - return nil, errors.New("no default database service available") + return nil, ErrNoDefaultService } - return service.BeginTx(ctx, opts) + tx, err := service.BeginTx(ctx, opts) + if err != nil { + return nil, fmt.Errorf("failed to begin transaction: %w", err) + } + return tx, nil } func (l *lazyDefaultService) Begin() (*sql.Tx, error) { service := l.module.GetDefaultService() if service == nil { - return nil, errors.New("no default database service available") + return nil, ErrNoDefaultService + } + tx, err := service.Begin() + if err != nil { + return nil, fmt.Errorf("failed to begin transaction: %w", err) } - return service.Begin() + return tx, nil } // Module represents the database module @@ -171,7 +217,13 @@ func (m *Module) RegisterConfig(app modular.Application) error { Connections: make(map[string]ConnectionConfig), } - app.RegisterConfigSection(m.Name(), modular.NewStdConfigProvider(defaultConfig)) + // Create instance-aware config provider with database-specific prefix + instancePrefixFunc := func(instanceKey string) string { + return "DB_" + instanceKey + "_" + } + + configProvider := modular.NewInstanceAwareConfigProvider(defaultConfig, instancePrefixFunc) + app.RegisterConfigSection(m.Name(), configProvider) return nil } diff --git a/modules/eventbus/config.go b/modules/eventbus/config.go index ab415b7f..b8e5083b 100644 --- a/modules/eventbus/config.go +++ b/modules/eventbus/config.go @@ -3,25 +3,25 @@ package eventbus // EventBusConfig defines the configuration for the event bus module type EventBusConfig struct { // Engine specifies the event bus engine to use ("memory", "redis", "kafka", etc.) - Engine string `json:"engine" yaml:"engine" validate:"oneof=memory redis kafka"` + Engine string `json:"engine" yaml:"engine" validate:"oneof=memory redis kafka" env:"ENGINE"` // MaxEventQueueSize is the maximum number of events to queue per topic - MaxEventQueueSize int `json:"maxEventQueueSize" yaml:"maxEventQueueSize" validate:"min=1"` + MaxEventQueueSize int `json:"maxEventQueueSize" yaml:"maxEventQueueSize" validate:"min=1" env:"MAX_EVENT_QUEUE_SIZE"` // DefaultEventBufferSize is the default buffer size for subscription channels - DefaultEventBufferSize int `json:"defaultEventBufferSize" yaml:"defaultEventBufferSize" validate:"min=1"` + DefaultEventBufferSize int `json:"defaultEventBufferSize" yaml:"defaultEventBufferSize" validate:"min=1" env:"DEFAULT_EVENT_BUFFER_SIZE"` // WorkerCount is the number of worker goroutines for async event processing - WorkerCount int `json:"workerCount" yaml:"workerCount" validate:"min=1"` + WorkerCount int `json:"workerCount" yaml:"workerCount" validate:"min=1" env:"WORKER_COUNT"` // EventTTL is the time to live for events in seconds - EventTTL int `json:"eventTTL" yaml:"eventTTL" validate:"min=1"` + EventTTL int `json:"eventTTL" yaml:"eventTTL" validate:"min=1" env:"EVENT_TTL"` // RetentionDays is how many days to retain event history - RetentionDays int `json:"retentionDays" yaml:"retentionDays" validate:"min=1"` + RetentionDays int `json:"retentionDays" yaml:"retentionDays" validate:"min=1" env:"RETENTION_DAYS"` // External broker configuration - ExternalBrokerURL string `json:"externalBrokerURL" yaml:"externalBrokerURL"` - ExternalBrokerUser string `json:"externalBrokerUser" yaml:"externalBrokerUser"` - ExternalBrokerPassword string `json:"externalBrokerPassword" yaml:"externalBrokerPassword"` + ExternalBrokerURL string `json:"externalBrokerURL" yaml:"externalBrokerURL" env:"EXTERNAL_BROKER_URL"` + ExternalBrokerUser string `json:"externalBrokerUser" yaml:"externalBrokerUser" env:"EXTERNAL_BROKER_USER"` + ExternalBrokerPassword string `json:"externalBrokerPassword" yaml:"externalBrokerPassword" env:"EXTERNAL_BROKER_PASSWORD"` } diff --git a/modules/httpclient/config.go b/modules/httpclient/config.go index 21b9b1e9..a309e60a 100644 --- a/modules/httpclient/config.go +++ b/modules/httpclient/config.go @@ -9,50 +9,50 @@ import ( // Config defines the configuration for the HTTP client module. type Config struct { // MaxIdleConns controls the maximum number of idle (keep-alive) connections across all hosts. - MaxIdleConns int `yaml:"max_idle_conns" json:"max_idle_conns"` + MaxIdleConns int `yaml:"max_idle_conns" json:"max_idle_conns" env:"MAX_IDLE_CONNS"` // MaxIdleConnsPerHost controls the maximum idle (keep-alive) connections to keep per-host. - MaxIdleConnsPerHost int `yaml:"max_idle_conns_per_host" json:"max_idle_conns_per_host"` + MaxIdleConnsPerHost int `yaml:"max_idle_conns_per_host" json:"max_idle_conns_per_host" env:"MAX_IDLE_CONNS_PER_HOST"` // IdleConnTimeout is the maximum amount of time an idle connection will remain idle before // closing itself, in seconds. - IdleConnTimeout int `yaml:"idle_conn_timeout" json:"idle_conn_timeout"` + IdleConnTimeout int `yaml:"idle_conn_timeout" json:"idle_conn_timeout" env:"IDLE_CONN_TIMEOUT"` // RequestTimeout is the maximum time for a request to complete, in seconds. - RequestTimeout int `yaml:"request_timeout" json:"request_timeout"` + RequestTimeout int `yaml:"request_timeout" json:"request_timeout" env:"REQUEST_TIMEOUT"` // TLSTimeout is the maximum time waiting for TLS handshake, in seconds. - TLSTimeout int `yaml:"tls_timeout" json:"tls_timeout"` + TLSTimeout int `yaml:"tls_timeout" json:"tls_timeout" env:"TLS_TIMEOUT"` // DisableCompression disables decompressing response bodies. - DisableCompression bool `yaml:"disable_compression" json:"disable_compression"` + DisableCompression bool `yaml:"disable_compression" json:"disable_compression" env:"DISABLE_COMPRESSION"` // DisableKeepAlives disables HTTP keep-alive and will only use connections for a single request. - DisableKeepAlives bool `yaml:"disable_keep_alives" json:"disable_keep_alives"` + DisableKeepAlives bool `yaml:"disable_keep_alives" json:"disable_keep_alives" env:"DISABLE_KEEP_ALIVES"` // Verbose enables detailed logging of HTTP requests and responses. - Verbose bool `yaml:"verbose" json:"verbose"` + Verbose bool `yaml:"verbose" json:"verbose" env:"VERBOSE"` // VerboseOptions configures the behavior when Verbose is enabled. - VerboseOptions *VerboseOptions `yaml:"verbose_options" json:"verbose_options"` + VerboseOptions *VerboseOptions `yaml:"verbose_options" json:"verbose_options" env:"VERBOSE_OPTIONS"` } // VerboseOptions configures the behavior of verbose logging. type VerboseOptions struct { // LogHeaders enables logging of request and response headers. - LogHeaders bool `yaml:"log_headers" json:"log_headers"` + LogHeaders bool `yaml:"log_headers" json:"log_headers" env:"LOG_HEADERS"` // LogBody enables logging of request and response bodies. - LogBody bool `yaml:"log_body" json:"log_body"` + LogBody bool `yaml:"log_body" json:"log_body" env:"LOG_BODY"` // MaxBodyLogSize limits the size of logged request and response bodies. - MaxBodyLogSize int `yaml:"max_body_log_size" json:"max_body_log_size"` + MaxBodyLogSize int `yaml:"max_body_log_size" json:"max_body_log_size" env:"MAX_BODY_LOG_SIZE"` // LogToFile enables logging to files instead of just the logger. - LogToFile bool `yaml:"log_to_file" json:"log_to_file"` + LogToFile bool `yaml:"log_to_file" json:"log_to_file" env:"LOG_TO_FILE"` // LogFilePath is the directory where log files will be written. - LogFilePath string `yaml:"log_file_path" json:"log_file_path"` + LogFilePath string `yaml:"log_file_path" json:"log_file_path" env:"LOG_FILE_PATH"` } // Validate checks the configuration values and sets sensible defaults. diff --git a/modules/httpserver/config.go b/modules/httpserver/config.go index d21d208c..4da443cb 100644 --- a/modules/httpserver/config.go +++ b/modules/httpserver/config.go @@ -12,26 +12,26 @@ const DefaultTimeoutSeconds = 15 // HTTPServerConfig defines the configuration for the HTTP server module. type HTTPServerConfig struct { // Host is the hostname or IP address to bind to. - Host string `yaml:"host" json:"host"` + Host string `yaml:"host" json:"host" env:"HOST"` // Port is the port number to listen on. - Port int `yaml:"port" json:"port"` + Port int `yaml:"port" json:"port" env:"PORT"` // ReadTimeout is the maximum duration for reading the entire request, // including the body, in seconds. - ReadTimeout int `yaml:"read_timeout" json:"read_timeout"` + ReadTimeout int `yaml:"read_timeout" json:"read_timeout" env:"READ_TIMEOUT"` // WriteTimeout is the maximum duration before timing out writes of the response, // in seconds. - WriteTimeout int `yaml:"write_timeout" json:"write_timeout"` + WriteTimeout int `yaml:"write_timeout" json:"write_timeout" env:"WRITE_TIMEOUT"` // IdleTimeout is the maximum amount of time to wait for the next request, // in seconds. - IdleTimeout int `yaml:"idle_timeout" json:"idle_timeout"` + IdleTimeout int `yaml:"idle_timeout" json:"idle_timeout" env:"IDLE_TIMEOUT"` // ShutdownTimeout is the maximum amount of time to wait during graceful // shutdown, in seconds. - ShutdownTimeout int `yaml:"shutdown_timeout" json:"shutdown_timeout"` + ShutdownTimeout int `yaml:"shutdown_timeout" json:"shutdown_timeout" env:"SHUTDOWN_TIMEOUT"` // TLS configuration if HTTPS is enabled TLS *TLSConfig `yaml:"tls" json:"tls"` @@ -40,24 +40,24 @@ type HTTPServerConfig struct { // TLSConfig holds the TLS configuration for HTTPS support type TLSConfig struct { // Enabled indicates if HTTPS should be used instead of HTTP - Enabled bool `yaml:"enabled" json:"enabled"` + Enabled bool `yaml:"enabled" json:"enabled" env:"TLS_ENABLED"` // CertFile is the path to the certificate file - CertFile string `yaml:"cert_file" json:"cert_file"` + CertFile string `yaml:"cert_file" json:"cert_file" env:"TLS_CERT_FILE"` // KeyFile is the path to the private key file - KeyFile string `yaml:"key_file" json:"key_file"` + KeyFile string `yaml:"key_file" json:"key_file" env:"TLS_KEY_FILE"` // UseService indicates whether to use a certificate service instead of files // When true, the module will look for a CertificateService in its dependencies - UseService bool `yaml:"use_service" json:"use_service"` + UseService bool `yaml:"use_service" json:"use_service" env:"TLS_USE_SERVICE"` // AutoGenerate indicates whether to automatically generate self-signed certificates // if no certificate service is provided and file paths are not specified - AutoGenerate bool `yaml:"auto_generate" json:"auto_generate"` + AutoGenerate bool `yaml:"auto_generate" json:"auto_generate" env:"TLS_AUTO_GENERATE"` // Domains is a list of domain names to generate certificates for (when AutoGenerate is true) - Domains []string `yaml:"domains" json:"domains"` + Domains []string `yaml:"domains" json:"domains" env:"TLS_DOMAINS"` } // Validate checks if the configuration is valid and sets default values diff --git a/modules/letsencrypt/config.go b/modules/letsencrypt/config.go index 0c68635b..93a5301a 100644 --- a/modules/letsencrypt/config.go +++ b/modules/letsencrypt/config.go @@ -12,38 +12,38 @@ import ( // LetsEncryptConfig defines the configuration for the Let's Encrypt module. type LetsEncryptConfig struct { // Email is the email address to use for registration with Let's Encrypt - Email string `yaml:"email" json:"email"` + Email string `yaml:"email" json:"email" env:"EMAIL"` // Domains is a list of domain names to obtain certificates for - Domains []string `yaml:"domains" json:"domains"` + Domains []string `yaml:"domains" json:"domains" env:"DOMAINS"` // UseStaging determines whether to use Let's Encrypt's staging environment // Set to true for testing to avoid rate limits - UseStaging bool `yaml:"use_staging" json:"use_staging"` + UseStaging bool `yaml:"use_staging" json:"use_staging" env:"USE_STAGING"` // UseProduction is the opposite of UseStaging, for clarity in configuration - UseProduction bool `yaml:"use_production" json:"use_production"` + UseProduction bool `yaml:"use_production" json:"use_production" env:"USE_PRODUCTION"` // StoragePath is the directory where certificates and account information will be stored - StoragePath string `yaml:"storage_path" json:"storage_path"` + StoragePath string `yaml:"storage_path" json:"storage_path" env:"STORAGE_PATH"` // RenewBefore sets how long before expiry certificates should be renewed (in days) - RenewBefore int `yaml:"renew_before" json:"renew_before"` + RenewBefore int `yaml:"renew_before" json:"renew_before" env:"RENEW_BEFORE"` // RenewBeforeDays is an alias for RenewBefore for backward compatibility - RenewBeforeDays int `yaml:"renew_before_days" json:"renew_before_days"` + RenewBeforeDays int `yaml:"renew_before_days" json:"renew_before_days" env:"RENEW_BEFORE_DAYS"` // AutoRenew enables automatic certificate renewal - AutoRenew bool `yaml:"auto_renew" json:"auto_renew"` + AutoRenew bool `yaml:"auto_renew" json:"auto_renew" env:"AUTO_RENEW"` // UseDNS indicates whether to use DNS challenges instead of HTTP - UseDNS bool `yaml:"use_dns" json:"use_dns"` + UseDNS bool `yaml:"use_dns" json:"use_dns" env:"USE_DNS"` // DNSProvider configuration for DNS challenges DNSProvider *DNSProviderConfig `yaml:"dns_provider,omitempty" json:"dns_provider,omitempty"` // DNSConfig is a map of DNS provider specific configuration parameters - DNSConfig map[string]string `yaml:"dns_config,omitempty" json:"dns_config,omitempty"` + DNSConfig map[string]string `yaml:"dns_config,omitempty" json:"dns_config,omitempty" env:"DNS_CONFIG"` // HTTPProvider configuration for HTTP challenges HTTPProvider *HTTPProviderConfig `yaml:"http_provider,omitempty" json:"http_provider,omitempty"` @@ -58,10 +58,10 @@ type LetsEncryptConfig struct { // DNSProviderConfig defines the configuration for DNS challenge providers type DNSProviderConfig struct { // Provider is the name of the DNS provider (e.g., "cloudflare", "route53", etc.) - Provider string `yaml:"provider" json:"provider"` + Provider string `yaml:"provider" json:"provider" env:"PROVIDER"` // Parameters is a map of provider-specific configuration parameters - Parameters map[string]string `yaml:"parameters" json:"parameters"` + Parameters map[string]string `yaml:"parameters" json:"parameters" env:"PARAMETERS"` // Provider-specific configurations Cloudflare *CloudflareConfig `yaml:"cloudflare,omitempty" json:"cloudflare,omitempty"` @@ -71,31 +71,31 @@ type DNSProviderConfig struct { // CloudflareConfig holds the configuration for Cloudflare DNS API type CloudflareConfig struct { - Email string `yaml:"email" json:"email"` - APIKey string `yaml:"api_key" json:"api_key"` - APIToken string `yaml:"api_token" json:"api_token"` + Email string `yaml:"email" json:"email" env:"EMAIL"` + APIKey string `yaml:"api_key" json:"api_key" env:"API_KEY"` + APIToken string `yaml:"api_token" json:"api_token" env:"API_TOKEN"` } // Route53Config holds the configuration for AWS Route53 DNS API type Route53Config struct { - AccessKeyID string `yaml:"access_key_id" json:"access_key_id"` - SecretAccessKey string `yaml:"secret_access_key" json:"secret_access_key"` - Region string `yaml:"region" json:"region"` - HostedZoneID string `yaml:"hosted_zone_id" json:"hosted_zone_id"` + AccessKeyID string `yaml:"access_key_id" json:"access_key_id" env:"ACCESS_KEY_ID"` + SecretAccessKey string `yaml:"secret_access_key" json:"secret_access_key" env:"SECRET_ACCESS_KEY"` + Region string `yaml:"region" json:"region" env:"REGION"` + HostedZoneID string `yaml:"hosted_zone_id" json:"hosted_zone_id" env:"HOSTED_ZONE_ID"` } // DigitalOceanConfig holds the configuration for DigitalOcean DNS API type DigitalOceanConfig struct { - AuthToken string `yaml:"auth_token" json:"auth_token"` + AuthToken string `yaml:"auth_token" json:"auth_token" env:"AUTH_TOKEN"` } // HTTPProviderConfig defines the configuration for HTTP challenge providers type HTTPProviderConfig struct { // Use the built-in HTTP server for challenges - UseBuiltIn bool `yaml:"use_built_in" json:"use_built_in"` + UseBuiltIn bool `yaml:"use_built_in" json:"use_built_in" env:"USE_BUILT_IN"` // Port to use for the HTTP challenge server (default: 80) - Port int `yaml:"port" json:"port"` + Port int `yaml:"port" json:"port" env:"PORT"` } // Validate checks if the configuration is valid and sets default values diff --git a/modules/reverseproxy/config.go b/modules/reverseproxy/config.go index d342e177..4ee23906 100644 --- a/modules/reverseproxy/config.go +++ b/modules/reverseproxy/config.go @@ -5,27 +5,27 @@ import "time" // ReverseProxyConfig provides configuration options for the ReverseProxyModule. type ReverseProxyConfig struct { - BackendServices map[string]string `json:"backend_services" yaml:"backend_services"` - Routes map[string]string `json:"routes" yaml:"routes"` - DefaultBackend string `json:"default_backend" yaml:"default_backend"` + BackendServices map[string]string `json:"backend_services" yaml:"backend_services" env:"BACKEND_SERVICES"` + Routes map[string]string `json:"routes" yaml:"routes" env:"ROUTES"` + DefaultBackend string `json:"default_backend" yaml:"default_backend" env:"DEFAULT_BACKEND"` CircuitBreakerConfig CircuitBreakerConfig `json:"circuit_breaker" yaml:"circuit_breaker"` BackendCircuitBreakers map[string]CircuitBreakerConfig `json:"backend_circuit_breakers" yaml:"backend_circuit_breakers"` CompositeRoutes map[string]CompositeRoute `json:"composite_routes" yaml:"composite_routes"` - TenantIDHeader string `json:"tenant_id_header" yaml:"tenant_id_header"` - RequireTenantID bool `json:"require_tenant_id" yaml:"require_tenant_id"` - CacheEnabled bool `json:"cache_enabled" yaml:"cache_enabled"` - CacheTTL time.Duration `json:"cache_ttl" yaml:"cache_ttl"` - RequestTimeout time.Duration `json:"request_timeout" yaml:"request_timeout"` - MetricsEnabled bool `json:"metrics_enabled" yaml:"metrics_enabled"` - MetricsPath string `json:"metrics_path" yaml:"metrics_path"` - MetricsEndpoint string `json:"metrics_endpoint" yaml:"metrics_endpoint"` + TenantIDHeader string `json:"tenant_id_header" yaml:"tenant_id_header" env:"TENANT_ID_HEADER"` + RequireTenantID bool `json:"require_tenant_id" yaml:"require_tenant_id" env:"REQUIRE_TENANT_ID"` + CacheEnabled bool `json:"cache_enabled" yaml:"cache_enabled" env:"CACHE_ENABLED"` + CacheTTL time.Duration `json:"cache_ttl" yaml:"cache_ttl" env:"CACHE_TTL"` + RequestTimeout time.Duration `json:"request_timeout" yaml:"request_timeout" env:"REQUEST_TIMEOUT"` + MetricsEnabled bool `json:"metrics_enabled" yaml:"metrics_enabled" env:"METRICS_ENABLED"` + MetricsPath string `json:"metrics_path" yaml:"metrics_path" env:"METRICS_PATH"` + MetricsEndpoint string `json:"metrics_endpoint" yaml:"metrics_endpoint" env:"METRICS_ENDPOINT"` } // CompositeRoute defines a route that combines responses from multiple backends. type CompositeRoute struct { - Pattern string `json:"pattern" yaml:"pattern"` - Backends []string `json:"backends" yaml:"backends"` - Strategy string `json:"strategy" yaml:"strategy"` + Pattern string `json:"pattern" yaml:"pattern" env:"PATTERN"` + Backends []string `json:"backends" yaml:"backends" env:"BACKENDS"` + Strategy string `json:"strategy" yaml:"strategy" env:"STRATEGY"` } // Config provides configuration options for the ReverseProxyModule. @@ -55,13 +55,13 @@ type BackendConfig struct { // CircuitBreakerConfig provides configuration for the circuit breaker. type CircuitBreakerConfig struct { - Enabled bool `json:"enabled" yaml:"enabled"` - FailureThreshold int `json:"failure_threshold" yaml:"failure_threshold"` - SuccessThreshold int `json:"success_threshold" yaml:"success_threshold"` - OpenTimeout time.Duration `json:"open_timeout" yaml:"open_timeout"` - HalfOpenAllowedRequests int `json:"half_open_allowed_requests" yaml:"half_open_allowed_requests"` - WindowSize int `json:"window_size" yaml:"window_size"` - SuccessRateThreshold float64 `json:"success_rate_threshold" yaml:"success_rate_threshold"` + Enabled bool `json:"enabled" yaml:"enabled" env:"ENABLED"` + FailureThreshold int `json:"failure_threshold" yaml:"failure_threshold" env:"FAILURE_THRESHOLD"` + SuccessThreshold int `json:"success_threshold" yaml:"success_threshold" env:"SUCCESS_THRESHOLD"` + OpenTimeout time.Duration `json:"open_timeout" yaml:"open_timeout" env:"OPEN_TIMEOUT"` + HalfOpenAllowedRequests int `json:"half_open_allowed_requests" yaml:"half_open_allowed_requests" env:"HALF_OPEN_ALLOWED_REQUESTS"` + WindowSize int `json:"window_size" yaml:"window_size" env:"WINDOW_SIZE"` + SuccessRateThreshold float64 `json:"success_rate_threshold" yaml:"success_rate_threshold" env:"SUCCESS_RATE_THRESHOLD"` } // RetryConfig provides configuration for the retry policy. diff --git a/modules/scheduler/config.go b/modules/scheduler/config.go index 3e6f1d2e..62b54f5c 100644 --- a/modules/scheduler/config.go +++ b/modules/scheduler/config.go @@ -3,26 +3,26 @@ package scheduler // SchedulerConfig defines the configuration for the scheduler module type SchedulerConfig struct { // WorkerCount is the number of worker goroutines to run - WorkerCount int `json:"workerCount" yaml:"workerCount" validate:"min=1"` + WorkerCount int `json:"workerCount" yaml:"workerCount" validate:"min=1" env:"WORKER_COUNT"` // QueueSize is the maximum number of jobs to queue - QueueSize int `json:"queueSize" yaml:"queueSize" validate:"min=1"` + QueueSize int `json:"queueSize" yaml:"queueSize" validate:"min=1" env:"QUEUE_SIZE"` // ShutdownTimeout is the time in seconds to wait for graceful shutdown - ShutdownTimeout int `json:"shutdownTimeout" yaml:"shutdownTimeout" validate:"min=1"` + ShutdownTimeout int `json:"shutdownTimeout" yaml:"shutdownTimeout" validate:"min=1" env:"SHUTDOWN_TIMEOUT"` // StorageType is the type of job storage to use (memory, file, etc.) - StorageType string `json:"storageType" yaml:"storageType" validate:"oneof=memory file"` + StorageType string `json:"storageType" yaml:"storageType" validate:"oneof=memory file" env:"STORAGE_TYPE"` // CheckInterval is how often to check for scheduled jobs (in seconds) - CheckInterval int `json:"checkInterval" yaml:"checkInterval" validate:"min=1"` + CheckInterval int `json:"checkInterval" yaml:"checkInterval" validate:"min=1" env:"CHECK_INTERVAL"` // RetentionDays is how many days to retain job history - RetentionDays int `json:"retentionDays" yaml:"retentionDays" validate:"min=1"` + RetentionDays int `json:"retentionDays" yaml:"retentionDays" validate:"min=1" env:"RETENTION_DAYS"` // PersistenceFile is the file path for job persistence - PersistenceFile string `json:"persistenceFile" yaml:"persistenceFile"` + PersistenceFile string `json:"persistenceFile" yaml:"persistenceFile" env:"PERSISTENCE_FILE"` // EnablePersistence determines if jobs should be persisted between restarts - EnablePersistence bool `json:"enablePersistence" yaml:"enablePersistence"` + EnablePersistence bool `json:"enablePersistence" yaml:"enablePersistence" env:"ENABLE_PERSISTENCE"` }