diff --git a/core/src/main/java/org/testcontainers/utility/DefaultImageNameSubstitutor.java b/core/src/main/java/org/testcontainers/utility/DefaultImageNameSubstitutor.java index bcc7c96ba18..840b8a77b40 100644 --- a/core/src/main/java/org/testcontainers/utility/DefaultImageNameSubstitutor.java +++ b/core/src/main/java/org/testcontainers/utility/DefaultImageNameSubstitutor.java @@ -5,31 +5,37 @@ /** * Testcontainers' default implementation of {@link ImageNameSubstitutor}. - * Delegates to {@link ConfigurationFileImageNameSubstitutor}. + * Delegates to {@link ConfigurationFileImageNameSubstitutor} followed by {@link PrefixingImageNameSubstitutor}. */ @Slf4j final class DefaultImageNameSubstitutor extends ImageNameSubstitutor { private final ConfigurationFileImageNameSubstitutor configurationFileImageNameSubstitutor; + private final PrefixingImageNameSubstitutor prefixingImageNameSubstitutor; public DefaultImageNameSubstitutor() { configurationFileImageNameSubstitutor = new ConfigurationFileImageNameSubstitutor(); + prefixingImageNameSubstitutor = new PrefixingImageNameSubstitutor(); } @VisibleForTesting DefaultImageNameSubstitutor( - final ConfigurationFileImageNameSubstitutor configurationFileImageNameSubstitutor + final ConfigurationFileImageNameSubstitutor configurationFileImageNameSubstitutor, + final PrefixingImageNameSubstitutor prefixingImageNameSubstitutor ) { this.configurationFileImageNameSubstitutor = configurationFileImageNameSubstitutor; + this.prefixingImageNameSubstitutor = prefixingImageNameSubstitutor; } @Override public DockerImageName apply(final DockerImageName original) { - return configurationFileImageNameSubstitutor.apply(original); + return configurationFileImageNameSubstitutor + .andThen(prefixingImageNameSubstitutor) + .apply(original); } @Override protected String getDescription() { - return "DefaultImageNameSubstitutor (" + configurationFileImageNameSubstitutor.getDescription() + ")"; + return "DefaultImageNameSubstitutor (composite of '" + configurationFileImageNameSubstitutor.getDescription() + "' and '" + prefixingImageNameSubstitutor.getDescription() + "')"; } } diff --git a/core/src/main/java/org/testcontainers/utility/DockerImageName.java b/core/src/main/java/org/testcontainers/utility/DockerImageName.java index af8e5300863..b9d266a41ca 100644 --- a/core/src/main/java/org/testcontainers/utility/DockerImageName.java +++ b/core/src/main/java/org/testcontainers/utility/DockerImageName.java @@ -5,6 +5,7 @@ import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.EqualsAndHashCode; +import lombok.Getter; import lombok.With; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -24,8 +25,8 @@ public final class DockerImageName { private static final Pattern REPO_NAME = Pattern.compile(REPO_NAME_PART + "(/" + REPO_NAME_PART + ")*"); private final String rawName; - private final String registry; - private final String repo; + @With @Getter private final String registry; + @With @Getter private final String repository; @NotNull @With(AccessLevel.PRIVATE) private final Versioning versioning; @Nullable @With(AccessLevel.PRIVATE) @@ -68,13 +69,13 @@ public DockerImageName(String fullImageName) { } if (remoteName.contains("@sha256:")) { - repo = remoteName.split("@sha256:")[0]; + repository = remoteName.split("@sha256:")[0]; versioning = new Sha256Versioning(remoteName.split("@sha256:")[1]); } else if (remoteName.contains(":")) { - repo = remoteName.split(":")[0]; + repository = remoteName.split(":")[0]; versioning = new TagVersioning(remoteName.split(":")[1]); } else { - repo = remoteName; + repository = remoteName; versioning = Versioning.ANY; } @@ -110,10 +111,10 @@ public DockerImageName(String nameWithoutTag, @NotNull String version) { } if (version.startsWith("sha256:")) { - repo = remoteName; + repository = remoteName; versioning = new Sha256Versioning(version.replace("sha256:", "")); } else { - repo = remoteName; + repository = remoteName; versioning = new TagVersioning(version); } @@ -125,9 +126,9 @@ public DockerImageName(String nameWithoutTag, @NotNull String version) { */ public String getUnversionedPart() { if (!"".equals(registry)) { - return registry + "/" + repo; + return registry + "/" + repository; } else { - return repo; + return repository; } } @@ -158,18 +159,14 @@ public String toString() { public void assertValid() { //noinspection UnstableApiUsage HostAndPort.fromString(registry); // return value ignored - this throws if registry is not a valid host:port string - if (!REPO_NAME.matcher(repo).matches()) { - throw new IllegalArgumentException(repo + " is not a valid Docker image name (in " + rawName + ")"); + if (!REPO_NAME.matcher(repository).matches()) { + throw new IllegalArgumentException(repository + " is not a valid Docker image name (in " + rawName + ")"); } if (!versioning.isValid()) { throw new IllegalArgumentException(versioning + " is not a valid image versioning identifier (in " + rawName + ")"); } } - public String getRegistry() { - return registry; - } - /** * @param newTag version tag for the copy to use * @return an immutable copy of this {@link DockerImageName} with the new version tag diff --git a/core/src/main/java/org/testcontainers/utility/PrefixingImageNameSubstitutor.java b/core/src/main/java/org/testcontainers/utility/PrefixingImageNameSubstitutor.java new file mode 100644 index 00000000000..c720416deac --- /dev/null +++ b/core/src/main/java/org/testcontainers/utility/PrefixingImageNameSubstitutor.java @@ -0,0 +1,58 @@ +package org.testcontainers.utility; + +import com.google.common.annotations.VisibleForTesting; +import lombok.NoArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * An {@link ImageNameSubstitutor} which applies a prefix to all image names, e.g. a private registry host and path. + * The prefix may be set via an environment variable (TESTCONTAINERS_HUB_IMAGE_NAME_PREFIX) or an equivalent + * configuration file entry (see {@link TestcontainersConfiguration}). + */ +@NoArgsConstructor +@Slf4j +final class PrefixingImageNameSubstitutor extends ImageNameSubstitutor { + + @VisibleForTesting + static final String PREFIX_PROPERTY_KEY = "hub.image.name.prefix"; + + private TestcontainersConfiguration configuration = TestcontainersConfiguration.getInstance(); + + @VisibleForTesting + PrefixingImageNameSubstitutor(final TestcontainersConfiguration configuration) { + this.configuration = configuration; + } + + @Override + public DockerImageName apply(DockerImageName original) { + final String configuredPrefix = configuration.getEnvVarOrProperty(PREFIX_PROPERTY_KEY, ""); + + if (configuredPrefix.isEmpty()) { + log.debug("No prefix is configured"); + return original; + } + + boolean isAHubImage = original.getRegistry().isEmpty(); + if (!isAHubImage) { + log.debug("Image {} is not a Docker Hub image - not applying registry/repository change", original); + return original; + } + + log.debug( + "Applying changes to image name {}: applying prefix '{}'", + original, + configuredPrefix + ); + + DockerImageName prefixAsImage = DockerImageName.parse(configuredPrefix); + + return original + .withRegistry(prefixAsImage.getRegistry()) + .withRepository(prefixAsImage.getRepository() + original.getRepository()); + } + + @Override + protected String getDescription() { + return getClass().getSimpleName(); + } +} diff --git a/core/src/main/java/org/testcontainers/utility/TestcontainersConfiguration.java b/core/src/main/java/org/testcontainers/utility/TestcontainersConfiguration.java index 47c274c05e6..3b4d9f8f70c 100644 --- a/core/src/main/java/org/testcontainers/utility/TestcontainersConfiguration.java +++ b/core/src/main/java/org/testcontainers/utility/TestcontainersConfiguration.java @@ -212,7 +212,6 @@ private String getConfigurable(@NotNull final String propertyName, @Nullable fin * @param propertyName name of configuration file property (dot-separated lower case) * @return the found value, or null if not set */ - @Nullable @Contract("_, !null -> !null") public String getEnvVarOrProperty(@NotNull final String propertyName, @Nullable final String defaultValue) { return getConfigurable(propertyName, defaultValue, userProperties, classpathProperties); @@ -225,7 +224,6 @@ public String getEnvVarOrProperty(@NotNull final String propertyName, @Nullable * @param propertyName name of configuration file property (dot-separated lower case) * @return the found value, or null if not set */ - @Nullable @Contract("_, !null -> !null") public String getEnvVarOrUserProperty(@NotNull final String propertyName, @Nullable final String defaultValue) { return getConfigurable(propertyName, defaultValue, userProperties); @@ -238,7 +236,6 @@ public String getEnvVarOrUserProperty(@NotNull final String propertyName, @Nulla * @param propertyName name of configuration file property (dot-separated lower case) * @return the found value, or null if not set */ - @Nullable @Contract("_, !null -> !null") public String getUserProperty(@NotNull final String propertyName, @Nullable final String defaultValue) { return getConfigurable(propertyName, defaultValue); diff --git a/core/src/main/java/org/testcontainers/utility/Versioning.java b/core/src/main/java/org/testcontainers/utility/Versioning.java index 84dcf46274e..4b1da407440 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/utility/DockerImageNameCompatibilityTest.java b/core/src/test/java/org/testcontainers/utility/DockerImageNameCompatibilityTest.java index 7c794814faf..22d67de5785 100644 --- a/core/src/test/java/org/testcontainers/utility/DockerImageNameCompatibilityTest.java +++ b/core/src/test/java/org/testcontainers/utility/DockerImageNameCompatibilityTest.java @@ -1,13 +1,13 @@ package org.testcontainers.utility; -import org.junit.Rule; -import org.junit.Test; -import org.junit.rules.ExpectedException; - import static org.hamcrest.core.StringContains.containsString; import static org.rnorth.visibleassertions.VisibleAssertions.assertFalse; import static org.rnorth.visibleassertions.VisibleAssertions.assertTrue; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; + public class DockerImageNameCompatibilityTest { diff --git a/core/src/test/java/org/testcontainers/utility/PrefixingImageNameSubstitutorTest.java b/core/src/test/java/org/testcontainers/utility/PrefixingImageNameSubstitutorTest.java new file mode 100644 index 00000000000..d2c33989deb --- /dev/null +++ b/core/src/test/java/org/testcontainers/utility/PrefixingImageNameSubstitutorTest.java @@ -0,0 +1,140 @@ +package org.testcontainers.utility; + +import org.junit.Before; +import org.junit.Test; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.rnorth.visibleassertions.VisibleAssertions.assertEquals; +import static org.testcontainers.utility.PrefixingImageNameSubstitutor.PREFIX_PROPERTY_KEY; + +public class PrefixingImageNameSubstitutorTest { + + private TestcontainersConfiguration mockConfiguration; + private PrefixingImageNameSubstitutor underTest; + + @Before + public void setUp() { + mockConfiguration = mock(TestcontainersConfiguration.class); + underTest = new PrefixingImageNameSubstitutor(mockConfiguration); + } + + @Test + public void testHappyPath() { + when(mockConfiguration.getEnvVarOrProperty(eq(PREFIX_PROPERTY_KEY), any())).thenReturn("someregistry.com/our-mirror/"); + + final DockerImageName result = underTest.apply(DockerImageName.parse("some/image:tag")); + + assertEquals( + "The prefix is applied", + "someregistry.com/our-mirror/some/image:tag", + result.asCanonicalNameString() + ); + } + + @Test + public void hubIoRegistryIsNotChanged() { + when(mockConfiguration.getEnvVarOrProperty(eq(PREFIX_PROPERTY_KEY), any())).thenReturn("someregistry.com/our-mirror/"); + + final DockerImageName result = underTest.apply(DockerImageName.parse("docker.io/some/image:tag")); + + assertEquals( + "The prefix is applied", + "docker.io/some/image:tag", + result.asCanonicalNameString() + ); + } + + @Test + public void hubComRegistryIsNotChanged() { + when(mockConfiguration.getEnvVarOrProperty(eq(PREFIX_PROPERTY_KEY), any())).thenReturn("someregistry.com/our-mirror/"); + + final DockerImageName result = underTest.apply(DockerImageName.parse("registry.hub.docker.com/some/image:tag")); + + assertEquals( + "The prefix is applied", + "registry.hub.docker.com/some/image:tag", + result.asCanonicalNameString() + ); + } + + @Test + public void thirdPartyRegistriesNotAffected() { + when(mockConfiguration.getEnvVarOrProperty(eq(PREFIX_PROPERTY_KEY), any())).thenReturn("someregistry.com/our-mirror/"); + + final DockerImageName result = underTest.apply(DockerImageName.parse("gcr.io/something/image:tag")); + + assertEquals( + "The prefix is not applied if a third party registry is used", + "gcr.io/something/image:tag", + result.asCanonicalNameString() + ); + } + + @Test + public void testNoDoublePrefixing() { + when(mockConfiguration.getEnvVarOrProperty(eq(PREFIX_PROPERTY_KEY), any())).thenReturn("someregistry.com/our-mirror/"); + + final DockerImageName result = underTest.apply(DockerImageName.parse("someregistry.com/some/image:tag")); + + assertEquals( + "The prefix is not applied if already present", + "someregistry.com/some/image:tag", + result.asCanonicalNameString() + ); + } + + @Test + public void testHandlesEmptyValue() { + when(mockConfiguration.getEnvVarOrProperty(eq(PREFIX_PROPERTY_KEY), any())).thenReturn(""); + + final DockerImageName result = underTest.apply(DockerImageName.parse("some/image:tag")); + + assertEquals( + "The prefix is not applied if the env var is not set", + "some/image:tag", + result.asCanonicalNameString() + ); + } + + @Test + public void testHandlesRegistryOnlyWithTrailingSlash() { + when(mockConfiguration.getEnvVarOrProperty(eq(PREFIX_PROPERTY_KEY), any())).thenReturn("someregistry.com/"); + + final DockerImageName result = underTest.apply(DockerImageName.parse("some/image:tag")); + + assertEquals( + "The prefix is applied", + "someregistry.com/some/image:tag", + result.asCanonicalNameString() + ); + } + + @Test + public void testCombinesLiterallyForRegistryOnlyWithoutTrailingSlash() { + when(mockConfiguration.getEnvVarOrProperty(eq(PREFIX_PROPERTY_KEY), any())).thenReturn("someregistry.com"); + + final DockerImageName result = underTest.apply(DockerImageName.parse("some/image:tag")); + + assertEquals( + "The prefix is applied", + "someregistry.comsome/image:tag", // treating the prefix literally, for predictability + result.asCanonicalNameString() + ); + } + + @Test + public void testCombinesLiterallyForBothPartsWithoutTrailingSlash() { + when(mockConfiguration.getEnvVarOrProperty(eq(PREFIX_PROPERTY_KEY), any())).thenReturn("someregistry.com/our-mirror"); + + final DockerImageName result = underTest.apply(DockerImageName.parse("some/image:tag")); + + assertEquals( + "The prefix is applied", + "someregistry.com/our-mirrorsome/image:tag", // treating the prefix literally, for predictability + result.asCanonicalNameString() + ); + } +} diff --git a/docs/features/image_name_substitution.md b/docs/features/image_name_substitution.md index 45ea6038b49..37088473d72 100644 --- a/docs/features/image_name_substitution.md +++ b/docs/features/image_name_substitution.md @@ -45,7 +45,37 @@ to: +## Automatically modifying Docker Hub image names +Testcontainers can be configured to modify Docker Hub image names on the fly to apply a prefix string. + +Consider this if: + +* Developers and CI machines need to use different image names. For example, developers are able to pull images from Docker Hub, but CI machines need to pull from a private registry +* Your private registry has copies of images from Docker Hub where the names are predictable, and just adding a prefix is enough. + For example, `registry.mycompany.com/mirror/mysql:8.0.22` can be derived from the original Docker Hub image name (`mysql:8.0.22`) with a consistent prefix string: `registry.mycompany.com/mirror/` + +In this case, image name references in code are **unchanged**. +i.e. you would leave as-is: + + +[Unchanged direct Docker Hub image name](../examples/junit4/generic/src/test/java/generic/ImageNameSubstitutionTest.java) inside_block:directDockerHubReference + + +You can then configure Testcontainers to apply the prefix `registry.mycompany.com/mirror/` to every image that it tries to pull from Docker Hub. +This can be done in one of two ways: + +* Setting environment variables `TESTCONTAINERS_HUB_IMAGE_NAME_PREFIX=registry.mycompany.com/mirror/` +* Via config file, setting `hub.image.name.prefix` in either: + * the `~/.testcontainers.properties` file in your user home directory, or + * a file named `testcontainers.properties` on the classpath + +Testcontainers will automatically apply the prefix to every image that it pulls from Docker Hub - please verify that all [the required images](../supported_docker_environment/image_registry_rate_limiting.md#which-images-are-used-by-testcontainers) exist in your registry. + +Testcontainers will not apply the prefix to: + +* non-Hub image names (e.g. where another registry is set) +* Docker Hub image names where the hub registry is explicitly part of the name (i.e. anything with a `docker.io` or `registry.hub.docker.com` host part) @@ -54,7 +84,7 @@ to: Consider this if: * You have complex rules about which private registry images should be used as substitutes, e.g.: - * non-deterministic mapping of names meaning that a [name prefix](#adding-a-registry-url-prefix-to-image-names-automatically) cannot be used + * non-deterministic mapping of names meaning that a [name prefix](#automatically-modifying-docker-hub-image-names) cannot be used * rules depending upon developer identity or location * or you wish to add audit logging of images used in the build * or you wish to prevent accidental usage of images that are not on an approved list