Skip to content

Commit f280ef0

Browse files
committed
feat(config): enhance environment variable expansion in dependencies and services
1 parent 8145482 commit f280ef0

File tree

3 files changed

+221
-25
lines changed

3 files changed

+221
-25
lines changed

pkg/config/config.go

Lines changed: 93 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -117,10 +117,11 @@ func getDefaultConfig(baseName, version string) (*Dependency, bool) {
117117
return &dep, true
118118
}
119119

120-
// UnmarshalYAML is a custom unmarshaler that lets you handle both string-based
121-
// and map-based dependencies.
120+
// UnmarshalYAML is a custom unmarshaler that handles both string-based
121+
// dependencies (like "mysql:8") and map-based dependencies, plus expands env vars.
122122
func (d *Dependency) UnmarshalYAML(node *yaml.Node) error {
123123
switch node.Tag {
124+
124125
case "!!str":
125126
// If the node is just a string (e.g. "postgres:16"), parse it.
126127
value := node.Value
@@ -130,6 +131,17 @@ func (d *Dependency) UnmarshalYAML(node *yaml.Node) error {
130131
// We have a base name + version
131132
base, version := parts[0], parts[1]
132133
if defaultDep, ok := getDefaultConfig(base, version); ok {
134+
// Expand env placeholders in the default config
135+
for i, envLine := range defaultDep.Env {
136+
expanded, err := expandWithEnvAndDefault(envLine)
137+
if err != nil {
138+
return fmt.Errorf(
139+
"failed expanding env in default config for %q: %w",
140+
base, err,
141+
)
142+
}
143+
defaultDep.Env[i] = expanded
144+
}
133145
*d = *defaultDep
134146
} else {
135147
// fallback for unknown base (e.g., "foobar:1.0")
@@ -140,9 +152,20 @@ func (d *Dependency) UnmarshalYAML(node *yaml.Node) error {
140152
// Only a base name (e.g., "redis")
141153
base := parts[0]
142154
if defaultDep, ok := getDefaultConfig(base, "latest"); ok {
155+
// Expand env placeholders
156+
for i, envLine := range defaultDep.Env {
157+
expanded, err := expandWithEnvAndDefault(envLine)
158+
if err != nil {
159+
return fmt.Errorf(
160+
"failed expanding env in default config for %q: %w",
161+
base, err,
162+
)
163+
}
164+
defaultDep.Env[i] = expanded
165+
}
143166
*d = *defaultDep
144167
} else {
145-
// fallback for unknown base (e.g., "foobar")
168+
// fallback for unknown base
146169
d.Name = base
147170
d.Image = base
148171
}
@@ -156,6 +179,17 @@ func (d *Dependency) UnmarshalYAML(node *yaml.Node) error {
156179
if err := node.Decode(&tmp); err != nil {
157180
return fmt.Errorf("failed to decode dependency map: %w", err)
158181
}
182+
// Expand placeholders in tmp.Env
183+
for i, envLine := range tmp.Env {
184+
expanded, err := expandWithEnvAndDefault(envLine)
185+
if err != nil {
186+
return fmt.Errorf(
187+
"failed to expand env for dependency %q: %w",
188+
tmp.Name, err,
189+
)
190+
}
191+
tmp.Env[i] = expanded
192+
}
159193
*d = Dependency(tmp)
160194
return nil
161195

@@ -175,26 +209,68 @@ type Hooks struct {
175209
Post string `yaml:"post"`
176210
}
177211

178-
func ParseConfig(data []byte) (*Config, error) {
179-
// Load any .env file from the current directory
180-
_ = godotenv.Load()
212+
// expandWithEnvAndDefault expands environment variables within a single string.
213+
// It handles `${VAR:-default}` and `${VAR:?error message}` syntax. If a required
214+
// variable is missing, it returns an error. Otherwise, it returns the expanded
215+
// string and a nil error.
216+
func expandWithEnvAndDefault(input string) (string, error) {
217+
var expansionErr error
218+
219+
expanded := os.Expand(input, func(key string) string {
220+
val, err := expandOneVar(key)
221+
if err != nil && expansionErr == nil {
222+
// capture the first error
223+
expansionErr = err
224+
}
225+
return val
226+
})
181227

182-
// Process environment variables with default values
183-
expandedData := os.Expand(string(data), func(key string) string {
184-
// Check if there's a default value specified
228+
// if expansionErr != nil, expanded might be partially filled, but we return the error
229+
return expanded, expansionErr
230+
}
231+
232+
// expandOneVar handles a single ${...} expression inside os.Expand.
233+
func expandOneVar(key string) (string, error) {
234+
// Check for ":-" = default fallback
235+
if strings.Contains(key, ":-") {
185236
parts := strings.SplitN(key, ":-", 2)
186237
envKey := parts[0]
187-
188-
if value, exists := os.LookupEnv(envKey); exists {
189-
return value
238+
defaultVal := parts[1]
239+
if val, ok := os.LookupEnv(envKey); ok {
240+
return val, nil
190241
}
242+
return defaultVal, nil
243+
}
191244

192-
// Return default value if specified, empty string otherwise
193-
if len(parts) > 1 {
194-
return parts[1]
245+
// Check for ":?" = required variable
246+
if strings.Contains(key, ":?") {
247+
parts := strings.SplitN(key, ":?", 2)
248+
envKey := parts[0]
249+
errMsg := parts[1]
250+
if val, ok := os.LookupEnv(envKey); ok {
251+
return val, nil
195252
}
196-
return ""
197-
})
253+
// variable not set => return an error
254+
return "", fmt.Errorf("required environment variable %s not set: %s", envKey, errMsg)
255+
}
256+
257+
// Otherwise, it's just ${VAR} with no colon
258+
if val, ok := os.LookupEnv(key); ok {
259+
return val, nil
260+
}
261+
// Not found in environment => return empty string
262+
return "", nil
263+
}
264+
265+
func ParseConfig(data []byte) (*Config, error) {
266+
// Load any .env file from the current directory
267+
_ = godotenv.Load()
268+
269+
// Process environment variables with default values
270+
expandedData, err := expandWithEnvAndDefault(string(data))
271+
if err != nil {
272+
return nil, fmt.Errorf("error expanding environment variables: %v", err)
273+
}
198274

199275
var config Config
200276
if err := yaml.Unmarshal([]byte(expandedData), &config); err != nil {

pkg/config/config_test.go

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -408,6 +408,8 @@ server:
408408
services:
409409
- name: "web"
410410
image: "nginx:latest"
411+
routes:
412+
- path: "/"
411413
port: 80
412414
volumes:
413415
- "/absolute/path:/container/path"
@@ -458,3 +460,120 @@ dependencies:
458460
expected := []string{"redis_data"}
459461
assert.Equal(suite.T(), expected, config.Volumes)
460462
}
463+
464+
func (suite *ConfigTestSuite) TestParseConfig_EnvExpansionInDefaults_Success() {
465+
// We'll set an env var that overrides the default "production-secret" for MySQL.
466+
// Then after the test, we'll unset it to avoid side effects in other tests.
467+
os.Setenv("MYSQL_ROOT_PASSWORD", "super-secret-password")
468+
defer os.Unsetenv("MYSQL_ROOT_PASSWORD")
469+
470+
yamlData := []byte(`
471+
project:
472+
name: "env-default-test"
473+
domain: "example.com"
474+
email: "test@example.com"
475+
server:
476+
host: "example.com"
477+
port: 22
478+
user: "user"
479+
ssh_key: "~/.ssh/id_rsa"
480+
services:
481+
- name: "web"
482+
image: "nginx:latest"
483+
routes:
484+
- path: "/"
485+
port: 80
486+
dependencies:
487+
- "mysql:5.7"
488+
`)
489+
490+
config, err := ParseConfig(yamlData)
491+
assert.NoError(suite.T(), err)
492+
assert.NotNil(suite.T(), config)
493+
assert.Len(suite.T(), config.Dependencies, 1)
494+
495+
dep := config.Dependencies[0]
496+
// The name should come from the default config or be "mysql"
497+
assert.Equal(suite.T(), "mysql", dep.Name)
498+
// The image should reflect version override => "mysql:5.7"
499+
assert.Equal(suite.T(), "mysql:5.7", dep.Image)
500+
501+
// Check env expansions
502+
// Our default config had "MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD:-production-secret}"
503+
// We set that env var => we expect "MYSQL_ROOT_PASSWORD=super-secret-password"
504+
require := assert.New(suite.T())
505+
require.Len(dep.Env, 1)
506+
require.Equal("MYSQL_ROOT_PASSWORD=super-secret-password", dep.Env[0])
507+
}
508+
509+
func (suite *ConfigTestSuite) TestParseConfig_EnvExpansionInServices_Success() {
510+
// We'll set an env var that overrides a default in the service env.
511+
os.Setenv("MY_SERVICE_VAR", "overridden-value")
512+
defer os.Unsetenv("MY_SERVICE_VAR")
513+
514+
yamlData := []byte(`
515+
project:
516+
name: "service-env-test"
517+
domain: "example.com"
518+
email: "test@example.com"
519+
server:
520+
host: "example.com"
521+
port: 22
522+
user: "user"
523+
ssh_key: "~/.ssh/id_rsa"
524+
525+
services:
526+
- name: "my-service"
527+
image: "my-service:latest"
528+
routes:
529+
- path: "/"
530+
port: 8080
531+
env:
532+
- "MY_VAR=${MY_SERVICE_VAR:-default-value}"
533+
dependencies: []
534+
`)
535+
536+
config, err := ParseConfig(yamlData)
537+
assert.NoError(suite.T(), err)
538+
assert.NotNil(suite.T(), config)
539+
assert.Len(suite.T(), config.Services, 1)
540+
541+
svc := config.Services[0]
542+
require := assert.New(suite.T())
543+
require.Len(svc.Env, 1)
544+
// Should reflect the env var we set
545+
require.Equal("MY_VAR=overridden-value", svc.Env[0])
546+
}
547+
548+
func (suite *ConfigTestSuite) TestParseConfig_EnvExpansion_RequiredVarMissing() {
549+
// We do NOT set the environment variable MY_REQUIRED_VAR, so we expect an error.
550+
yamlData := []byte(`
551+
project:
552+
name: "required-var-test"
553+
domain: "example.com"
554+
email: "test@example.com"
555+
server:
556+
host: "example.com"
557+
port: 22
558+
user: "user"
559+
ssh_key: "~/.ssh/id_rsa"
560+
561+
services:
562+
- name: "my-service"
563+
image: "my-service:latest"
564+
routes:
565+
- path: "/"
566+
port: 8080
567+
env:
568+
- "MUST_BE_SET=${MY_REQUIRED_VAR:?must be set!}"
569+
dependencies: []
570+
`)
571+
572+
config, err := ParseConfig(yamlData)
573+
574+
assert.Error(suite.T(), err)
575+
assert.Nil(suite.T(), config)
576+
// The error message should say "required environment variable MY_REQUIRED_VAR not set"
577+
assert.Contains(suite.T(), err.Error(), "required environment variable MY_REQUIRED_VAR not set")
578+
assert.Contains(suite.T(), err.Error(), "must be set!")
579+
}

pkg/config/default_configs.go

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ var defaultConfigs = map[string]Dependency{
99
Ports: []int{3306},
1010
Volumes: []string{"mysql_data:/var/lib/mysql"},
1111
Env: []string{
12-
"MYSQL_ROOT_PASSWORD=production-secret",
12+
"MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD}",
1313
},
1414
},
1515
"postgres": {
@@ -18,7 +18,7 @@ var defaultConfigs = map[string]Dependency{
1818
Ports: []int{5432},
1919
Volumes: []string{"postgres_data:/var/lib/postgresql/data"},
2020
Env: []string{
21-
"POSTGRES_PASSWORD=production-secret",
21+
"POSTGRES_PASSWORD=${POSTGRES_PASSWORD}",
2222
},
2323
},
2424
"postgresql": {
@@ -27,7 +27,7 @@ var defaultConfigs = map[string]Dependency{
2727
Ports: []int{5432},
2828
Volumes: []string{"postgres_data:/var/lib/postgresql/data"},
2929
Env: []string{
30-
"POSTGRES_PASSWORD=production-secret",
30+
"POSTGRES_PASSWORD=${POSTGRES_PASSWORD}",
3131
},
3232
},
3333
"elasticsearch": {
@@ -54,8 +54,8 @@ var defaultConfigs = map[string]Dependency{
5454
Ports: []int{27017},
5555
Volumes: []string{"mongo_data:/data/db"},
5656
Env: []string{
57-
"MONGO_INITDB_ROOT_USERNAME=root",
58-
"MONGO_INITDB_ROOT_PASSWORD=production-secret",
57+
"MONGO_INITDB_ROOT_USERNAME=${MONGO_ROOT_USER:mongouser}",
58+
"MONGO_INITDB_ROOT_PASSWORD=${MONGO_ROOT_PASSWORD}",
5959
},
6060
},
6161
"redis": {
@@ -70,9 +70,10 @@ var defaultConfigs = map[string]Dependency{
7070
Ports: []int{11211},
7171
},
7272
"rabbitmq": {
73-
Name: "rabbitmq",
74-
Image: "rabbitmq:latest",
75-
Ports: []int{5672, 15672}, // main + management
73+
Name: "rabbitmq",
74+
Image: "rabbitmq:latest",
75+
Ports: []int{5672, 15672}, // main + management
76+
Volumes: []string{"rabbitmq_data:/var/lib/rabbitmq"},
7677
Container: &Container{
7778
ULimits: []ULimit{
7879
{

0 commit comments

Comments
 (0)