Skip to content

Commit 4248f33

Browse files
authored
feat(managers): add env interpolation (#7)
1 parent 594dd1a commit 4248f33

File tree

4 files changed

+467
-1
lines changed

4 files changed

+467
-1
lines changed

README.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,45 @@ allowing you to fetch secrets from different cloud providers
205205
or projects as needed. Kuba currently supports GCP Secret Manager,
206206
AWS Secrets Manager, Azure Key Vault, and OpenBao.
207207

208+
### Environment Variable Interpolation
209+
210+
Kuba supports environment variable interpolation in the `value` field using `${VAR_NAME}` syntax. This allows you to:
211+
212+
- Reference previously defined environment variables from the same configuration
213+
- Use system environment variables
214+
- Build complex connection strings and URLs dynamically
215+
216+
**Example with interpolation:**
217+
```yaml
218+
default:
219+
provider: gcp
220+
project: 1337
221+
mappings:
222+
- environment-variable: "DB_PASSWORD"
223+
secret-key: "db-password"
224+
- environment-variable: "DB_HOST"
225+
value: "mydbhost"
226+
- environment-variable: "DB_CONNECTION_STRING"
227+
value: "postgresql://user:${DB_PASSWORD}@${DB_HOST}:5432/mydb"
228+
- environment-variable: "API_URL"
229+
value: "https://api.${DOMAIN}/v1"
230+
- environment-variable: "APP_ENV"
231+
value: "${NODE_ENV:-development}"
232+
- environment-variable: "REDIS_URL"
233+
value: "redis://${REDIS_HOST:-localhost}:${REDIS_PORT:-6379}/0"
234+
```
235+
236+
In this example:
237+
- `${DB_PASSWORD}` will be replaced with the value from the secret
238+
- `${DB_HOST}` will be replaced with the literal value "mydbhost"
239+
- `${DOMAIN}` will be replaced with the system environment variable if it exists
240+
- `${NODE_ENV:-development}` will use the `NODE_ENV` environment variable if set, otherwise default to "development"
241+
- `${REDIS_HOST:-localhost}` will use the `REDIS_HOST` environment variable if set, otherwise default to "localhost"
242+
243+
**Note**: Interpolation is processed in order, so you can reference variables defined earlier in the same configuration. Unresolved variables will remain unchanged in the output.
244+
245+
**Shell-style default values**: You can use `${VAR_NAME:-default}` syntax to provide fallback values when environment variables are not set. This is particularly useful for providing sensible defaults while allowing overrides through environment variables.
246+
208247
### Running with a specific environment
209248

210249
You can also specify the environment you want to use:

internal/config/kuba_config.go

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import (
44
"fmt"
55
"os"
66
"path/filepath"
7+
"regexp"
8+
"strings"
79

810
"gopkg.in/yaml.v3"
911
)
@@ -30,6 +32,123 @@ type Mapping struct {
3032
Project string `yaml:"project,omitempty"`
3133
}
3234

35+
// interpolateEnvVars replaces ${VAR_NAME} patterns with actual environment variable values
36+
// It also supports previously resolved variables from the same configuration
37+
// Supports both ${VAR_NAME} and ${VAR_NAME:-default} syntax
38+
func interpolateEnvVars(value string, resolvedVars map[string]string) string {
39+
// Regex to match ${VAR_NAME} and ${VAR_NAME:-default} patterns
40+
re := regexp.MustCompile(`\$\{([^}]+)\}`)
41+
42+
return re.ReplaceAllStringFunc(value, func(match string) string {
43+
// Extract the variable name and optional default from ${VAR_NAME} or ${VAR_NAME:-default}
44+
content := match[2 : len(match)-1]
45+
46+
// Check if there's a default value specified
47+
if strings.Contains(content, ":-") {
48+
parts := strings.SplitN(content, ":-", 2)
49+
varName := parts[0]
50+
defaultValue := parts[1]
51+
52+
// First check if we have this variable from previously resolved mappings
53+
if resolvedValue, exists := resolvedVars[varName]; exists {
54+
return resolvedValue
55+
}
56+
57+
// Then check if it's an environment variable
58+
if envValue := os.Getenv(varName); envValue != "" {
59+
return envValue
60+
}
61+
62+
// If not found, return the default value
63+
return defaultValue
64+
}
65+
66+
// No default value specified, use original logic
67+
varName := content
68+
69+
// First check if we have this variable from previously resolved mappings
70+
if resolvedValue, exists := resolvedVars[varName]; exists {
71+
return resolvedValue
72+
}
73+
74+
// Then check if it's an environment variable
75+
if envValue := os.Getenv(varName); envValue != "" {
76+
return envValue
77+
}
78+
79+
// If not found, return the original pattern (could be useful for debugging)
80+
return match
81+
})
82+
}
83+
84+
// processValueInterpolations processes all value fields in mappings to resolve environment variable interpolations
85+
func processValueInterpolations(config *KubaConfig) error {
86+
// Process environments in order to handle dependencies correctly
87+
// We'll process each environment multiple times until no more interpolations are possible
88+
// or until we detect a circular dependency
89+
90+
for envName, env := range config.Environments {
91+
// Track resolved variables for this environment
92+
resolvedVars := make(map[string]string)
93+
94+
// Process mappings multiple times to handle dependencies
95+
maxIterations := len(env.Mappings) * 2 // Allow for some dependency depth
96+
for iteration := 0; iteration < maxIterations; iteration++ {
97+
changed := false
98+
99+
for i, mapping := range env.Mappings {
100+
if mapping.Value != nil {
101+
// Convert value to string for processing
102+
var strValue string
103+
switch v := mapping.Value.(type) {
104+
case string:
105+
strValue = v
106+
case int, int32, int64:
107+
strValue = fmt.Sprintf("%d", v)
108+
case float32, float64:
109+
strValue = fmt.Sprintf("%g", v)
110+
default:
111+
strValue = fmt.Sprintf("%v", v)
112+
}
113+
114+
// Check if this value contains interpolation patterns
115+
if strings.Contains(strValue, "${") {
116+
// Interpolate the value
117+
interpolatedValue := interpolateEnvVars(strValue, resolvedVars)
118+
119+
// If the value changed, update it
120+
if interpolatedValue != strValue {
121+
// Update the mapping value
122+
env.Mappings[i].Value = interpolatedValue
123+
// Update our resolved vars map
124+
resolvedVars[mapping.EnvironmentVariable] = interpolatedValue
125+
changed = true
126+
}
127+
} else {
128+
// No interpolation needed, but convert numeric values to strings for consistency
129+
if mapping.Value != strValue {
130+
env.Mappings[i].Value = strValue
131+
changed = true
132+
}
133+
// Store the value in resolved vars
134+
resolvedVars[mapping.EnvironmentVariable] = strValue
135+
}
136+
}
137+
}
138+
139+
// If no changes were made in this iteration, we're done
140+
if !changed {
141+
break
142+
}
143+
}
144+
145+
// Update the environment in the config
146+
config.Environments[envName] = env
147+
}
148+
149+
return nil
150+
}
151+
33152
// LoadKubaConfig loads the kuba.yaml configuration file
34153
func LoadKubaConfig(configPath string) (*KubaConfig, error) {
35154
if configPath == "" {
@@ -53,6 +172,11 @@ func LoadKubaConfig(configPath string) (*KubaConfig, error) {
53172
return nil, fmt.Errorf("failed to parse configuration file: %w", err)
54173
}
55174

175+
// Process environment variable interpolations
176+
if err := processValueInterpolations(&config); err != nil {
177+
return nil, fmt.Errorf("failed to process environment variable interpolations: %w", err)
178+
}
179+
56180
// Validate configuration
57181
if err := validateConfig(&config); err != nil {
58182
return nil, fmt.Errorf("invalid configuration: %w", err)

0 commit comments

Comments
 (0)