diff --git a/core/src/main/java/org/testcontainers/containers/ParsedDockerComposeFile.java b/core/src/main/java/org/testcontainers/containers/ParsedDockerComposeFile.java index d797a86bfbf..3cd46d34585 100644 --- a/core/src/main/java/org/testcontainers/containers/ParsedDockerComposeFile.java +++ b/core/src/main/java/org/testcontainers/containers/ParsedDockerComposeFile.java @@ -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 @@ -144,7 +146,8 @@ private void validateNoContainerNameSpecified(String serviceName, Map private void findServiceImageName(String serviceName, Map 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)); } @@ -192,4 +195,89 @@ private void findImageNamesInDockerfile(String serviceName, Map 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 + "}"; + } + } } diff --git a/core/src/test/java/org/testcontainers/containers/ComposeVariableSubstitutionIntegrationTest.java b/core/src/test/java/org/testcontainers/containers/ComposeVariableSubstitutionIntegrationTest.java new file mode 100644 index 00000000000..86c9e9cd62a --- /dev/null +++ b/core/src/test/java/org/testcontainers/containers/ComposeVariableSubstitutionIntegrationTest.java @@ -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"); + } + } +} \ No newline at end of file diff --git a/core/src/test/java/org/testcontainers/containers/OriginalIssueDemo.java b/core/src/test/java/org/testcontainers/containers/OriginalIssueDemo.java new file mode 100644 index 00000000000..acbfab5fbaf --- /dev/null +++ b/core/src/test/java/org/testcontainers/containers/OriginalIssueDemo.java @@ -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"); + } + } +} \ No newline at end of file diff --git a/core/src/test/java/org/testcontainers/containers/ParsedDockerComposeFileValidationTest.java b/core/src/test/java/org/testcontainers/containers/ParsedDockerComposeFileValidationTest.java index 9067d77d825..4785323edc3 100644 --- a/core/src/test/java/org/testcontainers/containers/ParsedDockerComposeFileValidationTest.java +++ b/core/src/test/java/org/testcontainers/containers/ParsedDockerComposeFileValidationTest.java @@ -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"); + } + } } diff --git a/core/src/test/resources/docker-compose-variable-substitution.yml b/core/src/test/resources/docker-compose-variable-substitution.yml new file mode 100644 index 00000000000..c5ed4786ccc --- /dev/null +++ b/core/src/test/resources/docker-compose-variable-substitution.yml @@ -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: {} \ No newline at end of file