This change enhances the API to support gjson path syntax for accessing nested configuration values. Users can now use paths like 'server.ssl.enabled' or 'database.replicas.0.host' to directly access nested values without manual navigation. Key changes: - All typed getters now support gjson path syntax - Backward compatibility maintained for top-level keys - Proper error handling for null values and non-existent paths - Special float values (Infinity, NaN) handled correctly - Comprehensive test coverage for edge cases This makes the API much more intuitive and reduces boilerplate code when working with nested configuration structures.
776 lines
22 KiB
Markdown
776 lines
22 KiB
Markdown
# smartconfig
|
|
|
|
A Go library for YAML configuration files with powerful variable interpolation from multiple sources including environment variables, files, cloud secret managers, and more.
|
|
|
|
## Table of Contents
|
|
|
|
- [Key Features](#key-features)
|
|
- [Installation](#installation)
|
|
- [Quick Start](#quick-start)
|
|
- [Type-Preserving Interpolation](#type-preserving-interpolation)
|
|
- [Supported Resolvers](#supported-resolvers)
|
|
- [API Reference](#api-reference)
|
|
- [Advanced Features](#advanced-features)
|
|
- [CLI Tool](#cli-tool)
|
|
- [Security Considerations](#security-considerations)
|
|
- [Comparison with Other Libraries](#comparison-with-other-libraries)
|
|
- [Requirements](#requirements)
|
|
- [Contributing](#contributing)
|
|
- [License](#license)
|
|
|
|
## Key Features
|
|
|
|
- **Type-Preserving Interpolation**: Standalone interpolations preserve their types (numbers, booleans, strings)
|
|
- **Multiple Data Sources**: Environment variables, files, cloud secrets (AWS, GCP, Azure), Vault, Consul, K8S, and more
|
|
- **Nested Interpolation**: Support for complex variable references like `${ENV:PREFIX_${ENV:SUFFIX}}`
|
|
- **JSON5 Support**: JSON resolver supports comments and trailing commas
|
|
- **gjson Path Support**: Access nested configuration values using gjson syntax like `server.ssl.enabled` or `database.replicas.0.host`
|
|
- **Environment Injection**: Automatically export config values as environment variables
|
|
- **Extensible**: Add custom resolvers for your own data sources
|
|
- **CLI Tool**: Command-line tool for processing YAML files with interpolation
|
|
|
|
## Installation
|
|
|
|
```bash
|
|
go get git.eeqj.de/sneak/smartconfig
|
|
```
|
|
|
|
## Quick Start
|
|
|
|
### Basic Example
|
|
|
|
```yaml
|
|
# config.yaml
|
|
# Showcase the power of multiple resolver types
|
|
app_name: ${ENV:APP_NAME}
|
|
version: ${FILE:/app/VERSION} # Read from file
|
|
port: ${ENV:PORT} # Number if PORT="8080"
|
|
debug_mode: ${ENV:DEBUG_MODE} # Boolean if "true"/"false"
|
|
|
|
# External service configuration from JSON config file
|
|
services: ${JSON:/etc/config/services.json:production} # Load entire object
|
|
|
|
# API credentials from cloud secret managers
|
|
api_keys:
|
|
stripe: ${GCPSM:projects/myproject/secrets/stripe-api-key}
|
|
sendgrid: ${AWSSM:prod/sendgrid-api-key}
|
|
twilio: ${VAULT:secret/data/api:twilio_key}
|
|
datadog: ${ENV:DD_API_KEY} # Fallback to env for local dev
|
|
|
|
# Database configuration with mixed sources
|
|
database:
|
|
host: ${CONSUL:service/postgres/address}
|
|
port: ${CONSUL:service/postgres/port}
|
|
name: ${ENV:DB_NAME}
|
|
username: ${ENV:DB_USER}
|
|
password: ${GCPSM:projects/myproject/secrets/db-password}
|
|
|
|
# SSL configuration from multiple sources
|
|
ssl:
|
|
enabled: ${ENV:DB_SSL_ENABLED}
|
|
ca_cert: ${FILE:/etc/ssl/db-ca.crt}
|
|
client_cert: ${K8SS:default/db-certs:client.crt}
|
|
|
|
# Feature flags from JSON file
|
|
features: ${JSON:/etc/config/features.json:${ENV:ENVIRONMENT}}
|
|
|
|
# Server configuration
|
|
server:
|
|
listen: "0.0.0.0:${ENV:PORT}"
|
|
workers: ${EXEC:nproc} # Dynamic based on CPU cores
|
|
hostname: ${EXEC:hostname -f}
|
|
|
|
env:
|
|
# Export these as environment variables for child processes
|
|
DATABASE_URL: "postgres://${ENV:DB_USER}:${GCPSM:projects/myproject/secrets/db-password}@${CONSUL:service/postgres/address}:5432/${ENV:DB_NAME}"
|
|
NEW_RELIC_LICENSE: ${AWSSM:monitoring/newrelic-license}
|
|
```
|
|
|
|
### Go Code
|
|
|
|
```go
|
|
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
|
|
"git.eeqj.de/sneak/smartconfig"
|
|
)
|
|
|
|
func main() {
|
|
// Load from /etc/myapp/config.yml
|
|
config, err := smartconfig.NewFromAppName("myapp")
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
|
|
// Access typed values
|
|
appName, _ := config.GetString("app_name")
|
|
port, _ := config.GetInt("port")
|
|
debugMode, _ := config.GetBool("debug_mode")
|
|
|
|
fmt.Printf("Starting %s on port %d (debug: %v)\n", appName, port, debugMode)
|
|
|
|
// Access nested values using gjson paths
|
|
stripeKey, _ := config.GetString("api_keys.stripe")
|
|
fmt.Printf("Stripe API key loaded: %s...\n", stripeKey[:8])
|
|
|
|
// Database configuration
|
|
dbHost, _ := config.GetString("database.host")
|
|
dbPort, _ := config.GetInt("database.port")
|
|
sslEnabled, _ := config.GetBool("database.ssl.enabled")
|
|
fmt.Printf("Database: %s:%d (SSL: %v)\n", dbHost, dbPort, sslEnabled)
|
|
|
|
// Check loaded services from JSON file
|
|
services, exists := config.Get("services")
|
|
if exists && services != nil {
|
|
if servicesMap, ok := services.(map[string]interface{}); ok {
|
|
fmt.Printf("Loaded %d services from JSON config\n", len(servicesMap))
|
|
}
|
|
}
|
|
|
|
// Environment variables are now available
|
|
fmt.Printf("DATABASE_URL env var: %s\n", os.Getenv("DATABASE_URL"))
|
|
}
|
|
```
|
|
|
|
Example JSON files referenced above:
|
|
|
|
```json
|
|
// /etc/config/services.json
|
|
{
|
|
"production": {
|
|
"api": {
|
|
"endpoint": "https://api.example.com",
|
|
"timeout": 30,
|
|
"retries": 3
|
|
},
|
|
"cache": {
|
|
"provider": "redis",
|
|
"ttl": 3600
|
|
}
|
|
},
|
|
"staging": {
|
|
"api": {
|
|
"endpoint": "https://api-staging.example.com",
|
|
"timeout": 60,
|
|
"retries": 5
|
|
}
|
|
}
|
|
}
|
|
|
|
// /etc/config/features.json
|
|
{
|
|
"production": {
|
|
"new_ui": false,
|
|
"rate_limiting": true,
|
|
"analytics": true
|
|
},
|
|
"staging": {
|
|
"new_ui": true,
|
|
"rate_limiting": true,
|
|
"analytics": false
|
|
}
|
|
}
|
|
```
|
|
|
|
## Type-Preserving Interpolation
|
|
|
|
smartconfig parses YAML first, then performs interpolation. This allows proper type preservation:
|
|
|
|
### How It Works
|
|
|
|
```yaml
|
|
# Standalone interpolations preserve types
|
|
port: ${ENV:PORT} # If PORT="8080", becomes integer 8080
|
|
enabled: ${ENV:ENABLED} # If ENABLED="true", becomes boolean true
|
|
timeout: ${ENV:TIMEOUT} # If TIMEOUT="30.5", becomes float 30.5
|
|
|
|
# Mixed content always returns strings
|
|
message: "Hello ${ENV:USER}!" # Always a string
|
|
port_label: "Port: ${ENV:PORT}" # Always a string
|
|
debug_flag: "debug-${ENV:DEBUG}" # Always a string
|
|
|
|
# Force string by adding any prefix/suffix
|
|
port_string: "${ENV:PORT}-suffix" # Forces string output
|
|
bool_string: "prefix-${ENV:ENABLED}" # Forces string output
|
|
```
|
|
|
|
### Type Conversion Rules
|
|
|
|
For standalone interpolations, smartconfig automatically converts:
|
|
- `"true"` → `true` (boolean)
|
|
- `"false"` → `false` (boolean)
|
|
- Numeric strings → numbers (int or float64) with safety checks
|
|
- Everything else → string
|
|
|
|
**Safety Check**: Numbers are only converted if the conversion is lossless. This means:
|
|
- `"123"` → `123` (converts to int)
|
|
- `"0123"` → `"0123"` (stays string - leading zeros)
|
|
- `"123.45"` → `123.45` (converts to float)
|
|
- `"1.50"` → `"1.50"` (stays string - trailing zeros would be lost)
|
|
- `"+123"` → `"+123"` (stays string - plus sign would be lost)
|
|
- `"1e10"` → `"1e10"` (stays string - notation would change)
|
|
|
|
## Supported Resolvers
|
|
|
|
### Local Resolvers
|
|
|
|
#### ENV - Environment Variables
|
|
```yaml
|
|
# Basic usage
|
|
api_key: ${ENV:API_KEY}
|
|
|
|
# With nested interpolation
|
|
database: ${ENV:DB_${ENV:ENVIRONMENT}}
|
|
```
|
|
|
|
#### FILE - Read File Contents
|
|
```yaml
|
|
# Read entire file (trimmed)
|
|
ssl_cert: ${FILE:/etc/ssl/cert.pem}
|
|
machine_id: ${FILE:/etc/machine-id}
|
|
|
|
# Read system files
|
|
cpu_temp: ${FILE:/sys/class/thermal/thermal_zone0/temp}
|
|
```
|
|
|
|
#### EXEC - Execute Shell Commands
|
|
```yaml
|
|
# Simple commands
|
|
hostname: ${EXEC:hostname -f}
|
|
timestamp: ${EXEC:date +%s}
|
|
git_hash: ${EXEC:git rev-parse HEAD}
|
|
|
|
# Complex commands with pipes
|
|
users_count: ${EXEC:who | wc -l}
|
|
docker_running: ${EXEC:docker ps -q | wc -l}
|
|
```
|
|
|
|
#### JSON - Read from JSON Files (with JSON5 support)
|
|
```yaml
|
|
# Supports gjson path syntax
|
|
api_endpoint: ${JSON:/etc/config.json:services.api.endpoint}
|
|
first_server: ${JSON:/etc/servers.json:servers.0.host}
|
|
all_features: ${JSON:/etc/features.json:features}
|
|
|
|
# JSON5 features: comments and trailing commas supported
|
|
config_value: ${JSON:/etc/app.json5:debug.level}
|
|
```
|
|
|
|
#### YAML - Read from YAML Files
|
|
```yaml
|
|
# Read specific paths from YAML files
|
|
db_config: ${YAML:/etc/database.yml:production.primary}
|
|
replica_host: ${YAML:/etc/database.yml:production.replica.host}
|
|
```
|
|
|
|
### Cloud Secret Managers
|
|
|
|
#### AWS Secrets Manager
|
|
```yaml
|
|
# Requires AWS credentials via:
|
|
# - Environment variables: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY
|
|
# - IAM instance role (on EC2)
|
|
# - AWS config files (~/.aws/credentials)
|
|
|
|
database:
|
|
password: ${AWSSM:prod/db/password}
|
|
api_key: ${AWSSM:external-api-key}
|
|
|
|
# With versioning
|
|
secret: ${AWSSM:mysecret:AWSCURRENT}
|
|
```
|
|
|
|
#### Google Cloud Secret Manager
|
|
```yaml
|
|
# Requires GCP credentials via:
|
|
# - Environment variable: GOOGLE_APPLICATION_CREDENTIALS
|
|
# - Default service account (on GCE/GKE)
|
|
# - gcloud auth application-default login
|
|
|
|
secrets:
|
|
api_key: ${GCPSM:projects/my-project/secrets/api-key}
|
|
db_pass: ${GCPSM:projects/my-project/secrets/db-password/versions/latest}
|
|
```
|
|
|
|
#### Azure Key Vault
|
|
```yaml
|
|
# Requires Azure credentials via:
|
|
# - Environment variables: AZURE_CLIENT_ID, AZURE_CLIENT_SECRET, AZURE_TENANT_ID
|
|
# - Managed Service Identity (on Azure VMs)
|
|
# - Azure CLI authentication (az login)
|
|
|
|
credentials:
|
|
cert: ${AZURESM:https://myvault.vault.azure.net:mycert}
|
|
key: ${AZURESM:https://myvault.vault.azure.net:mykey}
|
|
```
|
|
|
|
### Infrastructure Tools
|
|
|
|
#### HashiCorp Vault
|
|
```yaml
|
|
# Requires VAULT_ADDR and VAULT_TOKEN configured
|
|
database:
|
|
password: ${VAULT:secret/data/myapp:db_password}
|
|
api_key: ${VAULT:secret/data/external:api_key}
|
|
|
|
# KV v2 paths
|
|
secret: ${VAULT:secret/data/myapp:password}
|
|
```
|
|
|
|
#### HashiCorp Consul
|
|
```yaml
|
|
# Requires CONSUL_HTTP_ADDR configured
|
|
config:
|
|
feature_flags: ${CONSUL:myapp/features}
|
|
db_host: ${CONSUL:service/database/host}
|
|
```
|
|
|
|
#### Kubernetes Secrets
|
|
```yaml
|
|
# Requires in-cluster or kubeconfig access
|
|
database:
|
|
password: ${K8SS:default/db-secret:password}
|
|
cert: ${K8SS:kube-system/tls-secret:tls.crt}
|
|
```
|
|
|
|
#### etcd
|
|
```yaml
|
|
# Requires ETCD_ENDPOINTS configured
|
|
config:
|
|
cluster_size: ${ETCD:/cluster/size}
|
|
node_role: ${ETCD:/node/role}
|
|
```
|
|
|
|
## API Reference
|
|
|
|
### Loading Configuration
|
|
|
|
```go
|
|
// Load from /etc/{appname}/config.yml
|
|
config, err := smartconfig.NewFromAppName("myapp")
|
|
|
|
// Load from specific file
|
|
config, err := smartconfig.NewFromConfigPath("/path/to/config.yaml")
|
|
|
|
// Load from io.Reader
|
|
reader := strings.NewReader(yamlContent)
|
|
config, err := smartconfig.NewFromReader(reader)
|
|
```
|
|
|
|
### Accessing Values
|
|
|
|
All accessor methods now support gjson path syntax for accessing nested values:
|
|
|
|
```go
|
|
// Get nested values using gjson paths
|
|
host, err := config.GetString("server.host") // "localhost"
|
|
port, err := config.GetInt("database.replicas.0.port") // 5433
|
|
enabled, err := config.GetBool("server.ssl.enabled") // true
|
|
|
|
// Get() method also supports gjson paths
|
|
value, exists := config.Get("database.primary.credentials")
|
|
if exists {
|
|
// value is map[string]interface{} with username and password
|
|
}
|
|
|
|
// Backward compatibility: top-level keys still work
|
|
appName, err := config.GetString("app_name") // Direct top-level access
|
|
```
|
|
|
|
### Typed Getters
|
|
|
|
All typed getters support gjson path syntax for accessing nested values:
|
|
|
|
```go
|
|
// String values
|
|
name, err := config.GetString("app_name") // Top-level
|
|
dbHost, err := config.GetString("database.primary.host") // Nested path
|
|
|
|
// Integer values (works with both int and string values)
|
|
port, err := config.GetInt("server.port") // Nested
|
|
replicaPort, err := config.GetInt("database.replicas.1.port") // Array element
|
|
|
|
// Unsigned integers
|
|
maxConn, err := config.GetUint("features.max_connections") // Nested
|
|
|
|
// Float values
|
|
timeout, err := config.GetFloat("features.timeout_seconds") // Nested
|
|
|
|
// Boolean values (works with bool and string "true"/"false")
|
|
debug, err := config.GetBool("features.debug_mode") // Nested
|
|
sslEnabled, err := config.GetBool("server.ssl.enabled") // Deep nested
|
|
|
|
// Byte sizes with human-readable formats ("10GB", "512MiB", etc.)
|
|
maxSize, err := config.GetBytes("features.max_file_size") // Nested
|
|
|
|
// Get entire config as map
|
|
data := config.Data()
|
|
```
|
|
|
|
### Working with Nested Values
|
|
|
|
With gjson path support, accessing nested values is now straightforward:
|
|
|
|
```go
|
|
// Given this YAML:
|
|
// server:
|
|
// host: localhost
|
|
// port: 8080
|
|
// ssl:
|
|
// enabled: true
|
|
// cert: /etc/ssl/cert.pem
|
|
|
|
// Direct access using gjson paths
|
|
sslEnabled, _ := config.GetBool("server.ssl.enabled") // true
|
|
sslCert, _ := config.GetString("server.ssl.cert") // "/etc/ssl/cert.pem"
|
|
serverPort, _ := config.GetInt("server.port") // 8080
|
|
|
|
// Array access
|
|
// database:
|
|
// replicas:
|
|
// - host: db1.example.com
|
|
// port: 5433
|
|
// - host: db2.example.com
|
|
// port: 5434
|
|
|
|
firstReplica, _ := config.GetString("database.replicas.0.host") // "db1.example.com"
|
|
secondPort, _ := config.GetInt("database.replicas.1.port") // 5434
|
|
|
|
// You can still unmarshal into a struct if preferred
|
|
type Config struct {
|
|
Server struct {
|
|
Host string
|
|
Port int
|
|
SSL struct {
|
|
Enabled bool
|
|
Cert string
|
|
}
|
|
}
|
|
}
|
|
|
|
var cfg Config
|
|
data := config.Data()
|
|
// Use a YAML/JSON marshaling library to convert data to your struct
|
|
```
|
|
|
|
## Complete Example
|
|
|
|
Here's a comprehensive example showing all features:
|
|
|
|
```yaml
|
|
# production.yaml - Complete example with all features
|
|
|
|
# Top-level values for easy API access
|
|
app_name: ${ENV:APP_NAME}
|
|
environment: ${ENV:DEPLOY_ENV}
|
|
version: ${FILE:/app/VERSION}
|
|
build_info: ${EXEC:git describe --always --dirty}
|
|
|
|
# Type preservation examples
|
|
server_port: ${ENV:PORT} # Integer: 8080
|
|
debug_enabled: ${ENV:DEBUG} # Boolean: true/false
|
|
request_timeout: ${ENV:TIMEOUT_SECONDS} # Float: 30.5
|
|
max_upload_size: ${ENV:MAX_UPLOAD_SIZE} # Bytes: "100MB"
|
|
|
|
# Nested configuration
|
|
server:
|
|
listen_address: "0.0.0.0:${ENV:PORT}" # String concatenation
|
|
workers: ${ENV:WORKER_COUNT}
|
|
tls:
|
|
enabled: ${ENV:TLS_ENABLED}
|
|
cert: ${FILE:/etc/ssl/certs/server.crt}
|
|
key: ${VAULT:secret/data/ssl:private_key}
|
|
|
|
# Database configuration with nested interpolation
|
|
database:
|
|
primary:
|
|
host: ${ENV:DB_HOST_${ENV:DEPLOY_ENV}} # e.g., DB_HOST_prod
|
|
port: ${ENV:DB_PORT}
|
|
name: ${ENV:DB_NAME}
|
|
user: ${ENV:DB_USER}
|
|
password: ${AWSSM:${ENV:APP_NAME}/${ENV:DEPLOY_ENV}/db_password}
|
|
|
|
replica:
|
|
host: ${CONSUL:service/db-replica-${ENV:DEPLOY_ENV}/address}
|
|
port: ${CONSUL:service/db-replica-${ENV:DEPLOY_ENV}/port}
|
|
|
|
# External services
|
|
services:
|
|
redis:
|
|
url: ${ETCD:/config/${ENV:APP_NAME}/redis_url}
|
|
password: ${K8SS:default/redis-secret:password}
|
|
|
|
elasticsearch:
|
|
hosts: ${JSON:/etc/config/services.json:elasticsearch.hosts}
|
|
api_key: ${GCPSM:projects/${ENV:GCP_PROJECT}/secrets/es_api_key}
|
|
|
|
# Feature flags from various sources
|
|
features:
|
|
new_ui: ${CONSUL:features/${ENV:APP_NAME}/new_ui}
|
|
rate_limiting: ${ENV:FEATURE_RATE_LIMITING}
|
|
analytics: ${YAML:/etc/features.yaml:features.analytics.enabled}
|
|
|
|
# Cloud storage configuration
|
|
storage:
|
|
provider: ${ENV:STORAGE_PROVIDER}
|
|
config:
|
|
bucket: ${ENV:STORAGE_BUCKET}
|
|
region: ${ENV:AWS_REGION}
|
|
access_key: ${AWSSM:storage_access_key}
|
|
secret_key: ${AWSSM:storage_secret_key}
|
|
|
|
# Monitoring and logging
|
|
monitoring:
|
|
datadog:
|
|
api_key: ${VAULT:secret/data/monitoring:datadog_api_key}
|
|
app_key: ${VAULT:secret/data/monitoring:datadog_app_key}
|
|
|
|
sentry:
|
|
dsn: ${ENV:SENTRY_DSN}
|
|
environment: ${ENV:DEPLOY_ENV}
|
|
release: ${EXEC:git rev-parse HEAD}
|
|
|
|
# Environment variables to inject
|
|
env:
|
|
# These will be set as environment variables in the process
|
|
DATABASE_URL: "postgres://${ENV:DB_USER}:${AWSSM:${ENV:APP_NAME}/${ENV:DEPLOY_ENV}/db_password}@${ENV:DB_HOST_${ENV:DEPLOY_ENV}}:${ENV:DB_PORT}/${ENV:DB_NAME}"
|
|
REDIS_URL: ${ETCD:/config/${ENV:APP_NAME}/redis_url}
|
|
NEW_RELIC_LICENSE_KEY: ${AZURESM:https://myvault.vault.azure.net:newrelic-license}
|
|
DD_TRACE_ENABLED: ${ENV:DD_TRACE_ENABLED}
|
|
```
|
|
|
|
```go
|
|
// main.go - Using the complete configuration
|
|
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
|
|
"git.eeqj.de/sneak/smartconfig"
|
|
)
|
|
|
|
func main() {
|
|
// Load configuration
|
|
config, err := smartconfig.NewFromConfigPath("production.yaml")
|
|
if err != nil {
|
|
log.Fatalf("Failed to load config: %v", err)
|
|
}
|
|
|
|
// Access top-level typed values
|
|
appName, _ := config.GetString("app_name")
|
|
serverPort, _ := config.GetInt("server_port")
|
|
debugEnabled, _ := config.GetBool("debug_enabled")
|
|
maxUploadSize, _ := config.GetBytes("max_upload_size")
|
|
|
|
fmt.Printf("Starting %s on port %d (debug: %v)\n", appName, serverPort, debugEnabled)
|
|
fmt.Printf("Max upload size: %d bytes\n", maxUploadSize)
|
|
|
|
// Access nested configuration using gjson paths
|
|
dbHost, _ := config.GetString("database.primary.host")
|
|
dbPort, _ := config.GetInt("database.primary.port")
|
|
fmt.Printf("Database: %s:%d\n", dbHost, dbPort)
|
|
|
|
// Access array elements
|
|
replicaHost, _ := config.GetString("database.replica.host")
|
|
fmt.Printf("Replica: %s\n", replicaHost)
|
|
|
|
// Check injected environment variables
|
|
fmt.Printf("DATABASE_URL: %s\n", os.Getenv("DATABASE_URL"))
|
|
fmt.Printf("REDIS_URL: %s\n", os.Getenv("REDIS_URL"))
|
|
|
|
// Feature flags
|
|
if features, ok := data["features"].(map[string]interface{}); ok {
|
|
if newUI, ok := features["new_ui"].(bool); ok && newUI {
|
|
fmt.Println("New UI is enabled!")
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
## Advanced Features
|
|
|
|
### Nested Interpolation
|
|
|
|
Interpolations can be nested up to 3 levels deep:
|
|
|
|
```yaml
|
|
# Dynamic environment selection
|
|
environment: prod
|
|
database:
|
|
host: ${ENV:DB_HOST_${ENV:ENVIRONMENT}} # Looks for DB_HOST_prod
|
|
|
|
# Multi-level nesting
|
|
config:
|
|
value: ${ENV:${ENV:PREFIX}_${ENV:SUFFIX}_KEY}
|
|
|
|
# With different resolvers
|
|
secret: ${VAULT:secret/${ENV:APP_NAME}/${ENV:ENVIRONMENT}:password}
|
|
```
|
|
|
|
### Environment Variable Injection
|
|
|
|
Values under the `env` key are automatically exported as environment variables:
|
|
|
|
```yaml
|
|
# These become environment variables in your process
|
|
env:
|
|
DATABASE_URL: "postgres://${ENV:DB_USER}:${AWSSM:db-password}@${ENV:DB_HOST}/myapp"
|
|
REDIS_URL: ${CONSUL:service/redis/url}
|
|
API_KEY: ${VAULT:secret/data/external:api_key}
|
|
|
|
# Your application can now use os.Getenv("DATABASE_URL"), etc.
|
|
```
|
|
|
|
### Custom Resolvers
|
|
|
|
Extend smartconfig with your own resolvers:
|
|
|
|
```go
|
|
// Implement the Resolver interface
|
|
type RedisResolver struct {
|
|
client *redis.Client
|
|
}
|
|
|
|
func (r *RedisResolver) Resolve(key string) (string, error) {
|
|
return r.client.Get(key).Result()
|
|
}
|
|
|
|
// Register your resolver
|
|
config := smartconfig.New()
|
|
config.RegisterResolver("REDIS", &RedisResolver{client: redisClient})
|
|
|
|
// Use in YAML
|
|
// cache_ttl: ${REDIS:config:cache:ttl}
|
|
```
|
|
|
|
### Error Handling
|
|
|
|
Always handle errors appropriately:
|
|
|
|
```go
|
|
config, err := smartconfig.NewFromAppName("myapp")
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
log.Fatal("Config file not found in /etc/myapp/config.yml")
|
|
}
|
|
log.Fatalf("Failed to load config: %v", err)
|
|
}
|
|
|
|
// Handle missing keys
|
|
port, err := config.GetInt("server_port")
|
|
if err != nil {
|
|
if strings.Contains(err.Error(), "not found") {
|
|
// Use default
|
|
port = 8080
|
|
} else {
|
|
log.Fatalf("Invalid port configuration: %v", err)
|
|
}
|
|
}
|
|
|
|
// Handle type conversion errors
|
|
timeout, err := config.GetFloat("timeout")
|
|
if err != nil {
|
|
if strings.Contains(err.Error(), "cannot convert") {
|
|
log.Fatalf("Timeout must be a number, got: %v", err)
|
|
}
|
|
}
|
|
```
|
|
|
|
## CLI Tool
|
|
|
|
A command-line tool is provided for testing and preprocessing configuration files:
|
|
|
|
```bash
|
|
# Install the CLI tool
|
|
go install git.eeqj.de/sneak/smartconfig/cmd/smartconfig@latest
|
|
|
|
# Process a config file (outputs YAML)
|
|
smartconfig < config.yaml > processed.yaml
|
|
|
|
# Output as JSON
|
|
smartconfig --json < config.yaml > processed.json
|
|
|
|
# Test interpolation
|
|
export DB_PASSWORD="secret123"
|
|
echo 'password: ${ENV:DB_PASSWORD}' | smartconfig
|
|
# Output: password: secret123
|
|
|
|
# Type preservation in action
|
|
export PORT="8080"
|
|
export ENABLED="true"
|
|
export TIMEOUT="30.5"
|
|
cat <<'EOF' | smartconfig --json
|
|
port: ${ENV:PORT}
|
|
enabled: ${ENV:ENABLED}
|
|
timeout: ${ENV:TIMEOUT}
|
|
EOF
|
|
# Output: {"enabled":true,"port":8080,"timeout":30.5}
|
|
```
|
|
|
|
## Security Considerations
|
|
|
|
### Arbitrary Code Execution
|
|
The `EXEC` resolver can execute any shell command. This is by design but has security implications:
|
|
- Only use trusted configuration files
|
|
- Validate configuration sources in production
|
|
- Consider disabling EXEC resolver in sensitive environments
|
|
- Use appropriate file permissions on configuration files
|
|
|
|
### Secret Management Best Practices
|
|
- Never commit secrets to version control
|
|
- Use appropriate secret managers (Vault, AWS SM, etc.) for production
|
|
- Limit access to configuration files containing secret references
|
|
- Rotate secrets regularly
|
|
- Monitor secret access
|
|
|
|
### File Access
|
|
The `FILE` resolver can read any file accessible to the process:
|
|
- Run applications with minimal required permissions
|
|
- Use separate users for different applications
|
|
- Consider using secrets managers instead of direct file access
|
|
|
|
## Comparison with Other Libraries
|
|
|
|
### vs. Viper
|
|
- **smartconfig**: Focuses on interpolation from multiple sources with type preservation
|
|
- **Viper**: More features but heavier, includes watching, flags, etc.
|
|
|
|
### vs. envconfig
|
|
- **smartconfig**: YAML-based with interpolation from many sources
|
|
- **envconfig**: Environment variables only, struct tags
|
|
|
|
### vs. koanf
|
|
- **smartconfig**: Simpler API, focused on interpolation
|
|
- **koanf**: More providers and formats, more complex API
|
|
|
|
## Requirements
|
|
|
|
- Go 1.18 or later
|
|
- Valid YAML syntax in configuration files
|
|
- Appropriate credentials for cloud providers (AWS, GCP, Azure)
|
|
- Network access for remote resolvers (Vault, Consul, etcd)
|
|
|
|
## Contributing
|
|
|
|
Contributions are welcome! Please ensure:
|
|
- Tests pass: `make test`
|
|
- Code is formatted: `make fmt`
|
|
- No linting errors: `make lint`
|
|
|
|
## License
|
|
|
|
WTFPL
|
|
|
|
## Author
|
|
|
|
sneak <sneak@sneak.berlin>
|
|
|
|
---
|
|
|
|
*Inspired by Ryan Smith's configuration format ideas.* |