diff --git a/core/src/main/java/org/testcontainers/DockerClientFactory.java b/core/src/main/java/org/testcontainers/DockerClientFactory.java index 571b18fbcce..d44cd3b5b6f 100644 --- a/core/src/main/java/org/testcontainers/DockerClientFactory.java +++ b/core/src/main/java/org/testcontainers/DockerClientFactory.java @@ -25,6 +25,8 @@ import org.testcontainers.dockerclient.TransportConfig; import org.testcontainers.images.TimeLimitedLoggedPullImageResultCallback; import org.testcontainers.utility.ComparableVersion; +import org.testcontainers.utility.DockerImageName; +import org.testcontainers.utility.ImageNameSubstitutor; import org.testcontainers.utility.MountableFile; import org.testcontainers.utility.ResourceReaper; import org.testcontainers.utility.TestcontainersConfiguration; @@ -61,7 +63,7 @@ public class DockerClientFactory { TESTCONTAINERS_SESSION_ID_LABEL, SESSION_ID ); - private static final String TINY_IMAGE = TestcontainersConfiguration.getInstance().getTinyDockerImageName().asCanonicalNameString(); + private static final DockerImageName TINY_IMAGE = DockerImageName.parse("alpine:3.5"); private static DockerClientFactory instance; // Cached client configuration @@ -343,8 +345,11 @@ public T runInsideDocker(Consumer createContainerCmdCons } private T runInsideDocker(DockerClient client, Consumer createContainerCmdConsumer, BiFunction block) { - checkAndPullImage(client, TINY_IMAGE); - CreateContainerCmd createContainerCmd = client.createContainerCmd(TINY_IMAGE) + + final String tinyImage = ImageNameSubstitutor.instance().apply(TINY_IMAGE).asCanonicalNameString(); + + checkAndPullImage(client, tinyImage); + CreateContainerCmd createContainerCmd = client.createContainerCmd(tinyImage) .withLabels(DEFAULT_LABELS); createContainerCmdConsumer.accept(createContainerCmd); String id = createContainerCmd.exec().getId(); diff --git a/core/src/main/java/org/testcontainers/containers/DockerComposeContainer.java b/core/src/main/java/org/testcontainers/containers/DockerComposeContainer.java index bd06083644d..7a67073e383 100644 --- a/core/src/main/java/org/testcontainers/containers/DockerComposeContainer.java +++ b/core/src/main/java/org/testcontainers/containers/DockerComposeContainer.java @@ -29,11 +29,11 @@ import org.testcontainers.utility.AuditLogger; import org.testcontainers.utility.Base58; import org.testcontainers.utility.CommandLine; +import org.testcontainers.utility.DockerImageName; import org.testcontainers.utility.DockerLoggerFactory; import org.testcontainers.utility.LogUtils; import org.testcontainers.utility.MountableFile; import org.testcontainers.utility.ResourceReaper; -import org.testcontainers.utility.TestcontainersConfiguration; import org.zeroturnaround.exec.InvalidExitValueException; import org.zeroturnaround.exec.ProcessExecutor; import org.zeroturnaround.exec.stream.slf4j.Slf4jStream; @@ -608,10 +608,11 @@ interface DockerCompose { class ContainerisedDockerCompose extends GenericContainer implements DockerCompose { public static final char UNIX_PATH_SEPERATOR = ':'; + public static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("docker/compose:1.24.1"); public ContainerisedDockerCompose(List composeFiles, String identifier) { - super(TestcontainersConfiguration.getInstance().getDockerComposeDockerImageName()); + super(DEFAULT_IMAGE_NAME); addEnv(ENV_PROJECT_NAME, identifier); // Map the docker compose file into the container diff --git a/core/src/main/java/org/testcontainers/containers/PortForwardingContainer.java b/core/src/main/java/org/testcontainers/containers/PortForwardingContainer.java index e42f2681675..b3f020500a1 100644 --- a/core/src/main/java/org/testcontainers/containers/PortForwardingContainer.java +++ b/core/src/main/java/org/testcontainers/containers/PortForwardingContainer.java @@ -5,15 +5,15 @@ import lombok.AccessLevel; import lombok.Getter; import lombok.SneakyThrows; -import org.testcontainers.utility.TestcontainersConfiguration; +import org.testcontainers.utility.DockerImageName; import java.time.Duration; import java.util.AbstractMap; import java.util.Collections; +import java.util.Map.Entry; import java.util.Optional; import java.util.Set; import java.util.UUID; -import java.util.Map.Entry; import java.util.concurrent.ConcurrentHashMap; public enum PortForwardingContainer { @@ -29,7 +29,7 @@ public enum PortForwardingContainer { @SneakyThrows private Connection createSSHSession() { String password = UUID.randomUUID().toString(); - container = new GenericContainer<>(TestcontainersConfiguration.getInstance().getSSHdDockerImageName()) + container = new GenericContainer<>(DockerImageName.parse("testcontainers/sshd:1.0.0")) .withExposedPorts(22) .withEnv("PASSWORD", password) .withCommand( diff --git a/core/src/main/java/org/testcontainers/containers/SocatContainer.java b/core/src/main/java/org/testcontainers/containers/SocatContainer.java index fbca73eb29d..6660dc3bf9b 100644 --- a/core/src/main/java/org/testcontainers/containers/SocatContainer.java +++ b/core/src/main/java/org/testcontainers/containers/SocatContainer.java @@ -17,7 +17,7 @@ public class SocatContainer extends GenericContainer { private final Map targets = new HashMap<>(); public SocatContainer() { - this(TestcontainersConfiguration.getInstance().getSocatDockerImageName()); + this(DockerImageName.parse("alpine/socat:latest")); } public SocatContainer(final DockerImageName dockerImageName) { diff --git a/core/src/main/java/org/testcontainers/containers/VncRecordingContainer.java b/core/src/main/java/org/testcontainers/containers/VncRecordingContainer.java index 8ac060c2af6..de720110a51 100644 --- a/core/src/main/java/org/testcontainers/containers/VncRecordingContainer.java +++ b/core/src/main/java/org/testcontainers/containers/VncRecordingContainer.java @@ -6,7 +6,7 @@ import lombok.ToString; import org.apache.commons.compress.archivers.tar.TarArchiveInputStream; import org.testcontainers.containers.wait.strategy.LogMessageWaitStrategy; -import org.testcontainers.utility.TestcontainersConfiguration; +import org.testcontainers.utility.DockerImageName; import java.io.File; import java.io.InputStream; @@ -52,7 +52,7 @@ public VncRecordingContainer(@NonNull GenericContainer targetContainer) { * Create a sidekick container and attach it to another container. The VNC output of that container will be recorded. */ public VncRecordingContainer(@NonNull Network network, @NonNull String targetNetworkAlias) throws IllegalStateException { - super(TestcontainersConfiguration.getInstance().getVncDockerImageName()); + super(DockerImageName.parse("testcontainers/vnc-recorder:1.1.0")); this.targetNetworkAlias = targetNetworkAlias; withNetwork(network); diff --git a/core/src/main/java/org/testcontainers/images/RemoteDockerImage.java b/core/src/main/java/org/testcontainers/images/RemoteDockerImage.java index 8d3a77c5680..f4b04712913 100644 --- a/core/src/main/java/org/testcontainers/images/RemoteDockerImage.java +++ b/core/src/main/java/org/testcontainers/images/RemoteDockerImage.java @@ -15,6 +15,7 @@ import org.testcontainers.containers.ContainerFetchException; import org.testcontainers.utility.DockerImageName; import org.testcontainers.utility.DockerLoggerFactory; +import org.testcontainers.utility.ImageNameSubstitutor; import org.testcontainers.utility.LazyFuture; import java.time.Duration; @@ -44,12 +45,12 @@ public RemoteDockerImage(DockerImageName dockerImageName) { @Deprecated public RemoteDockerImage(String dockerImageName) { - this.imageNameFuture = CompletableFuture.completedFuture(DockerImageName.parse(dockerImageName)); + this(DockerImageName.parse(dockerImageName)); } @Deprecated public RemoteDockerImage(@NonNull String repository, @NonNull String tag) { - this.imageNameFuture = CompletableFuture.completedFuture(DockerImageName.parse(repository).withTag(tag)); + this(DockerImageName.parse(repository).withTag(tag)); } public RemoteDockerImage(@NonNull Future imageFuture) { @@ -100,7 +101,10 @@ protected final String resolve() { } private DockerImageName getImageName() throws InterruptedException, ExecutionException { - return imageNameFuture.get(); + final DockerImageName specifiedImageName = imageNameFuture.get(); + + // Allow the image name to be substituted + return ImageNameSubstitutor.instance().apply(specifiedImageName); } @ToString.Include(name = "imageName", rank = 1) diff --git a/core/src/main/java/org/testcontainers/utility/DefaultImageNameSubstitutor.java b/core/src/main/java/org/testcontainers/utility/DefaultImageNameSubstitutor.java new file mode 100644 index 00000000000..5bb0d08c88b --- /dev/null +++ b/core/src/main/java/org/testcontainers/utility/DefaultImageNameSubstitutor.java @@ -0,0 +1,34 @@ +package org.testcontainers.utility; + +import com.google.common.annotations.VisibleForTesting; +import lombok.extern.slf4j.Slf4j; + +/** + * TODO: Javadocs + */ +@Slf4j +public class DefaultImageNameSubstitutor extends ImageNameSubstitutor { + + private final TestcontainersConfiguration configuration; + + public DefaultImageNameSubstitutor() { + this(TestcontainersConfiguration.getInstance()); + } + + @VisibleForTesting + DefaultImageNameSubstitutor(TestcontainersConfiguration configuration) { + this.configuration = configuration; + } + + @Override + public DockerImageName apply(final DockerImageName original) { + return configuration + .getConfiguredSubstituteImage(original) + .asCompatibleSubstituteFor(original); + } + + @Override + protected int getPriority() { + return 0; + } +} diff --git a/core/src/main/java/org/testcontainers/utility/DockerImageName.java b/core/src/main/java/org/testcontainers/utility/DockerImageName.java index af8e5300863..e62ecf8e021 100644 --- a/core/src/main/java/org/testcontainers/utility/DockerImageName.java +++ b/core/src/main/java/org/testcontainers/utility/DockerImageName.java @@ -2,16 +2,13 @@ import com.google.common.net.HostAndPort; +import java.util.regex.Pattern; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.EqualsAndHashCode; import lombok.With; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -import org.testcontainers.utility.Versioning.Sha256Versioning; -import org.testcontainers.utility.Versioning.TagVersioning; - -import java.util.regex.Pattern; @EqualsAndHashCode(exclude = { "rawName", "compatibleSubstituteFor" }) @AllArgsConstructor(access = AccessLevel.PRIVATE) @@ -26,7 +23,7 @@ public final class DockerImageName { private final String rawName; private final String registry; private final String repo; - @NotNull @With(AccessLevel.PRIVATE) + @Nullable @With(AccessLevel.PRIVATE) private final Versioning versioning; @Nullable @With(AccessLevel.PRIVATE) private final DockerImageName compatibleSubstituteFor; @@ -69,10 +66,10 @@ public DockerImageName(String fullImageName) { if (remoteName.contains("@sha256:")) { repo = remoteName.split("@sha256:")[0]; - versioning = new Sha256Versioning(remoteName.split("@sha256:")[1]); + versioning = new Versioning.Sha256Versioning(remoteName.split("@sha256:")[1]); } else if (remoteName.contains(":")) { repo = remoteName.split(":")[0]; - versioning = new TagVersioning(remoteName.split(":")[1]); + versioning = new Versioning.TagVersioning(remoteName.split(":")[1]); } else { repo = remoteName; versioning = Versioning.ANY; @@ -111,10 +108,10 @@ public DockerImageName(String nameWithoutTag, @NotNull String version) { if (version.startsWith("sha256:")) { repo = remoteName; - versioning = new Sha256Versioning(version.replace("sha256:", "")); + versioning = new Versioning.Sha256Versioning(version.replace("sha256:", "")); } else { repo = remoteName; - versioning = new TagVersioning(version); + versioning = new Versioning.TagVersioning(version); } compatibleSubstituteFor = null; @@ -135,7 +132,7 @@ public String getUnversionedPart() { * @return the versioned part of this name (tag or sha256) */ public String getVersionPart() { - return versioning.toString(); + return versioning == null ? "latest" : versioning.toString(); } /** @@ -161,7 +158,7 @@ public void assertValid() { if (!REPO_NAME.matcher(repo).matches()) { throw new IllegalArgumentException(repo + " is not a valid Docker image name (in " + rawName + ")"); } - if (!versioning.isValid()) { + if (versioning != null && !versioning.isValid()) { throw new IllegalArgumentException(versioning + " is not a valid image versioning identifier (in " + rawName + ")"); } } @@ -175,7 +172,7 @@ public String getRegistry() { * @return an immutable copy of this {@link DockerImageName} with the new version tag */ public DockerImageName withTag(final String newTag) { - return withVersioning(new TagVersioning(newTag)); + return withVersioning(new Versioning.TagVersioning(newTag)); } /** diff --git a/core/src/main/java/org/testcontainers/utility/ImageNameSubstitutor.java b/core/src/main/java/org/testcontainers/utility/ImageNameSubstitutor.java new file mode 100644 index 00000000000..9101943e5e1 --- /dev/null +++ b/core/src/main/java/org/testcontainers/utility/ImageNameSubstitutor.java @@ -0,0 +1,85 @@ +package org.testcontainers.utility; + +import com.google.common.annotations.VisibleForTesting; +import lombok.extern.slf4j.Slf4j; + +import java.util.ServiceLoader; +import java.util.function.Function; +import java.util.stream.StreamSupport; + +import static java.util.Comparator.comparingInt; + +/** + * TODO: Javadocs + */ +@Slf4j +public abstract class ImageNameSubstitutor implements Function { + + @VisibleForTesting + static ImageNameSubstitutor instance; + + public synchronized static ImageNameSubstitutor instance() { + if (instance == null) { + final ServiceLoader serviceLoader = ServiceLoader.load(ImageNameSubstitutor.class); + + instance = StreamSupport.stream(serviceLoader.spliterator(), false) + .peek(it -> log.debug("Found ImageNameSubstitutor using ServiceLoader: {} (priority {}) ", it, it.getPriority())) + .max(comparingInt(ImageNameSubstitutor::getPriority)) + .map(ImageNameSubstitutor::wrapWithLogging) + .orElseThrow(() -> new RuntimeException("Unable to find any ImageNameSubstitutor using ServiceLoader")); + + log.info("Using ImageNameSubstitutor: {}", instance); + } + + return instance; + } + + private static ImageNameSubstitutor wrapWithLogging(final ImageNameSubstitutor wrappedInstance) { + return new LogWrappedImageNameSubstitutor(wrappedInstance); + } + + /** + * Substitute a {@link DockerImageName} for another, for example to replace a generic Docker Hub image name with a + * private registry copy of the image. + * + * @param original original name to be replaced + * @return a replacement name, or the original, as appropriate + */ + public abstract DockerImageName apply(DockerImageName original); + + /** + * Priority of this {@link ImageNameSubstitutor} compared to other instances that may be found by the service + * loader. The highest priority instance found will always be used. + * + * @return a priority + */ + protected abstract int getPriority(); + + static class LogWrappedImageNameSubstitutor extends ImageNameSubstitutor { + @VisibleForTesting + final ImageNameSubstitutor wrappedInstance; + + public LogWrappedImageNameSubstitutor(final ImageNameSubstitutor wrappedInstance) { + this.wrappedInstance = wrappedInstance; + } + + @Override + public DockerImageName apply(final DockerImageName original) { + final String className = wrappedInstance.getClass().getName(); + final DockerImageName replacementImage = wrappedInstance.apply(original); + + if (!replacementImage.equals(original)) { + log.info("Using {} as a substitute image for {} (using image substitutor: {})", replacementImage.asCanonicalNameString(), original.asCanonicalNameString(), className); + return replacementImage; + } else { + log.debug("Did not find a substitute image for {} (using image substitutor: {})", original.asCanonicalNameString(), className); + return original; + } + } + + @Override + protected int getPriority() { + return wrappedInstance.getPriority(); + } + } +} diff --git a/core/src/main/java/org/testcontainers/utility/ResourceReaper.java b/core/src/main/java/org/testcontainers/utility/ResourceReaper.java index c37e6660654..3b0cfa1e289 100644 --- a/core/src/main/java/org/testcontainers/utility/ResourceReaper.java +++ b/core/src/main/java/org/testcontainers/utility/ResourceReaper.java @@ -72,7 +72,9 @@ private ResourceReaper() { @SneakyThrows(InterruptedException.class) public static String start(String hostIpAddress, DockerClient client) { - String ryukImage = TestcontainersConfiguration.getInstance().getRyukDockerImageName().asCanonicalNameString(); + String ryukImage = ImageNameSubstitutor.instance() + .apply(DockerImageName.parse("testcontainers/ryuk:0.3.0")) + .asCanonicalNameString(); DockerClientFactory.instance().checkAndPullImage(client, ryukImage); List binds = new ArrayList<>(); diff --git a/core/src/main/java/org/testcontainers/utility/TestcontainersConfiguration.java b/core/src/main/java/org/testcontainers/utility/TestcontainersConfiguration.java index 521a7519c2a..fe8032cc1e6 100644 --- a/core/src/main/java/org/testcontainers/utility/TestcontainersConfiguration.java +++ b/core/src/main/java/org/testcontainers/utility/TestcontainersConfiguration.java @@ -1,16 +1,7 @@ package org.testcontainers.utility; import com.google.common.annotations.VisibleForTesting; -import lombok.AccessLevel; -import lombok.Data; -import lombok.Getter; -import lombok.NonNull; -import lombok.RequiredArgsConstructor; -import lombok.SneakyThrows; -import lombok.Synchronized; -import lombok.extern.slf4j.Slf4j; -import org.testcontainers.UnstableAPI; - +import com.google.common.collect.ImmutableMap; import java.io.File; import java.io.FileNotFoundException; import java.io.FileOutputStream; @@ -19,10 +10,21 @@ import java.io.OutputStream; import java.net.MalformedURLException; import java.net.URL; +import java.util.Map; import java.util.Objects; +import java.util.Optional; import java.util.Properties; import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Stream; +import lombok.AccessLevel; +import lombok.Data; +import lombok.Getter; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import lombok.SneakyThrows; +import lombok.Synchronized; +import lombok.extern.slf4j.Slf4j; +import org.testcontainers.UnstableAPI; /** * Provides a mechanism for fetching configuration/defaults from the classpath. @@ -36,6 +38,30 @@ public class TestcontainersConfiguration { private static File ENVIRONMENT_CONFIG_FILE = new File(System.getProperty("user.home"), "." + PROPERTIES_FILE_NAME); + private static final String AMBASSADOR_IMAGE = "richnorth/ambassador:latest"; + private static final String SOCAT_IMAGE = "alpine/socat:latest"; + private static final String VNC_RECORDER_IMAGE = "testcontainers/vnc-recorder:1.1.0"; + private static final String COMPOSE_IMAGE = "docker/compose:1.24.1"; + private static final String ALPINE_IMAGE = "alpine:3.5"; + private static final String RYUK_IMAGE = "testcontainers/ryuk:0.3.0"; + private static final String KAFKA_IMAGE = "confluentinc/cp-kafka"; + private static final String PULSAR_IMAGE = "apachepulsar/pulsar"; + private static final String LOCALSTACK_IMAGE = "localstack/localstack"; + private static final String SSHD_IMAGE = "testcontainers/sshd:1.0.0"; + + private static final ImmutableMap CONTAINER_MAPPING = ImmutableMap.builder() + .put(DockerImageName.parse(AMBASSADOR_IMAGE), "ambassador.container.image") + .put(DockerImageName.parse(SOCAT_IMAGE), "socat.container.image") + .put(DockerImageName.parse(VNC_RECORDER_IMAGE), "vncrecorder.container.image") + .put(DockerImageName.parse(COMPOSE_IMAGE), "compose.container.image") + .put(DockerImageName.parse(ALPINE_IMAGE), "tinyimage.container.image") + .put(DockerImageName.parse(RYUK_IMAGE), "ryuk.container.image") + .put(DockerImageName.parse(KAFKA_IMAGE), "kafka.container.image") + .put(DockerImageName.parse(PULSAR_IMAGE), "pulsar.container.image") + .put(DockerImageName.parse(LOCALSTACK_IMAGE), "localstack.container.image") + .put(DockerImageName.parse(SSHD_IMAGE), "sshd.container.image") + .build(); + @Getter(lazy = true) private static final TestcontainersConfiguration instance = loadConfiguration(); @@ -58,60 +84,44 @@ static AtomicReference getInstanceField() { this.properties.putAll(environmentProperties); } - private DockerImageName getImage(final String key, final String defaultValue) { - return DockerImageName - .parse(properties.getProperty(key, defaultValue).trim()) - .asCompatibleSubstituteFor(defaultValue); - } - @Deprecated public String getAmbassadorContainerImage() { - return getAmbassadorContainerDockerImageName().asCanonicalNameString(); - } - - @Deprecated - public DockerImageName getAmbassadorContainerDockerImageName() { - return getImage("ambassador.container.image", "richnorth/ambassador:latest"); + return getImage(AMBASSADOR_IMAGE).asCanonicalNameString(); } @Deprecated public String getSocatContainerImage() { - return getSocatDockerImageName().asCanonicalNameString(); - } - - public DockerImageName getSocatDockerImageName() { - return getImage("socat.container.image", "alpine/socat:latest"); + return getImage(SOCAT_IMAGE).asCanonicalNameString(); } @Deprecated public String getVncRecordedContainerImage() { - return getVncDockerImageName().asCanonicalNameString(); - } - - public DockerImageName getVncDockerImageName() { - return getImage("vncrecorder.container.image", "testcontainers/vnc-recorder:1.1.0"); + return getImage(VNC_RECORDER_IMAGE).asCanonicalNameString(); } @Deprecated public String getDockerComposeContainerImage() { - return getDockerComposeDockerImageName().asCanonicalNameString(); - } - - public DockerImageName getDockerComposeDockerImageName() { - return getImage("compose.container.image", "docker/compose:1.24.1"); + return getImage(COMPOSE_IMAGE).asCanonicalNameString(); } @Deprecated public String getTinyImage() { - return getTinyDockerImageName().asCanonicalNameString(); + return getImage(ALPINE_IMAGE).asCanonicalNameString(); } - public DockerImageName getTinyDockerImageName() { - return getImage("tinyimage.container.image", "alpine:3.5"); + public boolean isRyukPrivileged() { + return Boolean + .parseBoolean((String) properties.getOrDefault("ryuk.container.privileged", "false")); } - public boolean isRyukPrivileged() { - return Boolean.parseBoolean((String) properties.getOrDefault("ryuk.container.privileged", "false")); + @Deprecated + public String getRyukImage() { + return getImage(RYUK_IMAGE).asCanonicalNameString(); + } + + @Deprecated + public String getSSHdImage() { + return getImage(SSHD_IMAGE).asCanonicalNameString(); } @Deprecated @@ -138,29 +148,17 @@ public Integer getRyukTimeout() { @Deprecated public String getKafkaImage() { - return getKafkaDockerImageName().asCanonicalNameString(); - } - - public DockerImageName getKafkaDockerImageName() { - return getImage("kafka.container.image", "confluentinc/cp-kafka"); + return getImage(KAFKA_IMAGE).asCanonicalNameString(); } @Deprecated public String getPulsarImage() { - return getPulsarDockerImageName().asCanonicalNameString(); - } - - public DockerImageName getPulsarDockerImageName() { - return getImage("pulsar.container.image", "apachepulsar/pulsar"); + return getImage(PULSAR_IMAGE).asCanonicalNameString(); } @Deprecated public String getLocalStackImage() { - return getLocalstackDockerImageName().asCanonicalNameString(); - } - - public DockerImageName getLocalstackDockerImageName() { - return getImage("localstack.container.image", "localstack/localstack"); + return getImage(LOCALSTACK_IMAGE).asCanonicalNameString(); } public boolean isDisableChecks() { @@ -238,4 +236,24 @@ private static Properties readProperties(URL url) { } return properties; } + + private DockerImageName getImage(final String defaultValue) { + return getConfiguredSubstituteImage(DockerImageName.parse(defaultValue)); + } + + DockerImageName getConfiguredSubstituteImage(DockerImageName original) { + for (final Map.Entry entry : CONTAINER_MAPPING.entrySet()) { + if (original.isCompatibleWith(entry.getKey())) { + return + Optional.ofNullable(entry.getValue()) + .map(properties::get) + .map(String::valueOf) + .map(String::trim) + .map(DockerImageName::parse) + .orElse(original) + .asCompatibleSubstituteFor(original); + } + } + return original; + } } diff --git a/core/src/main/java/org/testcontainers/utility/Versioning.java b/core/src/main/java/org/testcontainers/utility/Versioning.java index 8944b3f0ba6..eeb75b0bbdd 100644 --- a/core/src/main/java/org/testcontainers/utility/Versioning.java +++ b/core/src/main/java/org/testcontainers/utility/Versioning.java @@ -46,6 +46,7 @@ public int hashCode() { @EqualsAndHashCode class TagVersioning implements Versioning { public static final String TAG_REGEX = "[\\w][\\w.\\-]{0,127}"; + static final TagVersioning LATEST = new TagVersioning("latest"); private final String tag; TagVersioning(String tag) { diff --git a/core/src/main/resources/META-INF/services/org.testcontainers.utility.ImageNameSubstitutor b/core/src/main/resources/META-INF/services/org.testcontainers.utility.ImageNameSubstitutor new file mode 100644 index 00000000000..069dc7ee710 --- /dev/null +++ b/core/src/main/resources/META-INF/services/org.testcontainers.utility.ImageNameSubstitutor @@ -0,0 +1 @@ +org.testcontainers.utility.DefaultImageNameSubstitutor diff --git a/core/src/test/java/org/testcontainers/DockerClientFactoryTest.java b/core/src/test/java/org/testcontainers/DockerClientFactoryTest.java index 030e4164f00..d484cc92eaf 100644 --- a/core/src/test/java/org/testcontainers/DockerClientFactoryTest.java +++ b/core/src/test/java/org/testcontainers/DockerClientFactoryTest.java @@ -1,5 +1,9 @@ package org.testcontainers; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.testcontainers.TestImages.TINY_IMAGE; + import com.github.dockerjava.api.DockerClient; import com.github.dockerjava.api.exception.DockerException; import com.github.dockerjava.api.exception.NotFoundException; @@ -12,9 +16,6 @@ import org.testcontainers.utility.MockTestcontainersConfigurationRule; import org.testcontainers.utility.TestcontainersConfiguration; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - /** * Test for {@link DockerClientFactory}. */ @@ -30,7 +31,7 @@ public void runCommandInsideDockerShouldNotFailIfImageDoesNotExistsLocally() { try { //remove tiny image, so it will be pulled during next command run dockFactory.client() - .removeImageCmd(TestcontainersConfiguration.getInstance().getTinyDockerImageName().asCanonicalNameString()) + .removeImageCmd(TINY_IMAGE.asCanonicalNameString()) .withForce(true).exec(); } catch (NotFoundException ignored) { // Do not fail if it's not pulled yet diff --git a/core/src/test/java/org/testcontainers/TestImages.java b/core/src/test/java/org/testcontainers/TestImages.java index 3f16e88d882..7052f7e8f9d 100644 --- a/core/src/test/java/org/testcontainers/TestImages.java +++ b/core/src/test/java/org/testcontainers/TestImages.java @@ -1,7 +1,6 @@ package org.testcontainers; import org.testcontainers.utility.DockerImageName; -import org.testcontainers.utility.TestcontainersConfiguration; public interface TestImages { DockerImageName REDIS_IMAGE = DockerImageName.parse("redis:3.0.2"); @@ -9,5 +8,5 @@ public interface TestImages { DockerImageName MONGODB_IMAGE = DockerImageName.parse("mongo:3.1.5"); DockerImageName ALPINE_IMAGE = DockerImageName.parse("alpine:3.2"); DockerImageName DOCKER_REGISTRY_IMAGE = DockerImageName.parse("registry:2.7.0"); - DockerImageName TINY_IMAGE = TestcontainersConfiguration.getInstance().getTinyDockerImageName(); + DockerImageName TINY_IMAGE = DockerImageName.parse("alpine:3.5"); } diff --git a/core/src/test/java/org/testcontainers/utility/AuthenticatedImagePullTest.java b/core/src/test/java/org/testcontainers/utility/AuthenticatedImagePullTest.java index e49ff2880df..c99770c4fbf 100644 --- a/core/src/test/java/org/testcontainers/utility/AuthenticatedImagePullTest.java +++ b/core/src/test/java/org/testcontainers/utility/AuthenticatedImagePullTest.java @@ -1,9 +1,21 @@ package org.testcontainers.utility; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; +import static org.rnorth.visibleassertions.VisibleAssertions.assertTrue; +import static org.testcontainers.TestImages.DOCKER_REGISTRY_IMAGE; +import static org.testcontainers.TestImages.TINY_IMAGE; + import com.github.dockerjava.api.DockerClient; import com.github.dockerjava.api.async.ResultCallback; import com.github.dockerjava.api.command.PullImageResultCallback; import com.github.dockerjava.api.model.AuthConfig; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.concurrent.TimeUnit; import org.intellij.lang.annotations.Language; import org.junit.AfterClass; import org.junit.Before; @@ -18,18 +30,6 @@ import org.testcontainers.containers.wait.strategy.HttpWaitStrategy; import org.testcontainers.images.builder.ImageFromDockerfile; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.concurrent.TimeUnit; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.when; -import static org.rnorth.visibleassertions.VisibleAssertions.assertTrue; -import static org.testcontainers.TestImages.DOCKER_REGISTRY_IMAGE; - /** * This test checks the integration between Testcontainers and an authenticated registry, but uses * a mock instance of {@link RegistryAuthLocator} - the purpose of the test is solely to ensure that @@ -165,7 +165,7 @@ private Path getLocalTempFile(String s) throws IOException { private static void putImageInRegistry() throws InterruptedException { // It doesn't matter which image we use for this test, but use one that's likely to have been pulled already - final String dummySourceImage = TestcontainersConfiguration.getInstance().getRyukDockerImageName().asCanonicalNameString(); + final String dummySourceImage = TINY_IMAGE.asCanonicalNameString(); client.pullImageCmd(dummySourceImage) .exec(new PullImageResultCallback()) diff --git a/core/src/test/java/org/testcontainers/utility/DefaultImageNameSubstitutorTest.java b/core/src/test/java/org/testcontainers/utility/DefaultImageNameSubstitutorTest.java new file mode 100644 index 00000000000..bc5f7221315 --- /dev/null +++ b/core/src/test/java/org/testcontainers/utility/DefaultImageNameSubstitutorTest.java @@ -0,0 +1,43 @@ +package org.testcontainers.utility; + +import org.junit.Before; +import org.junit.Test; +import org.testcontainers.utility.ImageNameSubstitutor.LogWrappedImageNameSubstitutor; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class DefaultImageNameSubstitutorTest { + + public static final DockerImageName ORIGINAL_IMAGE = DockerImageName.parse("foo"); + public static final DockerImageName SUBSTITUTE_IMAGE = DockerImageName.parse("bar"); + private DefaultImageNameSubstitutor underTest; + private TestcontainersConfiguration mockConfiguration; + + @Before + public void setUp() { + mockConfiguration = mock(TestcontainersConfiguration.class); + underTest = new DefaultImageNameSubstitutor(mockConfiguration); + } + + @Test + public void testConfigurationLookup() { + when(mockConfiguration.getConfiguredSubstituteImage(eq(ORIGINAL_IMAGE))).thenReturn(SUBSTITUTE_IMAGE); + + final DockerImageName substitute = underTest.apply(ORIGINAL_IMAGE); + + assertEquals("match is found", SUBSTITUTE_IMAGE, substitute); + assertTrue("compatibility is automatically set", substitute.isCompatibleWith(ORIGINAL_IMAGE)); + } + + @Test + public void testServiceLoaderFindsDefaultImplementation() { + final ImageNameSubstitutor instance = ImageNameSubstitutor.instance(); + + assertTrue(instance instanceof LogWrappedImageNameSubstitutor); + assertTrue(((LogWrappedImageNameSubstitutor) instance).wrappedInstance instanceof DefaultImageNameSubstitutor); + } +} diff --git a/core/src/test/java/org/testcontainers/utility/DockerImageNameCompatibilityTest.java b/core/src/test/java/org/testcontainers/utility/DockerImageNameCompatibilityTest.java index 9e7136ea6d3..003c83a9587 100644 --- a/core/src/test/java/org/testcontainers/utility/DockerImageNameCompatibilityTest.java +++ b/core/src/test/java/org/testcontainers/utility/DockerImageNameCompatibilityTest.java @@ -32,6 +32,8 @@ public void testNoTagTreatedAsWildcard() { */ assertFalse("foo:4.5.6 != foo:1.2.3", subject.isCompatibleWith(DockerImageName.parse("foo:1.2.3"))); assertTrue("foo:4.5.6 ~= foo", subject.isCompatibleWith(DockerImageName.parse("foo"))); + assertTrue("foo:4.5.6 ~= foo:latest", subject.isCompatibleWith(DockerImageName.parse("foo:latest"))); + assertTrue("foo:4.5.6 ~= foo:latest", subject.isCompatibleWith(DockerImageName.parse("foo:1.2.3").withTag("latest"))); } @Test diff --git a/core/src/test/java/org/testcontainers/utility/ImageNameSubstitutorTest.java b/core/src/test/java/org/testcontainers/utility/ImageNameSubstitutorTest.java new file mode 100644 index 00000000000..66d53a36837 --- /dev/null +++ b/core/src/test/java/org/testcontainers/utility/ImageNameSubstitutorTest.java @@ -0,0 +1,15 @@ +package org.testcontainers.utility; + +import org.junit.Test; + +import static org.junit.Assert.assertTrue; + +public class ImageNameSubstitutorTest { + + @Test + public void simpleServiceLoadingTest() { + final ImageNameSubstitutor imageNameSubstitutor = ImageNameSubstitutor.instance(); + + assertTrue(imageNameSubstitutor instanceof DefaultImageNameSubstitutor); + } +} diff --git a/core/src/test/java/org/testcontainers/utility/TestcontainersConfigurationTest.java b/core/src/test/java/org/testcontainers/utility/TestcontainersConfigurationTest.java index ccd68fbd984..bf4c15ca2c0 100644 --- a/core/src/test/java/org/testcontainers/utility/TestcontainersConfigurationTest.java +++ b/core/src/test/java/org/testcontainers/utility/TestcontainersConfigurationTest.java @@ -47,7 +47,7 @@ public void shouldReadReuseFromEnvironmentOnly() { assertTrue("reuse enabled", newConfig().environmentSupportsReuse()); environmentProperties.setProperty("ryuk.container.image", " testcontainersofficial/ryuk:0.3.0 "); - assertEquals("trailing whitespace was not removed from image name property", "testcontainersofficial/ryuk:0.3.0",newConfig().getRyukDockerImageName().asCanonicalNameString()); + assertEquals("trailing whitespace was not removed from image name property", "testcontainersofficial/ryuk:0.3.0",newConfig().getRyukImage()); } diff --git a/docs/features/pull-rate-limiting.md b/docs/features/pull-rate-limiting.md new file mode 100644 index 00000000000..052c444b28d --- /dev/null +++ b/docs/features/pull-rate-limiting.md @@ -0,0 +1,23 @@ +# Image Registry rate limiting + +As of November 2020 Docker Hub pulls are rate limited. +As Testcontainers uses Docker Hub for standard images, some users may hit these rate limits and should mitigate accordingly. + +This page sets out recommended options for Testcontainers users. +Some combination of these approaches should be considered, taking into consideration the differences between local development and CI environments. + +## Considerations for local dev vs CI + +### Local development + +## Options + +### Do nothing + +### Use an authenticated Docker Hub account + +### Deploy a Registry server to act as a read-through cache + +### Copy required images to a private registry and update references to images in test code + +### Copy required images to a private registry and apply image name substitution diff --git a/modules/kafka/src/main/java/org/testcontainers/containers/KafkaContainer.java b/modules/kafka/src/main/java/org/testcontainers/containers/KafkaContainer.java index 4086cd4e2e4..61d1385bb16 100644 --- a/modules/kafka/src/main/java/org/testcontainers/containers/KafkaContainer.java +++ b/modules/kafka/src/main/java/org/testcontainers/containers/KafkaContainer.java @@ -5,7 +5,6 @@ import lombok.SneakyThrows; import org.testcontainers.images.builder.Transferable; import org.testcontainers.utility.DockerImageName; -import org.testcontainers.utility.TestcontainersConfiguration; import java.nio.charset.StandardCharsets; import java.util.concurrent.TimeUnit; @@ -38,7 +37,7 @@ public class KafkaContainer extends GenericContainer { */ @Deprecated public KafkaContainer() { - this(TestcontainersConfiguration.getInstance().getKafkaDockerImageName().withTag(DEFAULT_TAG)); + this(DEFAULT_IMAGE_NAME.withTag(DEFAULT_TAG)); } /** @@ -46,7 +45,7 @@ public KafkaContainer() { */ @Deprecated public KafkaContainer(String confluentPlatformVersion) { - this(TestcontainersConfiguration.getInstance().getKafkaDockerImageName().withTag(confluentPlatformVersion)); + this(DEFAULT_IMAGE_NAME.withTag(confluentPlatformVersion)); } public KafkaContainer(final DockerImageName dockerImageName) { diff --git a/modules/kafka/src/test/java/org/testcontainers/containers/KafkaContainerTest.java b/modules/kafka/src/test/java/org/testcontainers/containers/KafkaContainerTest.java index 6a669b2f60d..b6099a7699f 100644 --- a/modules/kafka/src/test/java/org/testcontainers/containers/KafkaContainerTest.java +++ b/modules/kafka/src/test/java/org/testcontainers/containers/KafkaContainerTest.java @@ -61,7 +61,7 @@ public void testUsageWithSpecificImage() throws Exception { @Test public void testUsageWithVersion() throws Exception { try ( - KafkaContainer kafka = new KafkaContainer("5.2.1") + KafkaContainer kafka = new KafkaContainer("5.5.1") ) { kafka.start(); testKafkaFunctionality(kafka.getBootstrapServers()); @@ -86,6 +86,7 @@ public void testExternalZookeeperWithExternalNetwork() throws Exception { // withKafkaNetwork { GenericContainer application = new GenericContainer<>(DockerImageName.parse("alpine")) + ) .withNetwork(network) // } .withNetworkAliases("dummy") diff --git a/modules/localstack/src/main/java/org/testcontainers/containers/localstack/LocalStackContainer.java b/modules/localstack/src/main/java/org/testcontainers/containers/localstack/LocalStackContainer.java index 7532ebe0b3d..b8416afb1ed 100644 --- a/modules/localstack/src/main/java/org/testcontainers/containers/localstack/LocalStackContainer.java +++ b/modules/localstack/src/main/java/org/testcontainers/containers/localstack/LocalStackContainer.java @@ -14,7 +14,6 @@ import org.testcontainers.containers.wait.strategy.Wait; import org.testcontainers.utility.ComparableVersion; import org.testcontainers.utility.DockerImageName; -import org.testcontainers.utility.TestcontainersConfiguration; import java.net.InetAddress; import java.net.URI; @@ -65,7 +64,7 @@ public class LocalStackContainer extends GenericContainer { */ @Deprecated public LocalStackContainer() { - this(TestcontainersConfiguration.getInstance().getLocalstackDockerImageName().withTag(DEFAULT_TAG)); + this(DEFAULT_IMAGE_NAME.withTag(DEFAULT_TAG)); } /** @@ -73,7 +72,7 @@ public LocalStackContainer() { */ @Deprecated public LocalStackContainer(String version) { - this(TestcontainersConfiguration.getInstance().getLocalstackDockerImageName().withTag(version)); + this(DEFAULT_IMAGE_NAME.withTag(version)); } /** diff --git a/modules/pulsar/src/main/java/org/testcontainers/containers/PulsarContainer.java b/modules/pulsar/src/main/java/org/testcontainers/containers/PulsarContainer.java index bceff8927d7..b3c836b56a5 100644 --- a/modules/pulsar/src/main/java/org/testcontainers/containers/PulsarContainer.java +++ b/modules/pulsar/src/main/java/org/testcontainers/containers/PulsarContainer.java @@ -3,7 +3,6 @@ import org.testcontainers.containers.wait.strategy.Wait; import org.testcontainers.containers.wait.strategy.WaitAllStrategy; import org.testcontainers.utility.DockerImageName; -import org.testcontainers.utility.TestcontainersConfiguration; /** * This container wraps Apache Pulsar running in standalone mode @@ -25,7 +24,7 @@ public class PulsarContainer extends GenericContainer { */ @Deprecated public PulsarContainer() { - this(TestcontainersConfiguration.getInstance().getPulsarDockerImageName().withTag(DEFAULT_TAG)); + this(DEFAULT_IMAGE_NAME.withTag(DEFAULT_TAG)); } /** @@ -33,7 +32,7 @@ public PulsarContainer() { */ @Deprecated public PulsarContainer(String pulsarVersion) { - this(TestcontainersConfiguration.getInstance().getPulsarDockerImageName().withTag(pulsarVersion)); + this(DEFAULT_IMAGE_NAME.withTag(pulsarVersion)); } public PulsarContainer(final DockerImageName dockerImageName) { diff --git a/modules/selenium/src/test/java/org/testcontainers/junit/BaseWebDriverContainerTest.java b/modules/selenium/src/test/java/org/testcontainers/junit/BaseWebDriverContainerTest.java index cf5c762f2ad..7896e2ec6ec 100644 --- a/modules/selenium/src/test/java/org/testcontainers/junit/BaseWebDriverContainerTest.java +++ b/modules/selenium/src/test/java/org/testcontainers/junit/BaseWebDriverContainerTest.java @@ -1,5 +1,9 @@ package org.testcontainers.junit; +import static java.lang.String.format; +import static org.rnorth.visibleassertions.VisibleAssertions.assertTrue; + +import java.util.concurrent.TimeUnit; import org.jetbrains.annotations.NotNull; import org.junit.ClassRule; import org.openqa.selenium.By; @@ -11,12 +15,6 @@ import org.testcontainers.containers.wait.strategy.HttpWaitStrategy; import org.testcontainers.utility.DockerImageName; -import java.util.concurrent.TimeUnit; - -import static java.lang.String.format; -import static org.rnorth.visibleassertions.VisibleAssertions.assertEquals; -import static org.rnorth.visibleassertions.VisibleAssertions.assertTrue; - /** * */ diff --git a/modules/spock/src/test/groovy/org/testcontainers/spock/SpockTestImages.groovy b/modules/spock/src/test/groovy/org/testcontainers/spock/SpockTestImages.groovy index 526c1fe5c42..9698e7d433a 100644 --- a/modules/spock/src/test/groovy/org/testcontainers/spock/SpockTestImages.groovy +++ b/modules/spock/src/test/groovy/org/testcontainers/spock/SpockTestImages.groovy @@ -1,11 +1,10 @@ -package org.testcontainers.spock; +package org.testcontainers.spock import org.testcontainers.utility.DockerImageName -import org.testcontainers.utility.TestcontainersConfiguration; interface SpockTestImages { DockerImageName MYSQL_IMAGE = DockerImageName.parse("mysql:5.7.22") DockerImageName POSTGRES_TEST_IMAGE = DockerImageName.parse("postgres:9.6.12") DockerImageName HTTPD_IMAGE = DockerImageName.parse("httpd:2.4-alpine") - DockerImageName TINY_IMAGE = TestcontainersConfiguration.getInstance().getTinyDockerImageName() + DockerImageName TINY_IMAGE = DockerImageName.parse("alpine:3.5") }