@@ -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 .
122122func (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 {
0 commit comments