Skip to content

Commit a5037e4

Browse files
Copilotkiview
andcommitted
Implement environment variable substitution in ParsedDockerComposeFile
Co-authored-by: kiview <[email protected]>
1 parent d902753 commit a5037e4

File tree

3 files changed

+163
-1
lines changed

3 files changed

+163
-1
lines changed

core/src/main/java/org/testcontainers/containers/ParsedDockerComposeFile.java

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
import java.util.HashMap;
2424
import java.util.Map;
2525
import java.util.Set;
26+
import java.util.regex.Matcher;
27+
import java.util.regex.Pattern;
2628

2729
/**
2830
* Representation of a docker-compose file, with partial parsing for validation and extraction of a minimal set of
@@ -144,7 +146,8 @@ private void validateNoContainerNameSpecified(String serviceName, Map<String, ?>
144146
private void findServiceImageName(String serviceName, Map<String, ?> serviceDefinitionMap) {
145147
Object result = serviceDefinitionMap.get("image");
146148
if (result instanceof String) {
147-
final String imageName = (String) result;
149+
final String rawImageName = (String) result;
150+
final String imageName = substituteEnvironmentVariables(rawImageName);
148151
log.debug("Resolved dependency image for Docker Compose in {}: {}", composeFileName, imageName);
149152
serviceNameToImageNames.put(serviceName, Sets.newHashSet(imageName));
150153
}
@@ -192,4 +195,89 @@ private void findImageNamesInDockerfile(String serviceName, Map<String, ?> servi
192195
}
193196
}
194197
}
198+
199+
/**
200+
* Substitutes environment variables in a string following Docker Compose variable substitution rules.
201+
* Supports patterns like ${VAR}, ${VAR:-default}, and $VAR.
202+
*
203+
* @param text the text containing variables to substitute
204+
* @return the text with variables substituted with their environment values
205+
*/
206+
private String substituteEnvironmentVariables(String text) {
207+
if (text == null) {
208+
return null;
209+
}
210+
211+
// Pattern for ${VAR} or ${VAR:-default} or ${VAR-default}
212+
Pattern bracedPattern = Pattern.compile("\\$\\{([^}]+)\\}");
213+
// Pattern for $VAR (word characters only)
214+
Pattern simplePattern = Pattern.compile("\\$([a-zA-Z_][a-zA-Z0-9_]*)");
215+
216+
String result = text;
217+
218+
// Handle ${VAR} and ${VAR:-default} patterns first
219+
Matcher bracedMatcher = bracedPattern.matcher(result);
220+
StringBuffer sb = new StringBuffer();
221+
while (bracedMatcher.find()) {
222+
String varExpression = bracedMatcher.group(1);
223+
String replacement = expandVariableExpression(varExpression);
224+
bracedMatcher.appendReplacement(sb, Matcher.quoteReplacement(replacement));
225+
}
226+
bracedMatcher.appendTail(sb);
227+
result = sb.toString();
228+
229+
// Handle $VAR patterns
230+
Matcher simpleMatcher = simplePattern.matcher(result);
231+
sb = new StringBuffer();
232+
while (simpleMatcher.find()) {
233+
String varName = simpleMatcher.group(1);
234+
String value = getVariableValue(varName);
235+
if (value != null) {
236+
simpleMatcher.appendReplacement(sb, Matcher.quoteReplacement(value));
237+
} else {
238+
simpleMatcher.appendReplacement(sb, Matcher.quoteReplacement(simpleMatcher.group(0)));
239+
}
240+
}
241+
simpleMatcher.appendTail(sb);
242+
243+
return sb.toString();
244+
}
245+
246+
/**
247+
* Gets the value of a variable, checking environment variables first, then system properties.
248+
*/
249+
private String getVariableValue(String varName) {
250+
String value = System.getenv(varName);
251+
if (value == null) {
252+
value = System.getProperty(varName);
253+
}
254+
return value;
255+
}
256+
257+
/**
258+
* Expands a variable expression that may contain default values.
259+
* Handles formats like "VAR", "VAR:-default", and "VAR-default".
260+
*/
261+
private String expandVariableExpression(String expression) {
262+
// Check for default value patterns
263+
if (expression.contains(":-")) {
264+
// ${VAR:-default} - use default if VAR is unset or empty
265+
String[] parts = expression.split(":-", 2);
266+
String varName = parts[0];
267+
String defaultValue = parts.length > 1 ? parts[1] : "";
268+
String value = getVariableValue(varName);
269+
return (value != null && !value.isEmpty()) ? value : defaultValue;
270+
} else if (expression.contains("-")) {
271+
// ${VAR-default} - use default if VAR is unset (but not if empty)
272+
String[] parts = expression.split("-", 2);
273+
String varName = parts[0];
274+
String defaultValue = parts.length > 1 ? parts[1] : "";
275+
String value = getVariableValue(varName);
276+
return (value != null) ? value : defaultValue;
277+
} else {
278+
// Simple variable ${VAR}
279+
String value = getVariableValue(expression);
280+
return value != null ? value : "${" + expression + "}";
281+
}
282+
}
195283
}

core/src/test/java/org/testcontainers/containers/ParsedDockerComposeFileValidationTest.java

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,4 +167,68 @@ public void shouldSupportALotOfAliases() throws Exception {
167167
}
168168
assertThatNoException().isThrownBy(() -> new ParsedDockerComposeFile(file));
169169
}
170+
171+
@Test
172+
public void shouldSubstituteEnvironmentVariablesInImageNames() {
173+
// Set up environment variables for testing
174+
System.setProperty("TEST_IMAGE_TAG", "latest");
175+
System.setProperty("TEST_REGISTRY", "my-registry.com");
176+
System.setProperty("EMPTY_VAR", "");
177+
178+
try {
179+
ParsedDockerComposeFile parsedFile = new ParsedDockerComposeFile(
180+
ImmutableMap.of(
181+
"version", "2",
182+
"services", ImmutableMap.of(
183+
"service1", ImmutableMap.of("image", "redis:${TEST_IMAGE_TAG}"),
184+
"service2", ImmutableMap.of("image", "${TEST_REGISTRY}/app:${TEST_IMAGE_TAG}"),
185+
"service3", ImmutableMap.of("image", "postgres:${MISSING_VAR:-default}"),
186+
"service4", ImmutableMap.of("image", "mysql:${EMPTY_VAR:-fallback}"),
187+
"service5", ImmutableMap.of("image", "nginx:${MISSING_VAR-alt}"),
188+
"service6", ImmutableMap.of("image", "nginx:${UNDEFINED_VAR}")
189+
)
190+
)
191+
);
192+
193+
assertThat(parsedFile.getServiceNameToImageNames())
194+
.as("environment variables are substituted correctly")
195+
.contains(
196+
entry("service1", Sets.newHashSet("redis:latest")),
197+
entry("service2", Sets.newHashSet("my-registry.com/app:latest")),
198+
entry("service3", Sets.newHashSet("postgres:default")),
199+
entry("service4", Sets.newHashSet("mysql:fallback")),
200+
entry("service5", Sets.newHashSet("nginx:alt")),
201+
entry("service6", Sets.newHashSet("nginx:${UNDEFINED_VAR}"))
202+
);
203+
} finally {
204+
// Clean up
205+
System.clearProperty("TEST_IMAGE_TAG");
206+
System.clearProperty("TEST_REGISTRY");
207+
System.clearProperty("EMPTY_VAR");
208+
}
209+
}
210+
211+
@Test
212+
public void shouldSubstituteEnvironmentVariablesFromFile() {
213+
// Set up environment variables for testing
214+
System.setProperty("TAG_CONFLUENT", "7.0.0");
215+
System.setProperty("REDIS_VERSION", ""); // Empty string to test :-default behavior
216+
217+
try {
218+
File file = new File("src/test/resources/docker-compose-variable-substitution.yml");
219+
ParsedDockerComposeFile parsedFile = new ParsedDockerComposeFile(file);
220+
221+
assertThat(parsedFile.getServiceNameToImageNames())
222+
.as("environment variables from compose file are substituted correctly")
223+
.contains(
224+
entry("confluent", Sets.newHashSet("confluentinc/cp-server:7.0.0")),
225+
entry("redis", Sets.newHashSet("redis:latest")), // :-default when empty
226+
entry("mysql", Sets.newHashSet("mysql:8.0")) // -default when undefined
227+
);
228+
} finally {
229+
// Clean up
230+
System.clearProperty("TAG_CONFLUENT");
231+
System.clearProperty("REDIS_VERSION");
232+
}
233+
}
170234
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
version: "2.1"
2+
services:
3+
confluent:
4+
image: confluentinc/cp-server:${TAG_CONFLUENT}
5+
redis:
6+
image: redis:${REDIS_VERSION:-latest}
7+
mysql:
8+
image: mysql:${MYSQL_VERSION-8.0}
9+
networks:
10+
custom_network: {}

0 commit comments

Comments
 (0)