Skip to content

Commit 44e8e9a

Browse files
authored
Image substitution (#3102)
* Refactor Testcontainers configuration to allow config by env var * Add Image substitution mechanism Builds upon #3021 and #3411: * adds a pluggable image substitution mechanism using ServiceLoader, enabling users to perform custom substitution/auditing of images being used by their tests * provides a default implementation that behaves similarly to legacy `TestcontainersConfiguration` approach (`testcontainers.properties`) Notes: * behaviour is similar but not quite identical to `TestcontainersConfiguration`: use of a configured custom image for, e.g. Kafka/Pulsar that does not have a tag specified causes the substitution to take effect for all usages. It seems very unlikely that people would be using a mix of the config file image overrides in some places _and_ specific images specified in code in others. * Duplication of default image names in modules vs `TestcontainersConfiguration` class is intentional: specifying image overrides in `testcontainers.properties` should be removed in the future. * ~Add log deprecation warnings when `testcontainers.properties` image overrides are used.~ Defer to a future release? * Remove extraneous change * Un-ignore docs example test by implementing a 'reversing' image name substitutor * Use configuration, not service loader, to select an ImageNameSubstitutor * Add check for order of config setting precedence * Extract classpath scanner and support finding of multiple resources * Introduce deterministic merging of classpath properties files * Update docs * Update docs * Remove service loader reference * Chain substitution through default and configured implementations * Small tweaks following review * Fix test compile error * Add UnstableAPI annotation * Move TestSpecificImageNameSubstitutor back to original package and remove duplicate use of default substitutor
1 parent 8d1a723 commit 44e8e9a

File tree

39 files changed

+936
-123
lines changed

39 files changed

+936
-123
lines changed

core/src/main/java/org/testcontainers/DockerClientFactory.java

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@
2525
import org.testcontainers.dockerclient.TransportConfig;
2626
import org.testcontainers.images.TimeLimitedLoggedPullImageResultCallback;
2727
import org.testcontainers.utility.ComparableVersion;
28+
import org.testcontainers.utility.DockerImageName;
29+
import org.testcontainers.utility.ImageNameSubstitutor;
2830
import org.testcontainers.utility.MountableFile;
2931
import org.testcontainers.utility.ResourceReaper;
3032
import org.testcontainers.utility.TestcontainersConfiguration;
@@ -61,7 +63,7 @@ public class DockerClientFactory {
6163
TESTCONTAINERS_SESSION_ID_LABEL, SESSION_ID
6264
);
6365

64-
private static final String TINY_IMAGE = TestcontainersConfiguration.getInstance().getTinyDockerImageName().asCanonicalNameString();
66+
private static final DockerImageName TINY_IMAGE = DockerImageName.parse("alpine:3.5");
6567
private static DockerClientFactory instance;
6668

6769
// Cached client configuration
@@ -343,8 +345,11 @@ public <T> T runInsideDocker(Consumer<CreateContainerCmd> createContainerCmdCons
343345
}
344346

345347
private <T> T runInsideDocker(DockerClient client, Consumer<CreateContainerCmd> createContainerCmdConsumer, BiFunction<DockerClient, String, T> block) {
346-
checkAndPullImage(client, TINY_IMAGE);
347-
CreateContainerCmd createContainerCmd = client.createContainerCmd(TINY_IMAGE)
348+
349+
final String tinyImage = ImageNameSubstitutor.instance().apply(TINY_IMAGE).asCanonicalNameString();
350+
351+
checkAndPullImage(client, tinyImage);
352+
CreateContainerCmd createContainerCmd = client.createContainerCmd(tinyImage)
348353
.withLabels(DEFAULT_LABELS);
349354
createContainerCmdConsumer.accept(createContainerCmd);
350355
String id = createContainerCmd.exec().getId();

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,11 @@
2929
import org.testcontainers.utility.AuditLogger;
3030
import org.testcontainers.utility.Base58;
3131
import org.testcontainers.utility.CommandLine;
32+
import org.testcontainers.utility.DockerImageName;
3233
import org.testcontainers.utility.DockerLoggerFactory;
3334
import org.testcontainers.utility.LogUtils;
3435
import org.testcontainers.utility.MountableFile;
3536
import org.testcontainers.utility.ResourceReaper;
36-
import org.testcontainers.utility.TestcontainersConfiguration;
3737
import org.zeroturnaround.exec.InvalidExitValueException;
3838
import org.zeroturnaround.exec.ProcessExecutor;
3939
import org.zeroturnaround.exec.stream.slf4j.Slf4jStream;
@@ -608,10 +608,11 @@ interface DockerCompose {
608608
class ContainerisedDockerCompose extends GenericContainer<ContainerisedDockerCompose> implements DockerCompose {
609609

610610
public static final char UNIX_PATH_SEPERATOR = ':';
611+
public static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("docker/compose:1.24.1");
611612

612613
public ContainerisedDockerCompose(List<File> composeFiles, String identifier) {
613614

614-
super(TestcontainersConfiguration.getInstance().getDockerComposeDockerImageName());
615+
super(DEFAULT_IMAGE_NAME);
615616
addEnv(ENV_PROJECT_NAME, identifier);
616617

617618
// Map the docker compose file into the container

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

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -239,13 +239,9 @@ public GenericContainer(@NonNull final RemoteDockerImage image) {
239239
*/
240240
@Deprecated
241241
public GenericContainer() {
242-
this(TestcontainersConfiguration.getInstance().getTinyDockerImageName().asCanonicalNameString());
242+
this(TestcontainersConfiguration.getInstance().getTinyImage());
243243
}
244244

245-
/**
246-
* @deprecated use {@link GenericContainer(DockerImageName)} instead
247-
*/
248-
@Deprecated
249245
public GenericContainer(@NonNull final String dockerImageName) {
250246
this.setDockerImageName(dockerImageName);
251247
}

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,15 @@
55
import lombok.AccessLevel;
66
import lombok.Getter;
77
import lombok.SneakyThrows;
8-
import org.testcontainers.utility.TestcontainersConfiguration;
8+
import org.testcontainers.utility.DockerImageName;
99

1010
import java.time.Duration;
1111
import java.util.AbstractMap;
1212
import java.util.Collections;
13+
import java.util.Map.Entry;
1314
import java.util.Optional;
1415
import java.util.Set;
1516
import java.util.UUID;
16-
import java.util.Map.Entry;
1717
import java.util.concurrent.ConcurrentHashMap;
1818

1919
public enum PortForwardingContainer {
@@ -29,7 +29,7 @@ public enum PortForwardingContainer {
2929
@SneakyThrows
3030
private Connection createSSHSession() {
3131
String password = UUID.randomUUID().toString();
32-
container = new GenericContainer<>(TestcontainersConfiguration.getInstance().getSSHdDockerImageName())
32+
container = new GenericContainer<>(DockerImageName.parse("testcontainers/sshd:1.0.0"))
3333
.withExposedPorts(22)
3434
.withEnv("PASSWORD", password)
3535
.withCommand(

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

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,10 @@
11
package org.testcontainers.containers;
22

3-
import org.testcontainers.utility.Base58;
4-
import org.testcontainers.utility.DockerImageName;
5-
import org.testcontainers.utility.TestcontainersConfiguration;
6-
73
import java.util.HashMap;
84
import java.util.Map;
95
import java.util.stream.Collectors;
6+
import org.testcontainers.utility.Base58;
7+
import org.testcontainers.utility.DockerImageName;
108

119
/**
1210
* A socat container is used as a TCP proxy, enabling any TCP port of another container to be exposed
@@ -17,7 +15,7 @@ public class SocatContainer extends GenericContainer<SocatContainer> {
1715
private final Map<Integer, String> targets = new HashMap<>();
1816

1917
public SocatContainer() {
20-
this(TestcontainersConfiguration.getInstance().getSocatDockerImageName());
18+
this(DockerImageName.parse("alpine/socat:1.7.3.4-r0"));
2119
}
2220

2321
public SocatContainer(final DockerImageName dockerImageName) {

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import lombok.ToString;
77
import org.apache.commons.compress.archivers.tar.TarArchiveInputStream;
88
import org.testcontainers.containers.wait.strategy.LogMessageWaitStrategy;
9-
import org.testcontainers.utility.TestcontainersConfiguration;
9+
import org.testcontainers.utility.DockerImageName;
1010

1111
import java.io.File;
1212
import java.io.InputStream;
@@ -52,7 +52,7 @@ public VncRecordingContainer(@NonNull GenericContainer<?> targetContainer) {
5252
* Create a sidekick container and attach it to another container. The VNC output of that container will be recorded.
5353
*/
5454
public VncRecordingContainer(@NonNull Network network, @NonNull String targetNetworkAlias) throws IllegalStateException {
55-
super(TestcontainersConfiguration.getInstance().getVncDockerImageName());
55+
super(DockerImageName.parse("testcontainers/vnc-recorder:1.1.0"));
5656

5757
this.targetNetworkAlias = targetNetworkAlias;
5858
withNetwork(network);

core/src/main/java/org/testcontainers/dockerclient/DockerClientProviderStrategy.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,7 @@ public static DockerClientProviderStrategy getFirstValidStrategy(List<DockerClie
171171
}
172172

173173
if (strategy.isPersistable()) {
174-
TestcontainersConfiguration.getInstance().updateGlobalConfig("docker.client.strategy", strategy.getClass().getName());
174+
TestcontainersConfiguration.getInstance().updateUserConfig("docker.client.strategy", strategy.getClass().getName());
175175
}
176176

177177
return Stream.of(strategy);

core/src/main/java/org/testcontainers/images/RemoteDockerImage.java

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import org.testcontainers.containers.ContainerFetchException;
1616
import org.testcontainers.utility.DockerImageName;
1717
import org.testcontainers.utility.DockerLoggerFactory;
18+
import org.testcontainers.utility.ImageNameSubstitutor;
1819
import org.testcontainers.utility.LazyFuture;
1920

2021
import java.time.Duration;
@@ -44,12 +45,12 @@ public RemoteDockerImage(DockerImageName dockerImageName) {
4445

4546
@Deprecated
4647
public RemoteDockerImage(String dockerImageName) {
47-
this.imageNameFuture = CompletableFuture.completedFuture(DockerImageName.parse(dockerImageName));
48+
this(DockerImageName.parse(dockerImageName));
4849
}
4950

5051
@Deprecated
5152
public RemoteDockerImage(@NonNull String repository, @NonNull String tag) {
52-
this.imageNameFuture = CompletableFuture.completedFuture(DockerImageName.parse(repository).withTag(tag));
53+
this(DockerImageName.parse(repository).withTag(tag));
5354
}
5455

5556
public RemoteDockerImage(@NonNull Future<String> imageFuture) {
@@ -100,7 +101,10 @@ protected final String resolve() {
100101
}
101102

102103
private DockerImageName getImageName() throws InterruptedException, ExecutionException {
103-
return imageNameFuture.get();
104+
final DockerImageName specifiedImageName = imageNameFuture.get();
105+
106+
// Allow the image name to be substituted
107+
return ImageNameSubstitutor.instance().apply(specifiedImageName);
104108
}
105109

106110
@ToString.Include(name = "imageName", rank = 1)
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package org.testcontainers.utility;
2+
3+
import lombok.extern.slf4j.Slf4j;
4+
import org.jetbrains.annotations.Nullable;
5+
import org.jetbrains.annotations.VisibleForTesting;
6+
7+
import java.net.URL;
8+
import java.util.Collections;
9+
import java.util.Comparator;
10+
import java.util.Objects;
11+
import java.util.stream.Stream;
12+
13+
/**
14+
* Utility for identifying resource files on classloaders.
15+
*/
16+
@Slf4j
17+
class ClasspathScanner {
18+
19+
@VisibleForTesting
20+
static Stream<URL> scanFor(final String name, ClassLoader... classLoaders) {
21+
return Stream
22+
.of(classLoaders)
23+
.flatMap(classLoader -> getAllPropertyFilesOnClassloader(classLoader, name))
24+
.filter(Objects::nonNull)
25+
.sorted(
26+
Comparator
27+
.comparing(ClasspathScanner::filesFileSchemeFirst) // resolve 'local' files first
28+
.thenComparing(URL::toString) // sort alphabetically for the sake of determinism
29+
)
30+
.distinct();
31+
}
32+
33+
private static Integer filesFileSchemeFirst(final URL t) {
34+
return t.getProtocol().equals("file") ? 0 : 1;
35+
}
36+
37+
/**
38+
* @param name the resource name to search for
39+
* @return distinct, ordered stream of resources found by searching this class' classloader and the current thread's
40+
* context classloader. Results are currently alphabetically sorted.
41+
*/
42+
static Stream<URL> scanFor(final String name) {
43+
return scanFor(
44+
name,
45+
ClasspathScanner.class.getClassLoader(),
46+
Thread.currentThread().getContextClassLoader()
47+
);
48+
}
49+
50+
@Nullable
51+
private static Stream<URL> getAllPropertyFilesOnClassloader(final ClassLoader it, final String s) {
52+
try {
53+
return Collections.list(it.getResources(s)).stream();
54+
} catch (Exception e) {
55+
log.error("Unable to read configuration from classloader {} - this is probably a bug", it, e);
56+
return Stream.empty();
57+
}
58+
}
59+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package org.testcontainers.utility;
2+
3+
import com.google.common.annotations.VisibleForTesting;
4+
import lombok.extern.slf4j.Slf4j;
5+
6+
/**
7+
* {@link ImageNameSubstitutor} which takes replacement image names from configuration.
8+
* See {@link TestcontainersConfiguration} for the subset of image names which can be substituted using this mechanism.
9+
*/
10+
@Slf4j
11+
final class ConfigurationFileImageNameSubstitutor extends ImageNameSubstitutor {
12+
13+
private final TestcontainersConfiguration configuration;
14+
15+
public ConfigurationFileImageNameSubstitutor() {
16+
this(TestcontainersConfiguration.getInstance());
17+
}
18+
19+
@VisibleForTesting
20+
ConfigurationFileImageNameSubstitutor(TestcontainersConfiguration configuration) {
21+
this.configuration = configuration;
22+
}
23+
24+
@Override
25+
public DockerImageName apply(final DockerImageName original) {
26+
final DockerImageName result = configuration
27+
.getConfiguredSubstituteImage(original)
28+
.asCompatibleSubstituteFor(original);
29+
30+
if (!result.equals(original)) {
31+
log.warn("Image name {} was substituted by configuration to {}. This approach is deprecated and will be removed in the future",
32+
original,
33+
result
34+
);
35+
}
36+
37+
return result;
38+
}
39+
40+
@Override
41+
protected String getDescription() {
42+
return getClass().getSimpleName();
43+
}
44+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package org.testcontainers.utility;
2+
3+
import com.google.common.annotations.VisibleForTesting;
4+
import lombok.extern.slf4j.Slf4j;
5+
6+
/**
7+
* Testcontainers' default implementation of {@link ImageNameSubstitutor}.
8+
* Delegates to {@link ConfigurationFileImageNameSubstitutor}.
9+
*/
10+
@Slf4j
11+
final class DefaultImageNameSubstitutor extends ImageNameSubstitutor {
12+
13+
private final ConfigurationFileImageNameSubstitutor configurationFileImageNameSubstitutor;
14+
15+
public DefaultImageNameSubstitutor() {
16+
configurationFileImageNameSubstitutor = new ConfigurationFileImageNameSubstitutor();
17+
}
18+
19+
@VisibleForTesting
20+
DefaultImageNameSubstitutor(
21+
final ConfigurationFileImageNameSubstitutor configurationFileImageNameSubstitutor
22+
) {
23+
this.configurationFileImageNameSubstitutor = configurationFileImageNameSubstitutor;
24+
}
25+
26+
@Override
27+
public DockerImageName apply(final DockerImageName original) {
28+
return configurationFileImageNameSubstitutor.apply(original);
29+
}
30+
31+
@Override
32+
protected String getDescription() {
33+
return "DefaultImageNameSubstitutor (" + configurationFileImageNameSubstitutor.getDescription() + ")";
34+
}
35+
}

0 commit comments

Comments
 (0)