Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
* Representation of a docker-compose file, with partial parsing for validation and extraction of a minimal set of
Expand Down Expand Up @@ -144,7 +146,8 @@ private void validateNoContainerNameSpecified(String serviceName, Map<String, ?>
private void findServiceImageName(String serviceName, Map<String, ?> serviceDefinitionMap) {
Object result = serviceDefinitionMap.get("image");
if (result instanceof String) {
final String imageName = (String) result;
final String rawImageName = (String) result;
final String imageName = substituteEnvironmentVariables(rawImageName);
log.debug("Resolved dependency image for Docker Compose in {}: {}", composeFileName, imageName);
serviceNameToImageNames.put(serviceName, Sets.newHashSet(imageName));
}
Expand Down Expand Up @@ -192,4 +195,89 @@ private void findImageNamesInDockerfile(String serviceName, Map<String, ?> servi
}
}
}

/**
* Substitutes environment variables in a string following Docker Compose variable substitution rules.
* Supports patterns like ${VAR}, ${VAR:-default}, and $VAR.
*
* @param text the text containing variables to substitute
* @return the text with variables substituted with their environment values
*/
private String substituteEnvironmentVariables(String text) {
if (text == null) {
return null;
}

// Pattern for ${VAR} or ${VAR:-default} or ${VAR-default}
Pattern bracedPattern = Pattern.compile("\\$\\{([^}]+)\\}");
// Pattern for $VAR (word characters only)
Pattern simplePattern = Pattern.compile("\\$([a-zA-Z_][a-zA-Z0-9_]*)");

String result = text;

// Handle ${VAR} and ${VAR:-default} patterns first
Matcher bracedMatcher = bracedPattern.matcher(result);
StringBuffer sb = new StringBuffer();
while (bracedMatcher.find()) {
String varExpression = bracedMatcher.group(1);
String replacement = expandVariableExpression(varExpression);
bracedMatcher.appendReplacement(sb, Matcher.quoteReplacement(replacement));
}
bracedMatcher.appendTail(sb);
result = sb.toString();

// Handle $VAR patterns
Matcher simpleMatcher = simplePattern.matcher(result);
sb = new StringBuffer();
while (simpleMatcher.find()) {
String varName = simpleMatcher.group(1);
String value = getVariableValue(varName);
if (value != null) {
simpleMatcher.appendReplacement(sb, Matcher.quoteReplacement(value));
} else {
simpleMatcher.appendReplacement(sb, Matcher.quoteReplacement(simpleMatcher.group(0)));
}
}
simpleMatcher.appendTail(sb);

return sb.toString();
}

/**
* Gets the value of a variable, checking environment variables first, then system properties.
*/
private String getVariableValue(String varName) {
String value = System.getenv(varName);
if (value == null) {
value = System.getProperty(varName);
}
return value;
}

/**
* Expands a variable expression that may contain default values.
* Handles formats like "VAR", "VAR:-default", and "VAR-default".
*/
private String expandVariableExpression(String expression) {
// Check for default value patterns
if (expression.contains(":-")) {
// ${VAR:-default} - use default if VAR is unset or empty
String[] parts = expression.split(":-", 2);
String varName = parts[0];
String defaultValue = parts.length > 1 ? parts[1] : "";
String value = getVariableValue(varName);
return (value != null && !value.isEmpty()) ? value : defaultValue;
} else if (expression.contains("-")) {
// ${VAR-default} - use default if VAR is unset (but not if empty)
String[] parts = expression.split("-", 2);
String varName = parts[0];
String defaultValue = parts.length > 1 ? parts[1] : "";
String value = getVariableValue(varName);
return (value != null) ? value : defaultValue;
} else {
// Simple variable ${VAR}
String value = getVariableValue(expression);
return value != null ? value : "${" + expression + "}";
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package org.testcontainers.containers;

import org.junit.Test;
import org.testcontainers.containers.output.Slf4jLogConsumer;

import java.io.File;

import static org.assertj.core.api.Assertions.assertThatNoException;

/**
* Integration test for Docker Compose variable substitution.
* Tests the scenario described in the GitHub issue where image names with variables
* like ${TAG_CONFLUENT} were causing warnings.
*/
public class ComposeVariableSubstitutionIntegrationTest {

@Test
public void shouldHandleVariableSubstitutionWithoutWarnings() {
// Set up environment variable as if it was set by the user
System.setProperty("TAG_CONFLUENT", "7.0.0");
System.setProperty("REDIS_VERSION", "alpine");

try {
// This should not generate warnings about invalid image names
// because variable substitution should resolve ${TAG_CONFLUENT} to 7.0.0
assertThatNoException().isThrownBy(() -> {
try (ComposeContainer compose = new ComposeContainer(
new File("src/test/resources/docker-compose-variable-substitution.yml")
)
.withLocalCompose(true)
.withPull(false) // Don't actually pull images in test
.withLogConsumer("confluent", new Slf4jLogConsumer(org.slf4j.LoggerFactory.getLogger("confluent-test")))
) {
// Just validate the compose container can be created without exceptions
// The key test is that ParsedDockerComposeFile doesn't throw on variable substitution
// We're not actually starting the containers to avoid requiring Docker in the test
}
});
} finally {
// Clean up
System.clearProperty("TAG_CONFLUENT");
System.clearProperty("REDIS_VERSION");
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package org.testcontainers.containers;

import org.junit.Test;

import java.io.File;

import static org.assertj.core.api.Assertions.assertThat;

/**
* Test to demonstrate the fix for the original issue where variable substitution was not working.
*/
public class OriginalIssueDemo {

@Test
public void demonstrateOriginalIssueIsFixed() {
// Set the environment variable that was mentioned in the issue
System.setProperty("TAG_CONFLUENT", "7.0.0");

try {
// Parse the compose file that contains the problematic image name
ParsedDockerComposeFile parsedFile = new ParsedDockerComposeFile(
new File("src/test/resources/docker-compose-variable-substitution.yml")
);

// Before our fix, this would contain "confluentinc/cp-server:${TAG_CONFLUENT}"
// After our fix, this should contain "confluentinc/cp-server:7.0.0"
String actualImageName = parsedFile.getServiceNameToImageNames()
.get("confluent")
.iterator()
.next();

assertThat(actualImageName)
.as("Image name should have variable substituted")
.isEqualTo("confluentinc/cp-server:7.0.0")
.doesNotContain("${TAG_CONFLUENT}");

System.out.println("✅ SUCCESS: Variable substitution is working!");
System.out.println(" Original: confluentinc/cp-server:${TAG_CONFLUENT}");
System.out.println(" Resolved: " + actualImageName);

} finally {
System.clearProperty("TAG_CONFLUENT");
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -167,4 +167,114 @@ public void shouldSupportALotOfAliases() throws Exception {
}
assertThatNoException().isThrownBy(() -> new ParsedDockerComposeFile(file));
}

@Test
public void shouldSubstituteEnvironmentVariablesInImageNames() {
// Set up environment variables for testing
System.setProperty("TEST_IMAGE_TAG", "latest");
System.setProperty("TEST_REGISTRY", "my-registry.com");
System.setProperty("EMPTY_VAR", "");

try {
ParsedDockerComposeFile parsedFile = new ParsedDockerComposeFile(
ImmutableMap.of(
"version", "2",
"services", ImmutableMap.of(
"service1", ImmutableMap.of("image", "redis:${TEST_IMAGE_TAG}"),
"service2", ImmutableMap.of("image", "${TEST_REGISTRY}/app:${TEST_IMAGE_TAG}"),
"service3", ImmutableMap.of("image", "postgres:${MISSING_VAR:-default}"),
"service4", ImmutableMap.of("image", "mysql:${EMPTY_VAR:-fallback}"),
"service5", ImmutableMap.of("image", "nginx:${MISSING_VAR-alt}"),
"service6", ImmutableMap.of("image", "nginx:${UNDEFINED_VAR}")
)
)
);

assertThat(parsedFile.getServiceNameToImageNames())
.as("environment variables are substituted correctly")
.contains(
entry("service1", Sets.newHashSet("redis:latest")),
entry("service2", Sets.newHashSet("my-registry.com/app:latest")),
entry("service3", Sets.newHashSet("postgres:default")),
entry("service4", Sets.newHashSet("mysql:fallback")),
entry("service5", Sets.newHashSet("nginx:alt")),
entry("service6", Sets.newHashSet("nginx:${UNDEFINED_VAR}"))
);
} finally {
// Clean up
System.clearProperty("TEST_IMAGE_TAG");
System.clearProperty("TEST_REGISTRY");
System.clearProperty("EMPTY_VAR");
}
}

@Test
public void shouldSubstituteEnvironmentVariablesFromFile() {
// Set up environment variables for testing
System.setProperty("TAG_CONFLUENT", "7.0.0");
System.setProperty("REDIS_VERSION", ""); // Empty string to test :-default behavior

try {
File file = new File("src/test/resources/docker-compose-variable-substitution.yml");
ParsedDockerComposeFile parsedFile = new ParsedDockerComposeFile(file);

assertThat(parsedFile.getServiceNameToImageNames())
.as("environment variables from compose file are substituted correctly")
.contains(
entry("confluent", Sets.newHashSet("confluentinc/cp-server:7.0.0")),
entry("redis", Sets.newHashSet("redis:latest")), // :-default when empty
entry("mysql", Sets.newHashSet("mysql:8.0")) // -default when undefined
);
} finally {
// Clean up
System.clearProperty("TAG_CONFLUENT");
System.clearProperty("REDIS_VERSION");
}
}

@Test
public void shouldHandleEdgeCasesInVariableSubstitution() {
// Test various edge cases
System.setProperty("SIMPLE_VAR", "simple-value");
System.setProperty("WITH_SPECIAL_CHARS", "registry.io/app:v1.2.3");
System.setProperty("EMPTY_VAR", "");

try {
ParsedDockerComposeFile parsedFile = new ParsedDockerComposeFile(
ImmutableMap.of(
"version", "2",
"services", ImmutableMap.of(
// Test $VAR syntax
"service1", ImmutableMap.of("image", "redis:$SIMPLE_VAR"),
// Test complex image names with registry
"service2", ImmutableMap.of("image", "${WITH_SPECIAL_CHARS}"),
// Test multiple variables in one string
"service3", ImmutableMap.of("image", "${WITH_SPECIAL_CHARS}-${SIMPLE_VAR}"),
// Test undefined variables remain unchanged
"service4", ImmutableMap.of("image", "app:${UNDEFINED_VAR}"),
// Test variables with special characters that don't need substitution
"service5", ImmutableMap.of("image", "app:latest"),
// Test empty variable with default
"service6", ImmutableMap.of("image", "nginx:${EMPTY_VAR:-default}")
)
)
);

assertThat(parsedFile.getServiceNameToImageNames())
.as("edge cases in variable substitution work correctly")
.contains(
entry("service1", Sets.newHashSet("redis:simple-value")),
entry("service2", Sets.newHashSet("registry.io/app:v1.2.3")),
entry("service3", Sets.newHashSet("registry.io/app:v1.2.3-simple-value")),
entry("service4", Sets.newHashSet("app:${UNDEFINED_VAR}")),
entry("service5", Sets.newHashSet("app:latest")),
entry("service6", Sets.newHashSet("nginx:default"))
);
} finally {
// Clean up
System.clearProperty("SIMPLE_VAR");
System.clearProperty("WITH_SPECIAL_CHARS");
System.clearProperty("EMPTY_VAR");
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
version: "2.1"
services:
confluent:
image: confluentinc/cp-server:${TAG_CONFLUENT}
redis:
image: redis:${REDIS_VERSION:-latest}
mysql:
image: mysql:${MYSQL_VERSION-8.0}
networks:
custom_network: {}
Loading