initial
This commit is contained in:
1
pkg/config/.gitignore
vendored
Normal file
1
pkg/config/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
303
pkg/config/README.md
Normal file
303
pkg/config/README.md
Normal file
@@ -0,0 +1,303 @@
|
||||
# Configuration Module (Go)
|
||||
|
||||
A simple, clean, and generic configuration management system that supports multiple environments and automatic value resolution. This module is completely standalone and can be used in any Go project.
|
||||
|
||||
## Features
|
||||
|
||||
- **Simple API**: Just `config.Get()` and `config.GetSecret()`
|
||||
- **Type-safe helpers**: `config.GetString()`, `config.GetInt()`, `config.GetBool()`
|
||||
- **Environment Support**: Separate configs for different environments (dev/prod/staging/etc)
|
||||
- **Value Resolution**: Automatic resolution of special values:
|
||||
- `$ENV:VARIABLE` - Read from environment variable
|
||||
- `$GSM:secret-name` - Read from Google Secret Manager
|
||||
- `$ASM:secret-name` - Read from AWS Secrets Manager
|
||||
- `$FILE:/path/to/file` - Read from file contents
|
||||
- **Hierarchical Defaults**: Environment-specific values override defaults
|
||||
- **YAML-based**: Easy to read and edit configuration files
|
||||
- **Thread-safe**: Safe for concurrent use
|
||||
- **Testable**: Uses afero filesystem abstraction for easy testing
|
||||
- **Minimal Dependencies**: Only requires YAML parser and cloud SDKs (optional)
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
go get git.eeqj.de/sneak/webhooker/pkg/config
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"git.eeqj.de/sneak/webhooker/pkg/config"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Set the environment explicitly
|
||||
config.SetEnvironment("prod")
|
||||
|
||||
// Get configuration values
|
||||
baseURL := config.GetString("baseURL")
|
||||
apiTimeout := config.GetInt("timeout", 30)
|
||||
debugMode := config.GetBool("debugMode", false)
|
||||
|
||||
// Get secret values
|
||||
apiKey := config.GetSecretString("api_key")
|
||||
dbPassword := config.GetSecretString("db_password", "default")
|
||||
|
||||
// Get all values (for debugging)
|
||||
allConfig := config.GetAllConfig()
|
||||
allSecrets := config.GetAllSecrets()
|
||||
|
||||
// Reload configuration from file
|
||||
if err := config.Reload(); err != nil {
|
||||
fmt.Printf("Failed to reload config: %v\n", err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration File Structure
|
||||
|
||||
Create a `config.yaml` file in your project root:
|
||||
|
||||
```yaml
|
||||
environments:
|
||||
dev:
|
||||
config:
|
||||
baseURL: https://dev.example.com
|
||||
debugMode: true
|
||||
timeout: 30
|
||||
secrets:
|
||||
api_key: dev-key-12345
|
||||
db_password: $ENV:DEV_DB_PASSWORD
|
||||
|
||||
prod:
|
||||
config:
|
||||
baseURL: https://prod.example.com
|
||||
debugMode: false
|
||||
timeout: 10
|
||||
GCPProject: my-project-123
|
||||
AWSRegion: us-west-2
|
||||
secrets:
|
||||
api_key: $GSM:prod-api-key
|
||||
db_password: $ASM:prod/db/password
|
||||
|
||||
configDefaults:
|
||||
app_name: my-app
|
||||
timeout: 30
|
||||
log_level: INFO
|
||||
port: 8080
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **Environment Selection**: Call `config.SetEnvironment("prod")` to select which environment to use
|
||||
|
||||
2. **Value Lookup**: When you call `config.Get("key")`:
|
||||
- First checks `environments.<env>.config.key`
|
||||
- Falls back to `configDefaults.key`
|
||||
- Returns the default value if not found
|
||||
|
||||
3. **Secret Lookup**: When you call `config.GetSecret("key")`:
|
||||
- Looks in `environments.<env>.secrets.key`
|
||||
- Returns the default value if not found
|
||||
|
||||
4. **Value Resolution**: If a value starts with a special prefix:
|
||||
- `$ENV:` - Reads from environment variable
|
||||
- `$GSM:` - Fetches from Google Secret Manager (requires GCPProject to be set in config)
|
||||
- `$ASM:` - Fetches from AWS Secrets Manager (uses AWSRegion from config or defaults to us-east-1)
|
||||
- `$FILE:` - Reads from file (supports `~` expansion)
|
||||
|
||||
## Type-Safe Access
|
||||
|
||||
The module provides type-safe helper functions:
|
||||
|
||||
```go
|
||||
// String values
|
||||
baseURL := config.GetString("baseURL", "http://localhost")
|
||||
|
||||
// Integer values
|
||||
port := config.GetInt("port", 8080)
|
||||
|
||||
// Boolean values
|
||||
debug := config.GetBool("debug", false)
|
||||
|
||||
// Secret string values
|
||||
apiKey := config.GetSecretString("api_key", "default-key")
|
||||
```
|
||||
|
||||
## Local Development
|
||||
|
||||
For local development, you can:
|
||||
|
||||
1. Use environment variables:
|
||||
```yaml
|
||||
secrets:
|
||||
api_key: $ENV:LOCAL_API_KEY
|
||||
```
|
||||
|
||||
2. Use local files:
|
||||
```yaml
|
||||
secrets:
|
||||
api_key: $FILE:~/.secrets/api-key.txt
|
||||
```
|
||||
|
||||
3. Create a `config.local.yaml` (gitignored) with literal values for testing
|
||||
|
||||
## Cloud Provider Support
|
||||
|
||||
### Google Secret Manager
|
||||
|
||||
To use GSM resolution (`$GSM:` prefix):
|
||||
1. Set `GCPProject` in your config
|
||||
2. Ensure proper authentication (e.g., `GOOGLE_APPLICATION_CREDENTIALS` environment variable)
|
||||
3. The module will automatically initialize the GSM client when needed
|
||||
|
||||
### AWS Secrets Manager
|
||||
|
||||
To use ASM resolution (`$ASM:` prefix):
|
||||
1. Optionally set `AWSRegion` in your config (defaults to us-east-1)
|
||||
2. Ensure proper authentication (e.g., AWS credentials in environment or IAM role)
|
||||
3. The module will automatically initialize the ASM client when needed
|
||||
|
||||
## Advanced Usage
|
||||
|
||||
### Loading from a Specific File
|
||||
|
||||
```go
|
||||
// Load configuration from a specific file
|
||||
if err := config.LoadFile("/path/to/config.yaml"); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
```
|
||||
|
||||
### Checking Configuration Values
|
||||
|
||||
```go
|
||||
// Get all configuration for current environment
|
||||
allConfig := config.GetAllConfig()
|
||||
for key, value := range allConfig {
|
||||
fmt.Printf("%s: %v\n", key, value)
|
||||
}
|
||||
|
||||
// Get all secrets (be careful with logging!)
|
||||
allSecrets := config.GetAllSecrets()
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
The module uses the [afero](https://github.com/spf13/afero) filesystem abstraction, making it easy to test without real files:
|
||||
|
||||
```go
|
||||
package myapp_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"github.com/spf13/afero"
|
||||
"git.eeqj.de/sneak/webhooker/pkg/config"
|
||||
)
|
||||
|
||||
func TestMyApp(t *testing.T) {
|
||||
// Create an in-memory filesystem for testing
|
||||
fs := afero.NewMemMapFs()
|
||||
|
||||
// Write a test config file
|
||||
testConfig := `
|
||||
environments:
|
||||
test:
|
||||
config:
|
||||
apiURL: http://test.example.com
|
||||
secrets:
|
||||
apiKey: test-key-123
|
||||
`
|
||||
afero.WriteFile(fs, "config.yaml", []byte(testConfig), 0644)
|
||||
|
||||
// Use the test filesystem
|
||||
config.SetFs(fs)
|
||||
config.SetEnvironment("test")
|
||||
|
||||
// Now your tests use the in-memory config
|
||||
if url := config.GetString("apiURL"); url != "http://test.example.com" {
|
||||
t.Errorf("Expected test URL, got %s", url)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Unit Testing with Isolated Config
|
||||
|
||||
For unit tests, you can create isolated configuration managers:
|
||||
|
||||
```go
|
||||
func TestMyComponent(t *testing.T) {
|
||||
// Create a test-specific manager
|
||||
manager := config.NewManager()
|
||||
|
||||
// Use in-memory filesystem
|
||||
fs := afero.NewMemMapFs()
|
||||
afero.WriteFile(fs, "config.yaml", []byte(testConfig), 0644)
|
||||
manager.SetFs(fs)
|
||||
|
||||
// Test with isolated configuration
|
||||
manager.SetEnvironment("test")
|
||||
value := manager.Get("someKey", "default")
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
- If a config file is not found when using the default loader, an error is returned
|
||||
- If a key is not found, the default value is returned
|
||||
- If a special value cannot be resolved (e.g., env var not set, file not found), `nil` is returned
|
||||
- Cloud provider errors are logged but return `nil` to allow graceful degradation
|
||||
|
||||
## Thread Safety
|
||||
|
||||
All operations are thread-safe. The module uses read-write mutexes to ensure safe concurrent access to configuration data.
|
||||
|
||||
## Example Integration
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"git.eeqj.de/sneak/webhooker/pkg/config"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Read environment from your app-specific env var
|
||||
environment := os.Getenv("APP_ENV")
|
||||
if environment == "" {
|
||||
environment = "dev"
|
||||
}
|
||||
|
||||
config.SetEnvironment(environment)
|
||||
|
||||
// Now use configuration throughout your app
|
||||
databaseURL := config.GetString("database_url")
|
||||
apiKey := config.GetSecretString("api_key")
|
||||
|
||||
log.Printf("Running in %s environment", environment)
|
||||
log.Printf("Database URL: %s", databaseURL)
|
||||
}
|
||||
```
|
||||
|
||||
## Migration from Python Version
|
||||
|
||||
The Go version maintains API compatibility with the Python version where possible:
|
||||
|
||||
| Python | Go |
|
||||
|--------|-----|
|
||||
| `config.get('key')` | `config.Get("key")` or `config.GetString("key")` |
|
||||
| `config.getSecret('key')` | `config.GetSecret("key")` or `config.GetSecretString("key")` |
|
||||
| `config.set_environment('prod')` | `config.SetEnvironment("prod")` |
|
||||
| `config.reload()` | `config.Reload()` |
|
||||
| `config.get_all_config()` | `config.GetAllConfig()` |
|
||||
| `config.get_all_secrets()` | `config.GetAllSecrets()` |
|
||||
|
||||
## License
|
||||
|
||||
This module is designed to be standalone and can be extracted into its own repository with your preferred license.
|
||||
180
pkg/config/config.go
Normal file
180
pkg/config/config.go
Normal file
@@ -0,0 +1,180 @@
|
||||
// Package config provides a simple, clean, and generic configuration management system
|
||||
// that supports multiple environments and automatic value resolution.
|
||||
//
|
||||
// Features:
|
||||
// - Simple API: Just config.Get() and config.GetSecret()
|
||||
// - Environment Support: Separate configs for different environments (dev/prod/staging/etc)
|
||||
// - Value Resolution: Automatic resolution of special values:
|
||||
// - $ENV:VARIABLE - Read from environment variable
|
||||
// - $GSM:secret-name - Read from Google Secret Manager
|
||||
// - $ASM:secret-name - Read from AWS Secrets Manager
|
||||
// - $FILE:/path/to/file - Read from file contents
|
||||
// - Hierarchical Defaults: Environment-specific values override defaults
|
||||
// - YAML-based: Easy to read and edit configuration files
|
||||
// - Zero Dependencies: Only depends on yaml and cloud provider SDKs (optional)
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// import "git.eeqj.de/sneak/webhooker/pkg/config"
|
||||
//
|
||||
// // Set the environment explicitly
|
||||
// config.SetEnvironment("prod")
|
||||
//
|
||||
// // Get configuration values
|
||||
// baseURL := config.Get("baseURL")
|
||||
// apiTimeout := config.GetInt("timeout", 30)
|
||||
//
|
||||
// // Get secret values
|
||||
// apiKey := config.GetSecret("api_key")
|
||||
// dbPassword := config.GetSecret("db_password", "default")
|
||||
package config
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
// Global configuration manager instance
|
||||
var (
|
||||
globalManager *Manager
|
||||
mu sync.Mutex // Protect global manager updates
|
||||
)
|
||||
|
||||
// getManager returns the global configuration manager, creating it if necessary
|
||||
func getManager() *Manager {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
if globalManager == nil {
|
||||
globalManager = NewManager()
|
||||
}
|
||||
return globalManager
|
||||
}
|
||||
|
||||
// SetEnvironment sets the active environment.
|
||||
func SetEnvironment(environment string) {
|
||||
getManager().SetEnvironment(environment)
|
||||
}
|
||||
|
||||
// SetFs sets the filesystem to use for all file operations.
|
||||
// This is primarily useful for testing with an in-memory filesystem.
|
||||
func SetFs(fs afero.Fs) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
// Create a new manager with the specified filesystem
|
||||
newManager := NewManager()
|
||||
newManager.SetFs(fs)
|
||||
|
||||
// Replace the global manager
|
||||
globalManager = newManager
|
||||
}
|
||||
|
||||
// Get retrieves a configuration value.
|
||||
//
|
||||
// This looks for values in the following order:
|
||||
// 1. Environment-specific config (environments.<env>.config.<key>)
|
||||
// 2. Config defaults (configDefaults.<key>)
|
||||
//
|
||||
// Values are resolved if they contain special prefixes:
|
||||
// - $ENV:VARIABLE_NAME - reads from environment variable
|
||||
// - $GSM:secret-name - reads from Google Secret Manager
|
||||
// - $ASM:secret-name - reads from AWS Secrets Manager
|
||||
// - $FILE:/path/to/file - reads from file
|
||||
func Get(key string, defaultValue ...interface{}) interface{} {
|
||||
var def interface{}
|
||||
if len(defaultValue) > 0 {
|
||||
def = defaultValue[0]
|
||||
}
|
||||
return getManager().Get(key, def)
|
||||
}
|
||||
|
||||
// GetString retrieves a configuration value as a string.
|
||||
func GetString(key string, defaultValue ...string) string {
|
||||
var def string
|
||||
if len(defaultValue) > 0 {
|
||||
def = defaultValue[0]
|
||||
}
|
||||
val := Get(key, def)
|
||||
if s, ok := val.(string); ok {
|
||||
return s
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// GetInt retrieves a configuration value as an integer.
|
||||
func GetInt(key string, defaultValue ...int) int {
|
||||
var def int
|
||||
if len(defaultValue) > 0 {
|
||||
def = defaultValue[0]
|
||||
}
|
||||
val := Get(key, def)
|
||||
switch v := val.(type) {
|
||||
case int:
|
||||
return v
|
||||
case int64:
|
||||
return int(v)
|
||||
case float64:
|
||||
return int(v)
|
||||
default:
|
||||
return def
|
||||
}
|
||||
}
|
||||
|
||||
// GetBool retrieves a configuration value as a boolean.
|
||||
func GetBool(key string, defaultValue ...bool) bool {
|
||||
var def bool
|
||||
if len(defaultValue) > 0 {
|
||||
def = defaultValue[0]
|
||||
}
|
||||
val := Get(key, def)
|
||||
if b, ok := val.(bool); ok {
|
||||
return b
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// GetSecret retrieves a secret value.
|
||||
//
|
||||
// This looks for secrets defined in environments.<env>.secrets.<key>
|
||||
func GetSecret(key string, defaultValue ...interface{}) interface{} {
|
||||
var def interface{}
|
||||
if len(defaultValue) > 0 {
|
||||
def = defaultValue[0]
|
||||
}
|
||||
return getManager().GetSecret(key, def)
|
||||
}
|
||||
|
||||
// GetSecretString retrieves a secret value as a string.
|
||||
func GetSecretString(key string, defaultValue ...string) string {
|
||||
var def string
|
||||
if len(defaultValue) > 0 {
|
||||
def = defaultValue[0]
|
||||
}
|
||||
val := GetSecret(key, def)
|
||||
if s, ok := val.(string); ok {
|
||||
return s
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// Reload reloads the configuration from file.
|
||||
func Reload() error {
|
||||
return getManager().Reload()
|
||||
}
|
||||
|
||||
// GetAllConfig returns all configuration values for the current environment.
|
||||
func GetAllConfig() map[string]interface{} {
|
||||
return getManager().GetAllConfig()
|
||||
}
|
||||
|
||||
// GetAllSecrets returns all secrets for the current environment.
|
||||
func GetAllSecrets() map[string]interface{} {
|
||||
return getManager().GetAllSecrets()
|
||||
}
|
||||
|
||||
// LoadFile loads configuration from a specific file.
|
||||
func LoadFile(configFile string) error {
|
||||
return getManager().LoadFile(configFile)
|
||||
}
|
||||
306
pkg/config/config_test.go
Normal file
306
pkg/config/config_test.go
Normal file
@@ -0,0 +1,306 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
func TestNewManager(t *testing.T) {
|
||||
manager := NewManager()
|
||||
if manager == nil {
|
||||
t.Fatal("NewManager returned nil")
|
||||
}
|
||||
if manager.config == nil {
|
||||
t.Error("Manager config map is nil")
|
||||
}
|
||||
if manager.loader == nil {
|
||||
t.Error("Manager loader is nil")
|
||||
}
|
||||
if manager.resolvedCache == nil {
|
||||
t.Error("Manager resolvedCache is nil")
|
||||
}
|
||||
if manager.fs == nil {
|
||||
t.Error("Manager fs is nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoader_FindConfigFile(t *testing.T) {
|
||||
// Create an in-memory filesystem for testing
|
||||
fs := afero.NewMemMapFs()
|
||||
loader := NewLoader(fs)
|
||||
|
||||
// Create a config file in the filesystem
|
||||
configContent := `
|
||||
environments:
|
||||
test:
|
||||
config:
|
||||
testKey: testValue
|
||||
secrets:
|
||||
testSecret: secretValue
|
||||
configDefaults:
|
||||
defaultKey: defaultValue
|
||||
`
|
||||
// Create the file in the current directory
|
||||
if err := afero.WriteFile(fs, "config.yaml", []byte(configContent), 0644); err != nil {
|
||||
t.Fatalf("Failed to write test config: %v", err)
|
||||
}
|
||||
|
||||
// Test finding the config file
|
||||
foundPath, err := loader.FindConfigFile("config.yaml")
|
||||
if err != nil {
|
||||
t.Errorf("FindConfigFile failed: %v", err)
|
||||
}
|
||||
|
||||
// In memory fs, the path should be exactly what we created
|
||||
if foundPath != "config.yaml" {
|
||||
t.Errorf("Expected config.yaml, got %s", foundPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoader_LoadYAML(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
loader := NewLoader(fs)
|
||||
|
||||
// Create a test config file
|
||||
testConfig := `
|
||||
environments:
|
||||
test:
|
||||
config:
|
||||
testKey: testValue
|
||||
configDefaults:
|
||||
defaultKey: defaultValue
|
||||
`
|
||||
if err := afero.WriteFile(fs, "test-config.yaml", []byte(testConfig), 0644); err != nil {
|
||||
t.Fatalf("Failed to write test config: %v", err)
|
||||
}
|
||||
|
||||
// Load the YAML
|
||||
config, err := loader.LoadYAML("test-config.yaml")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadYAML failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify the structure
|
||||
envs, ok := config["environments"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("environments not found or wrong type")
|
||||
}
|
||||
|
||||
testEnv, ok := envs["test"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("test environment not found")
|
||||
}
|
||||
|
||||
testConfig2, ok := testEnv["config"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("test config not found")
|
||||
}
|
||||
|
||||
if testConfig2["testKey"] != "testValue" {
|
||||
t.Errorf("Expected testKey=testValue, got %v", testConfig2["testKey"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolver_ResolveEnv(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
resolver := NewResolver("", "", fs)
|
||||
|
||||
// Set a test environment variable
|
||||
os.Setenv("TEST_CONFIG_VAR", "test-value")
|
||||
defer os.Unsetenv("TEST_CONFIG_VAR")
|
||||
|
||||
// Test resolving environment variable
|
||||
result := resolver.Resolve("$ENV:TEST_CONFIG_VAR")
|
||||
if result != "test-value" {
|
||||
t.Errorf("Expected 'test-value', got %v", result)
|
||||
}
|
||||
|
||||
// Test non-existent env var
|
||||
result = resolver.Resolve("$ENV:NON_EXISTENT_VAR")
|
||||
if result != nil {
|
||||
t.Errorf("Expected nil for non-existent env var, got %v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolver_ResolveFile(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
resolver := NewResolver("", "", fs)
|
||||
|
||||
// Create a test file
|
||||
secretContent := "my-secret-value"
|
||||
if err := afero.WriteFile(fs, "/test-secret.txt", []byte(secretContent+"\n"), 0644); err != nil {
|
||||
t.Fatalf("Failed to write test file: %v", err)
|
||||
}
|
||||
|
||||
// Test resolving file
|
||||
result := resolver.Resolve("$FILE:/test-secret.txt")
|
||||
if result != secretContent {
|
||||
t.Errorf("Expected '%s', got %v", secretContent, result)
|
||||
}
|
||||
|
||||
// Test non-existent file
|
||||
result = resolver.Resolve("$FILE:/non/existent/file")
|
||||
if result != nil {
|
||||
t.Errorf("Expected nil for non-existent file, got %v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManager_GetAndSet(t *testing.T) {
|
||||
// Create an in-memory filesystem
|
||||
fs := afero.NewMemMapFs()
|
||||
|
||||
// Create a test config file
|
||||
testConfig := `
|
||||
environments:
|
||||
dev:
|
||||
config:
|
||||
apiURL: http://dev.example.com
|
||||
timeout: 30
|
||||
debug: true
|
||||
secrets:
|
||||
apiKey: dev-key-123
|
||||
prod:
|
||||
config:
|
||||
apiURL: https://prod.example.com
|
||||
timeout: 10
|
||||
debug: false
|
||||
secrets:
|
||||
apiKey: $ENV:PROD_API_KEY
|
||||
configDefaults:
|
||||
appName: TestApp
|
||||
timeout: 20
|
||||
port: 8080
|
||||
`
|
||||
if err := afero.WriteFile(fs, "config.yaml", []byte(testConfig), 0644); err != nil {
|
||||
t.Fatalf("Failed to write test config: %v", err)
|
||||
}
|
||||
|
||||
// Create manager and set the filesystem
|
||||
manager := NewManager()
|
||||
manager.SetFs(fs)
|
||||
|
||||
// Load config should find the file automatically
|
||||
manager.SetEnvironment("dev")
|
||||
|
||||
// Test getting config values
|
||||
if v := manager.Get("apiURL", ""); v != "http://dev.example.com" {
|
||||
t.Errorf("Expected dev apiURL, got %v", v)
|
||||
}
|
||||
|
||||
if v := manager.Get("timeout", 0); v != 30 {
|
||||
t.Errorf("Expected timeout=30, got %v", v)
|
||||
}
|
||||
|
||||
if v := manager.Get("debug", false); v != true {
|
||||
t.Errorf("Expected debug=true, got %v", v)
|
||||
}
|
||||
|
||||
// Test default values
|
||||
if v := manager.Get("appName", ""); v != "TestApp" {
|
||||
t.Errorf("Expected appName from defaults, got %v", v)
|
||||
}
|
||||
|
||||
// Test getting secrets
|
||||
if v := manager.GetSecret("apiKey", ""); v != "dev-key-123" {
|
||||
t.Errorf("Expected dev apiKey, got %v", v)
|
||||
}
|
||||
|
||||
// Switch to prod environment
|
||||
manager.SetEnvironment("prod")
|
||||
|
||||
if v := manager.Get("apiURL", ""); v != "https://prod.example.com" {
|
||||
t.Errorf("Expected prod apiURL, got %v", v)
|
||||
}
|
||||
|
||||
// Test environment variable resolution in secrets
|
||||
os.Setenv("PROD_API_KEY", "prod-key-456")
|
||||
defer os.Unsetenv("PROD_API_KEY")
|
||||
|
||||
if v := manager.GetSecret("apiKey", ""); v != "prod-key-456" {
|
||||
t.Errorf("Expected resolved env var for apiKey, got %v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGlobalAPI(t *testing.T) {
|
||||
// Create an in-memory filesystem
|
||||
fs := afero.NewMemMapFs()
|
||||
|
||||
// Create a test config file
|
||||
testConfig := `
|
||||
environments:
|
||||
test:
|
||||
config:
|
||||
stringVal: hello
|
||||
intVal: 42
|
||||
boolVal: true
|
||||
secrets:
|
||||
secret1: test-secret
|
||||
configDefaults:
|
||||
defaultString: world
|
||||
`
|
||||
if err := afero.WriteFile(fs, "config.yaml", []byte(testConfig), 0644); err != nil {
|
||||
t.Fatalf("Failed to write test config: %v", err)
|
||||
}
|
||||
|
||||
// Use the global API with the test filesystem
|
||||
SetFs(fs)
|
||||
SetEnvironment("test")
|
||||
|
||||
// Test type-safe getters
|
||||
if v := GetString("stringVal"); v != "hello" {
|
||||
t.Errorf("Expected 'hello', got %v", v)
|
||||
}
|
||||
|
||||
if v := GetInt("intVal"); v != 42 {
|
||||
t.Errorf("Expected 42, got %v", v)
|
||||
}
|
||||
|
||||
if v := GetBool("boolVal"); v != true {
|
||||
t.Errorf("Expected true, got %v", v)
|
||||
}
|
||||
|
||||
if v := GetSecretString("secret1"); v != "test-secret" {
|
||||
t.Errorf("Expected 'test-secret', got %v", v)
|
||||
}
|
||||
|
||||
// Test defaults
|
||||
if v := GetString("defaultString"); v != "world" {
|
||||
t.Errorf("Expected 'world', got %v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManager_SetFs(t *testing.T) {
|
||||
// Create manager with default OS filesystem
|
||||
manager := NewManager()
|
||||
|
||||
// Create an in-memory filesystem
|
||||
memFs := afero.NewMemMapFs()
|
||||
|
||||
// Write a config file to the memory fs
|
||||
testConfig := `
|
||||
environments:
|
||||
test:
|
||||
config:
|
||||
testKey: fromMemory
|
||||
configDefaults:
|
||||
defaultKey: memoryDefault
|
||||
`
|
||||
if err := afero.WriteFile(memFs, "config.yaml", []byte(testConfig), 0644); err != nil {
|
||||
t.Fatalf("Failed to write test config: %v", err)
|
||||
}
|
||||
|
||||
// Set the filesystem
|
||||
manager.SetFs(memFs)
|
||||
manager.SetEnvironment("test")
|
||||
|
||||
// Test that it reads from the memory filesystem
|
||||
if v := manager.Get("testKey", ""); v != "fromMemory" {
|
||||
t.Errorf("Expected 'fromMemory', got %v", v)
|
||||
}
|
||||
|
||||
if v := manager.Get("defaultKey", ""); v != "memoryDefault" {
|
||||
t.Errorf("Expected 'memoryDefault', got %v", v)
|
||||
}
|
||||
}
|
||||
146
pkg/config/example_afero_test.go
Normal file
146
pkg/config/example_afero_test.go
Normal file
@@ -0,0 +1,146 @@
|
||||
package config_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"git.eeqj.de/sneak/webhooker/pkg/config"
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
// ExampleSetFs demonstrates how to use an in-memory filesystem for testing
|
||||
func ExampleSetFs() {
|
||||
// Create an in-memory filesystem
|
||||
fs := afero.NewMemMapFs()
|
||||
|
||||
// Create a test configuration file
|
||||
configYAML := `
|
||||
environments:
|
||||
test:
|
||||
config:
|
||||
baseURL: https://test.example.com
|
||||
debugMode: true
|
||||
secrets:
|
||||
apiKey: test-key-12345
|
||||
production:
|
||||
config:
|
||||
baseURL: https://api.example.com
|
||||
debugMode: false
|
||||
configDefaults:
|
||||
appName: Test Application
|
||||
timeout: 30
|
||||
`
|
||||
|
||||
// Write the config to the in-memory filesystem
|
||||
if err := afero.WriteFile(fs, "config.yaml", []byte(configYAML), 0644); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Use the in-memory filesystem
|
||||
config.SetFs(fs)
|
||||
config.SetEnvironment("test")
|
||||
|
||||
// Now all config operations use the in-memory filesystem
|
||||
fmt.Printf("Base URL: %s\n", config.GetString("baseURL"))
|
||||
fmt.Printf("Debug Mode: %v\n", config.GetBool("debugMode"))
|
||||
fmt.Printf("App Name: %s\n", config.GetString("appName"))
|
||||
|
||||
// Output:
|
||||
// Base URL: https://test.example.com
|
||||
// Debug Mode: true
|
||||
// App Name: Test Application
|
||||
}
|
||||
|
||||
// TestWithAferoFilesystem shows how to test with different filesystem implementations
|
||||
func TestWithAferoFilesystem(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setupFs func() afero.Fs
|
||||
environment string
|
||||
key string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "in-memory filesystem",
|
||||
setupFs: func() afero.Fs {
|
||||
fs := afero.NewMemMapFs()
|
||||
config := `
|
||||
environments:
|
||||
dev:
|
||||
config:
|
||||
apiURL: http://localhost:8080
|
||||
`
|
||||
afero.WriteFile(fs, "config.yaml", []byte(config), 0644)
|
||||
return fs
|
||||
},
|
||||
environment: "dev",
|
||||
key: "apiURL",
|
||||
expected: "http://localhost:8080",
|
||||
},
|
||||
{
|
||||
name: "readonly filesystem",
|
||||
setupFs: func() afero.Fs {
|
||||
memFs := afero.NewMemMapFs()
|
||||
config := `
|
||||
environments:
|
||||
staging:
|
||||
config:
|
||||
apiURL: https://staging.example.com
|
||||
`
|
||||
afero.WriteFile(memFs, "config.yaml", []byte(config), 0644)
|
||||
// Wrap in a read-only filesystem
|
||||
return afero.NewReadOnlyFs(memFs)
|
||||
},
|
||||
environment: "staging",
|
||||
key: "apiURL",
|
||||
expected: "https://staging.example.com",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Create a new manager for each test to ensure isolation
|
||||
manager := config.NewManager()
|
||||
manager.SetFs(tt.setupFs())
|
||||
manager.SetEnvironment(tt.environment)
|
||||
|
||||
result := manager.Get(tt.key, "")
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected %s, got %v", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileResolution shows how $FILE: resolution works with afero
|
||||
func TestFileResolution(t *testing.T) {
|
||||
// Create an in-memory filesystem
|
||||
fs := afero.NewMemMapFs()
|
||||
|
||||
// Create a secret file
|
||||
secretContent := "super-secret-api-key"
|
||||
if err := afero.WriteFile(fs, "/secrets/api-key.txt", []byte(secretContent), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create a config that references the file
|
||||
configYAML := `
|
||||
environments:
|
||||
prod:
|
||||
secrets:
|
||||
apiKey: $FILE:/secrets/api-key.txt
|
||||
`
|
||||
if err := afero.WriteFile(fs, "config.yaml", []byte(configYAML), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Use the filesystem
|
||||
config.SetFs(fs)
|
||||
config.SetEnvironment("prod")
|
||||
|
||||
// Get the secret - it should resolve from the file
|
||||
apiKey := config.GetSecretString("apiKey")
|
||||
if apiKey != secretContent {
|
||||
t.Errorf("Expected %s, got %s", secretContent, apiKey)
|
||||
}
|
||||
}
|
||||
139
pkg/config/example_test.go
Normal file
139
pkg/config/example_test.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package config_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"git.eeqj.de/sneak/webhooker/pkg/config"
|
||||
)
|
||||
|
||||
func Example() {
|
||||
// Set the environment explicitly
|
||||
config.SetEnvironment("dev")
|
||||
|
||||
// Get configuration values
|
||||
baseURL := config.GetString("baseURL")
|
||||
timeout := config.GetInt("timeout", 30)
|
||||
debugMode := config.GetBool("debugMode", false)
|
||||
|
||||
fmt.Printf("Base URL: %s\n", baseURL)
|
||||
fmt.Printf("Timeout: %d\n", timeout)
|
||||
fmt.Printf("Debug Mode: %v\n", debugMode)
|
||||
|
||||
// Get secret values
|
||||
apiKey := config.GetSecretString("api_key")
|
||||
if apiKey != "" {
|
||||
fmt.Printf("API Key: %s...\n", apiKey[:8])
|
||||
}
|
||||
}
|
||||
|
||||
func ExampleSetEnvironment() {
|
||||
// Your application determines which environment to use
|
||||
// This could come from command line args, env vars, etc.
|
||||
environment := os.Getenv("APP_ENV")
|
||||
if environment == "" {
|
||||
environment = "development"
|
||||
}
|
||||
|
||||
// Set the environment explicitly
|
||||
config.SetEnvironment(environment)
|
||||
|
||||
// Now use configuration throughout your application
|
||||
fmt.Printf("Environment: %s\n", environment)
|
||||
fmt.Printf("App Name: %s\n", config.GetString("app_name"))
|
||||
}
|
||||
|
||||
func ExampleGetString() {
|
||||
config.SetEnvironment("prod")
|
||||
|
||||
// Get a string configuration value with a default
|
||||
baseURL := config.GetString("baseURL", "http://localhost:8080")
|
||||
fmt.Printf("Base URL: %s\n", baseURL)
|
||||
}
|
||||
|
||||
func ExampleGetInt() {
|
||||
config.SetEnvironment("prod")
|
||||
|
||||
// Get an integer configuration value with a default
|
||||
port := config.GetInt("port", 8080)
|
||||
fmt.Printf("Port: %d\n", port)
|
||||
}
|
||||
|
||||
func ExampleGetBool() {
|
||||
config.SetEnvironment("dev")
|
||||
|
||||
// Get a boolean configuration value with a default
|
||||
debugMode := config.GetBool("debugMode", false)
|
||||
fmt.Printf("Debug Mode: %v\n", debugMode)
|
||||
}
|
||||
|
||||
func ExampleGetSecretString() {
|
||||
config.SetEnvironment("prod")
|
||||
|
||||
// Get a secret string value
|
||||
apiKey := config.GetSecretString("api_key")
|
||||
if apiKey != "" {
|
||||
// Be careful not to log the full secret!
|
||||
fmt.Printf("API Key configured: yes\n")
|
||||
}
|
||||
}
|
||||
|
||||
func ExampleLoadFile() {
|
||||
// Load configuration from a specific file
|
||||
if err := config.LoadFile("/path/to/config.yaml"); err != nil {
|
||||
log.Printf("Failed to load config: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
config.SetEnvironment("staging")
|
||||
fmt.Printf("Loaded configuration from custom file\n")
|
||||
}
|
||||
|
||||
func ExampleReload() {
|
||||
config.SetEnvironment("dev")
|
||||
|
||||
// Get initial value
|
||||
oldValue := config.GetString("some_key")
|
||||
|
||||
// ... config file might have been updated ...
|
||||
|
||||
// Reload configuration from file
|
||||
if err := config.Reload(); err != nil {
|
||||
log.Printf("Failed to reload config: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Get potentially updated value
|
||||
newValue := config.GetString("some_key")
|
||||
fmt.Printf("Value changed: %v\n", oldValue != newValue)
|
||||
}
|
||||
|
||||
// Example config.yaml structure:
|
||||
/*
|
||||
environments:
|
||||
development:
|
||||
config:
|
||||
baseURL: http://localhost:8000
|
||||
debugMode: true
|
||||
port: 8000
|
||||
secrets:
|
||||
api_key: dev-key-12345
|
||||
|
||||
production:
|
||||
config:
|
||||
baseURL: https://api.example.com
|
||||
debugMode: false
|
||||
port: 443
|
||||
GCPProject: my-project-123
|
||||
AWSRegion: us-west-2
|
||||
secrets:
|
||||
api_key: $GSM:prod-api-key
|
||||
db_password: $ASM:prod/db/password
|
||||
|
||||
configDefaults:
|
||||
app_name: My Application
|
||||
timeout: 30
|
||||
log_level: INFO
|
||||
port: 8080
|
||||
*/
|
||||
41
pkg/config/go.mod
Normal file
41
pkg/config/go.mod
Normal file
@@ -0,0 +1,41 @@
|
||||
module git.eeqj.de/sneak/webhooker/pkg/config
|
||||
|
||||
go 1.23.0
|
||||
|
||||
toolchain go1.24.1
|
||||
|
||||
require (
|
||||
github.com/aws/aws-sdk-go v1.50.0
|
||||
github.com/spf13/afero v1.14.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
cloud.google.com/go/compute v1.23.1 // indirect
|
||||
cloud.google.com/go/compute/metadata v0.2.3 // indirect
|
||||
cloud.google.com/go/iam v1.1.3 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||
github.com/golang/protobuf v1.5.3 // indirect
|
||||
github.com/google/s2a-go v0.1.7 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.12.0 // indirect
|
||||
go.opencensus.io v0.24.0 // indirect
|
||||
golang.org/x/crypto v0.14.0 // indirect
|
||||
golang.org/x/net v0.17.0 // indirect
|
||||
golang.org/x/oauth2 v0.13.0 // indirect
|
||||
golang.org/x/sync v0.12.0 // indirect
|
||||
golang.org/x/sys v0.13.0 // indirect
|
||||
golang.org/x/text v0.23.0 // indirect
|
||||
google.golang.org/api v0.149.0 // indirect
|
||||
google.golang.org/appengine v1.6.7 // indirect
|
||||
google.golang.org/genproto v0.0.0-20231016165738-49dd2c1f3d0b // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20231016165738-49dd2c1f3d0b // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20231016165738-49dd2c1f3d0b // indirect
|
||||
google.golang.org/grpc v1.59.0 // indirect
|
||||
google.golang.org/protobuf v1.31.0 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
cloud.google.com/go/secretmanager v1.11.4
|
||||
github.com/jmespath/go-jmespath v0.4.0 // indirect
|
||||
)
|
||||
161
pkg/config/go.sum
Normal file
161
pkg/config/go.sum
Normal file
@@ -0,0 +1,161 @@
|
||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
cloud.google.com/go v0.110.8 h1:tyNdfIxjzaWctIiLYOTalaLKZ17SI44SKFW26QbOhME=
|
||||
cloud.google.com/go v0.110.8/go.mod h1:Iz8AkXJf1qmxC3Oxoep8R1T36w8B92yU29PcBhHO5fk=
|
||||
cloud.google.com/go/compute v1.23.1 h1:V97tBoDaZHb6leicZ1G6DLK2BAaZLJ/7+9BB/En3hR0=
|
||||
cloud.google.com/go/compute v1.23.1/go.mod h1:CqB3xpmPKKt3OJpW2ndFIXnA9A4xAy/F3Xp1ixncW78=
|
||||
cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY=
|
||||
cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA=
|
||||
cloud.google.com/go/iam v1.1.3 h1:18tKG7DzydKWUnLjonWcJO6wjSCAtzh4GcRKlH/Hrzc=
|
||||
cloud.google.com/go/iam v1.1.3/go.mod h1:3khUlaBXfPKKe7huYgEpDn6FtgRyMEqbkvBxrQyY5SE=
|
||||
cloud.google.com/go/secretmanager v1.11.4 h1:krnX9qpG2kR2fJ+u+uNyNo+ACVhplIAS4Pu7u+4gd+k=
|
||||
cloud.google.com/go/secretmanager v1.11.4/go.mod h1:wreJlbS9Zdq21lMzWmJ0XhWW2ZxgPeahsqeV/vZoJ3w=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/aws/aws-sdk-go v1.50.0 h1:HBtrLeO+QyDKnc3t1+5DR1RxodOHCGr8ZcrHudpv7jI=
|
||||
github.com/aws/aws-sdk-go v1.50.0/go.mod h1:LF8svs817+Nz+DmiMQKTO3ubZ/6IaTpq3TjupRn3Eqk=
|
||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
||||
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
|
||||
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
|
||||
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o=
|
||||
github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw=
|
||||
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfFxPRy3Bf7vr3h0cechB90XaQs=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0=
|
||||
github.com/googleapis/gax-go/v2 v2.12.0 h1:A+gCJKdRfqXkr+BIRGtZLibNXf0m1f9E4HG56etFpas=
|
||||
github.com/googleapis/gax-go/v2 v2.12.0/go.mod h1:y+aIqrI5eb1YGMVJfuV3185Ts/D7qKpsEkdD5+I6QGU=
|
||||
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
|
||||
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
|
||||
github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
|
||||
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA=
|
||||
github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo=
|
||||
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/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=
|
||||
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
|
||||
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc=
|
||||
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
|
||||
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.13.0 h1:jDDenyj+WgFtmV3zYVoi8aE2BwtXFLWOA67ZfNWftiY=
|
||||
golang.org/x/oauth2 v0.13.0/go.mod h1:/JMhi4ZRXAf4HG9LiNmxvk+45+96RUlVThiH8FzNBn0=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
|
||||
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
|
||||
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/api v0.149.0 h1:b2CqT6kG+zqJIVKRQ3ELJVLN1PwHZ6DJ3dW8yl82rgY=
|
||||
google.golang.org/api v0.149.0/go.mod h1:Mwn1B7JTXrzXtnvmzQE2BD6bYZQ8DShKZDZbeN9I7qI=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
|
||||
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
|
||||
google.golang.org/genproto v0.0.0-20231016165738-49dd2c1f3d0b h1:+YaDE2r2OG8t/z5qmsh7Y+XXwCbvadxxZ0YY6mTdrVA=
|
||||
google.golang.org/genproto v0.0.0-20231016165738-49dd2c1f3d0b/go.mod h1:CgAqfJo+Xmu0GwA0411Ht3OU3OntXwsGmrmjI8ioGXI=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20231016165738-49dd2c1f3d0b h1:CIC2YMXmIhYw6evmhPxBKJ4fmLbOFtXQN/GV3XOZR8k=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20231016165738-49dd2c1f3d0b/go.mod h1:IBQ646DjkDkvUIsVq/cc03FUFQ9wbZu7yE396YcL870=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20231016165738-49dd2c1f3d0b h1:ZlWIi1wSK56/8hn4QcBp/j9M7Gt3U/3hZw3mC7vDICo=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20231016165738-49dd2c1f3d0b/go.mod h1:swOH3j0KzcDDgGUWr+SNpyTen5YrXjS3eyPzFYKc6lc=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
||||
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
|
||||
google.golang.org/grpc v1.59.0 h1:Z5Iec2pjwb+LEOqzpB2MR12/eKFhDPhuqW91O+4bwUk=
|
||||
google.golang.org/grpc v1.59.0/go.mod h1:aUPDwccQo6OTjy7Hct4AfBPD1GptF4fyUjIkQ9YtF98=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
|
||||
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
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=
|
||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
104
pkg/config/loader.go
Normal file
104
pkg/config/loader.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Loader handles loading configuration from YAML files.
|
||||
type Loader struct {
|
||||
fs afero.Fs
|
||||
}
|
||||
|
||||
// NewLoader creates a new configuration loader.
|
||||
func NewLoader(fs afero.Fs) *Loader {
|
||||
return &Loader{
|
||||
fs: fs,
|
||||
}
|
||||
}
|
||||
|
||||
// FindConfigFile searches for a configuration file by looking up the directory tree.
|
||||
func (l *Loader) FindConfigFile(filename string) (string, error) {
|
||||
if filename == "" {
|
||||
filename = "config.yaml"
|
||||
}
|
||||
|
||||
// First check if the file exists in the current directory (simple case)
|
||||
if _, err := l.fs.Stat(filename); err == nil {
|
||||
return filename, nil
|
||||
}
|
||||
|
||||
// For more complex cases, try to walk up the directory tree
|
||||
// Start from current directory or root for in-memory filesystems
|
||||
currentDir := "."
|
||||
|
||||
// Try to get the absolute path, but if it fails (e.g., in-memory fs),
|
||||
// just use the current directory
|
||||
if absPath, err := filepath.Abs("."); err == nil {
|
||||
currentDir = absPath
|
||||
}
|
||||
|
||||
// Search up the directory tree
|
||||
for {
|
||||
configPath := filepath.Join(currentDir, filename)
|
||||
if _, err := l.fs.Stat(configPath); err == nil {
|
||||
return configPath, nil
|
||||
}
|
||||
|
||||
// Move up one directory
|
||||
parentDir := filepath.Dir(currentDir)
|
||||
if parentDir == currentDir || currentDir == "." || currentDir == "/" {
|
||||
// Reached the root directory or can't go up further
|
||||
break
|
||||
}
|
||||
currentDir = parentDir
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("configuration file %s not found in directory tree", filename)
|
||||
}
|
||||
|
||||
// LoadYAML loads a YAML file and returns the parsed configuration.
|
||||
func (l *Loader) LoadYAML(filePath string) (map[string]interface{}, error) {
|
||||
data, err := afero.ReadFile(l.fs, filePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read file %s: %w", filePath, err)
|
||||
}
|
||||
|
||||
var config map[string]interface{}
|
||||
if err := yaml.Unmarshal(data, &config); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse YAML from %s: %w", filePath, err)
|
||||
}
|
||||
|
||||
if config == nil {
|
||||
config = make(map[string]interface{})
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// MergeConfigs performs a deep merge of two configuration maps.
|
||||
// The override map values take precedence over the base map.
|
||||
func (l *Loader) MergeConfigs(base, override map[string]interface{}) map[string]interface{} {
|
||||
if base == nil {
|
||||
base = make(map[string]interface{})
|
||||
}
|
||||
|
||||
for key, value := range override {
|
||||
if baseValue, exists := base[key]; exists {
|
||||
// If both values are maps, merge them recursively
|
||||
if baseMap, baseOk := baseValue.(map[string]interface{}); baseOk {
|
||||
if overrideMap, overrideOk := value.(map[string]interface{}); overrideOk {
|
||||
base[key] = l.MergeConfigs(baseMap, overrideMap)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
// Otherwise, override the value
|
||||
base[key] = value
|
||||
}
|
||||
|
||||
return base
|
||||
}
|
||||
373
pkg/config/manager.go
Normal file
373
pkg/config/manager.go
Normal file
@@ -0,0 +1,373 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
// Manager manages application configuration with value resolution.
|
||||
type Manager struct {
|
||||
mu sync.RWMutex
|
||||
config map[string]interface{}
|
||||
environment string
|
||||
resolver *Resolver
|
||||
loader *Loader
|
||||
configFile string
|
||||
resolvedCache map[string]interface{}
|
||||
fs afero.Fs
|
||||
}
|
||||
|
||||
// NewManager creates a new configuration manager.
|
||||
func NewManager() *Manager {
|
||||
fs := afero.NewOsFs()
|
||||
return &Manager{
|
||||
config: make(map[string]interface{}),
|
||||
loader: NewLoader(fs),
|
||||
resolvedCache: make(map[string]interface{}),
|
||||
fs: fs,
|
||||
}
|
||||
}
|
||||
|
||||
// SetFs sets the filesystem to use for all file operations.
|
||||
// This is primarily useful for testing with an in-memory filesystem.
|
||||
func (m *Manager) SetFs(fs afero.Fs) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
m.fs = fs
|
||||
m.loader = NewLoader(fs)
|
||||
|
||||
// If we have a resolver, recreate it with the new fs
|
||||
if m.resolver != nil {
|
||||
gcpProject := ""
|
||||
awsRegion := "us-east-1"
|
||||
|
||||
// Try to get the current settings
|
||||
if gcpProj := m.getConfigValue("GCPProject", ""); gcpProj != nil {
|
||||
if str, ok := gcpProj.(string); ok {
|
||||
gcpProject = str
|
||||
}
|
||||
}
|
||||
if awsReg := m.getConfigValue("AWSRegion", "us-east-1"); awsReg != nil {
|
||||
if str, ok := awsReg.(string); ok {
|
||||
awsRegion = str
|
||||
}
|
||||
}
|
||||
|
||||
m.resolver = NewResolver(gcpProject, awsRegion, fs)
|
||||
}
|
||||
|
||||
// Clear caches as filesystem changed
|
||||
m.resolvedCache = make(map[string]interface{})
|
||||
}
|
||||
|
||||
// LoadFile loads configuration from a specific file.
|
||||
func (m *Manager) LoadFile(configFile string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
config, err := m.loader.LoadYAML(configFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
m.config = config
|
||||
m.configFile = configFile
|
||||
m.resolvedCache = make(map[string]interface{}) // Clear cache
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadConfig loads the configuration from file.
|
||||
func (m *Manager) loadConfig() error {
|
||||
if m.configFile == "" {
|
||||
// Try to find config.yaml
|
||||
configPath, err := m.loader.FindConfigFile("config.yaml")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.configFile = configPath
|
||||
}
|
||||
|
||||
config, err := m.loader.LoadYAML(m.configFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
m.config = config
|
||||
m.resolvedCache = make(map[string]interface{}) // Clear cache
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetEnvironment sets the active environment.
|
||||
func (m *Manager) SetEnvironment(environment string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
m.environment = strings.ToLower(environment)
|
||||
|
||||
// Create resolver with GCP project and AWS region if available
|
||||
gcpProject := m.getConfigValue("GCPProject", "")
|
||||
awsRegion := m.getConfigValue("AWSRegion", "us-east-1")
|
||||
|
||||
if gcpProjectStr, ok := gcpProject.(string); ok {
|
||||
if awsRegionStr, ok := awsRegion.(string); ok {
|
||||
m.resolver = NewResolver(gcpProjectStr, awsRegionStr, m.fs)
|
||||
}
|
||||
}
|
||||
|
||||
// Clear resolved cache when environment changes
|
||||
m.resolvedCache = make(map[string]interface{})
|
||||
}
|
||||
|
||||
// Get retrieves a configuration value.
|
||||
func (m *Manager) Get(key string, defaultValue interface{}) interface{} {
|
||||
m.mu.RLock()
|
||||
|
||||
// Ensure config is loaded
|
||||
if m.config == nil || len(m.config) == 0 {
|
||||
// Need to upgrade to write lock to load config
|
||||
m.mu.RUnlock()
|
||||
m.mu.Lock()
|
||||
// Double-check after acquiring write lock
|
||||
if m.config == nil || len(m.config) == 0 {
|
||||
if err := m.loadConfig(); err != nil {
|
||||
log.Printf("Failed to load config: %v", err)
|
||||
m.mu.Unlock()
|
||||
return defaultValue
|
||||
}
|
||||
}
|
||||
// Downgrade back to read lock
|
||||
m.mu.Unlock()
|
||||
m.mu.RLock()
|
||||
}
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
// Check cache first
|
||||
cacheKey := fmt.Sprintf("config.%s", key)
|
||||
if cached, ok := m.resolvedCache[cacheKey]; ok {
|
||||
return cached
|
||||
}
|
||||
|
||||
// Try environment-specific config first
|
||||
var rawValue interface{}
|
||||
if m.environment != "" {
|
||||
envMap, ok := m.config["environments"].(map[string]interface{})
|
||||
if ok {
|
||||
if env, ok := envMap[m.environment].(map[string]interface{}); ok {
|
||||
if config, ok := env["config"].(map[string]interface{}); ok {
|
||||
if val, exists := config[key]; exists {
|
||||
rawValue = val
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to configDefaults
|
||||
if rawValue == nil {
|
||||
if defaults, ok := m.config["configDefaults"].(map[string]interface{}); ok {
|
||||
if val, exists := defaults[key]; exists {
|
||||
rawValue = val
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if rawValue == nil {
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
// Resolve the value if we have a resolver
|
||||
var resolvedValue interface{}
|
||||
if m.resolver != nil {
|
||||
resolvedValue = m.resolver.Resolve(rawValue)
|
||||
} else {
|
||||
resolvedValue = rawValue
|
||||
}
|
||||
|
||||
// Cache the resolved value
|
||||
m.resolvedCache[cacheKey] = resolvedValue
|
||||
|
||||
return resolvedValue
|
||||
}
|
||||
|
||||
// GetSecret retrieves a secret value for the current environment.
|
||||
func (m *Manager) GetSecret(key string, defaultValue interface{}) interface{} {
|
||||
m.mu.RLock()
|
||||
|
||||
// Ensure config is loaded
|
||||
if m.config == nil || len(m.config) == 0 {
|
||||
// Need to upgrade to write lock to load config
|
||||
m.mu.RUnlock()
|
||||
m.mu.Lock()
|
||||
// Double-check after acquiring write lock
|
||||
if m.config == nil || len(m.config) == 0 {
|
||||
if err := m.loadConfig(); err != nil {
|
||||
log.Printf("Failed to load config: %v", err)
|
||||
m.mu.Unlock()
|
||||
return defaultValue
|
||||
}
|
||||
}
|
||||
// Downgrade back to read lock
|
||||
m.mu.Unlock()
|
||||
m.mu.RLock()
|
||||
}
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
if m.environment == "" {
|
||||
log.Printf("No environment set when getting secret '%s'", key)
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
// Get the current environment's config
|
||||
envMap, ok := m.config["environments"].(map[string]interface{})
|
||||
if !ok {
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
env, ok := envMap[m.environment].(map[string]interface{})
|
||||
if !ok {
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
secrets, ok := env["secrets"].(map[string]interface{})
|
||||
if !ok {
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
secretValue, exists := secrets[key]
|
||||
if !exists {
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
// Resolve the value
|
||||
if m.resolver != nil {
|
||||
resolved := m.resolver.Resolve(secretValue)
|
||||
if resolved == nil {
|
||||
return defaultValue
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
return secretValue
|
||||
}
|
||||
|
||||
// getConfigValue is an internal helper to get config values without locking.
|
||||
func (m *Manager) getConfigValue(key string, defaultValue interface{}) interface{} {
|
||||
// Try environment-specific config first
|
||||
var rawValue interface{}
|
||||
if m.environment != "" {
|
||||
envMap, ok := m.config["environments"].(map[string]interface{})
|
||||
if ok {
|
||||
if env, ok := envMap[m.environment].(map[string]interface{}); ok {
|
||||
if config, ok := env["config"].(map[string]interface{}); ok {
|
||||
if val, exists := config[key]; exists {
|
||||
rawValue = val
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to configDefaults
|
||||
if rawValue == nil {
|
||||
if defaults, ok := m.config["configDefaults"].(map[string]interface{}); ok {
|
||||
if val, exists := defaults[key]; exists {
|
||||
rawValue = val
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if rawValue == nil {
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
return rawValue
|
||||
}
|
||||
|
||||
// Reload reloads the configuration from file.
|
||||
func (m *Manager) Reload() error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
return m.loadConfig()
|
||||
}
|
||||
|
||||
// GetAllConfig returns all configuration values for the current environment.
|
||||
func (m *Manager) GetAllConfig() map[string]interface{} {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
result := make(map[string]interface{})
|
||||
|
||||
// Start with configDefaults
|
||||
if defaults, ok := m.config["configDefaults"].(map[string]interface{}); ok {
|
||||
for k, v := range defaults {
|
||||
if m.resolver != nil {
|
||||
result[k] = m.resolver.Resolve(v)
|
||||
} else {
|
||||
result[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Override with environment-specific config
|
||||
if m.environment != "" {
|
||||
envMap, ok := m.config["environments"].(map[string]interface{})
|
||||
if ok {
|
||||
if env, ok := envMap[m.environment].(map[string]interface{}); ok {
|
||||
if config, ok := env["config"].(map[string]interface{}); ok {
|
||||
for k, v := range config {
|
||||
if m.resolver != nil {
|
||||
result[k] = m.resolver.Resolve(v)
|
||||
} else {
|
||||
result[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// GetAllSecrets returns all secrets for the current environment.
|
||||
func (m *Manager) GetAllSecrets() map[string]interface{} {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
if m.environment == "" {
|
||||
return make(map[string]interface{})
|
||||
}
|
||||
|
||||
envMap, ok := m.config["environments"].(map[string]interface{})
|
||||
if !ok {
|
||||
return make(map[string]interface{})
|
||||
}
|
||||
|
||||
env, ok := envMap[m.environment].(map[string]interface{})
|
||||
if !ok {
|
||||
return make(map[string]interface{})
|
||||
}
|
||||
|
||||
secrets, ok := env["secrets"].(map[string]interface{})
|
||||
if !ok {
|
||||
return make(map[string]interface{})
|
||||
}
|
||||
|
||||
// Resolve all secrets
|
||||
result := make(map[string]interface{})
|
||||
for k, v := range secrets {
|
||||
if m.resolver != nil {
|
||||
result[k] = m.resolver.Resolve(v)
|
||||
} else {
|
||||
result[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
204
pkg/config/resolver.go
Normal file
204
pkg/config/resolver.go
Normal file
@@ -0,0 +1,204 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
secretmanager "cloud.google.com/go/secretmanager/apiv1"
|
||||
"cloud.google.com/go/secretmanager/apiv1/secretmanagerpb"
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/aws/session"
|
||||
"github.com/aws/aws-sdk-go/service/secretsmanager"
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
// Resolver handles resolution of configuration values with special prefixes.
|
||||
type Resolver struct {
|
||||
gcpProject string
|
||||
awsRegion string
|
||||
gsmClient *secretmanager.Client
|
||||
asmClient *secretsmanager.SecretsManager
|
||||
awsSession *session.Session
|
||||
specialValue *regexp.Regexp
|
||||
fs afero.Fs
|
||||
}
|
||||
|
||||
// NewResolver creates a new value resolver.
|
||||
func NewResolver(gcpProject, awsRegion string, fs afero.Fs) *Resolver {
|
||||
return &Resolver{
|
||||
gcpProject: gcpProject,
|
||||
awsRegion: awsRegion,
|
||||
specialValue: regexp.MustCompile(`^\$([A-Z]+):(.+)$`),
|
||||
fs: fs,
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve resolves a configuration value that may contain special prefixes.
|
||||
func (r *Resolver) Resolve(value interface{}) interface{} {
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
return r.resolveString(v)
|
||||
case map[string]interface{}:
|
||||
// Recursively resolve map values
|
||||
result := make(map[string]interface{})
|
||||
for k, val := range v {
|
||||
result[k] = r.Resolve(val)
|
||||
}
|
||||
return result
|
||||
case []interface{}:
|
||||
// Recursively resolve slice items
|
||||
result := make([]interface{}, len(v))
|
||||
for i, val := range v {
|
||||
result[i] = r.Resolve(val)
|
||||
}
|
||||
return result
|
||||
default:
|
||||
// Return non-string values as-is
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
// resolveString resolves a string value that may contain a special prefix.
|
||||
func (r *Resolver) resolveString(value string) interface{} {
|
||||
matches := r.specialValue.FindStringSubmatch(value)
|
||||
if matches == nil {
|
||||
return value
|
||||
}
|
||||
|
||||
resolverType := matches[1]
|
||||
resolverValue := matches[2]
|
||||
|
||||
switch resolverType {
|
||||
case "ENV":
|
||||
return r.resolveEnv(resolverValue)
|
||||
case "GSM":
|
||||
return r.resolveGSM(resolverValue)
|
||||
case "ASM":
|
||||
return r.resolveASM(resolverValue)
|
||||
case "FILE":
|
||||
return r.resolveFile(resolverValue)
|
||||
default:
|
||||
log.Printf("Unknown resolver type: %s", resolverType)
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
// resolveEnv resolves an environment variable.
|
||||
func (r *Resolver) resolveEnv(envVar string) interface{} {
|
||||
value := os.Getenv(envVar)
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// resolveGSM resolves a Google Secret Manager secret.
|
||||
func (r *Resolver) resolveGSM(secretName string) interface{} {
|
||||
if r.gcpProject == "" {
|
||||
log.Printf("GCP project not configured for GSM resolution")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Initialize GSM client if needed
|
||||
if r.gsmClient == nil {
|
||||
ctx := context.Background()
|
||||
client, err := secretmanager.NewClient(ctx)
|
||||
if err != nil {
|
||||
log.Printf("Failed to create GSM client: %v", err)
|
||||
return nil
|
||||
}
|
||||
r.gsmClient = client
|
||||
}
|
||||
|
||||
// Build the resource name
|
||||
name := fmt.Sprintf("projects/%s/secrets/%s/versions/latest", r.gcpProject, secretName)
|
||||
|
||||
// Access the secret
|
||||
ctx := context.Background()
|
||||
req := &secretmanagerpb.AccessSecretVersionRequest{
|
||||
Name: name,
|
||||
}
|
||||
|
||||
result, err := r.gsmClient.AccessSecretVersion(ctx, req)
|
||||
if err != nil {
|
||||
log.Printf("Failed to access GSM secret %s: %v", secretName, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
return string(result.Payload.Data)
|
||||
}
|
||||
|
||||
// resolveASM resolves an AWS Secrets Manager secret.
|
||||
func (r *Resolver) resolveASM(secretName string) interface{} {
|
||||
// Initialize AWS session if needed
|
||||
if r.awsSession == nil {
|
||||
sess, err := session.NewSession(&aws.Config{
|
||||
Region: aws.String(r.awsRegion),
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("Failed to create AWS session: %v", err)
|
||||
return nil
|
||||
}
|
||||
r.awsSession = sess
|
||||
}
|
||||
|
||||
// Initialize ASM client if needed
|
||||
if r.asmClient == nil {
|
||||
r.asmClient = secretsmanager.New(r.awsSession)
|
||||
}
|
||||
|
||||
// Get the secret value
|
||||
input := &secretsmanager.GetSecretValueInput{
|
||||
SecretId: aws.String(secretName),
|
||||
}
|
||||
|
||||
result, err := r.asmClient.GetSecretValue(input)
|
||||
if err != nil {
|
||||
log.Printf("Failed to access ASM secret %s: %v", secretName, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Return the secret string
|
||||
if result.SecretString != nil {
|
||||
return *result.SecretString
|
||||
}
|
||||
|
||||
// If it's binary data, we can't handle it as a string config value
|
||||
log.Printf("ASM secret %s contains binary data, which is not supported", secretName)
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveFile resolves a file's contents.
|
||||
func (r *Resolver) resolveFile(filePath string) interface{} {
|
||||
// Expand user home directory if present
|
||||
if strings.HasPrefix(filePath, "~/") {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
log.Printf("Failed to get user home directory: %v", err)
|
||||
return nil
|
||||
}
|
||||
filePath = filepath.Join(home, filePath[2:])
|
||||
}
|
||||
|
||||
data, err := afero.ReadFile(r.fs, filePath)
|
||||
if err != nil {
|
||||
log.Printf("Failed to read file %s: %v", filePath, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Strip whitespace/newlines from file contents
|
||||
return strings.TrimSpace(string(data))
|
||||
}
|
||||
|
||||
// Close closes any open clients.
|
||||
func (r *Resolver) Close() error {
|
||||
if r.gsmClient != nil {
|
||||
return r.gsmClient.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user