Skip to content

Commit

Permalink
Add image compatibility checks (#3021)
Browse files Browse the repository at this point in the history
Co-authored-by: Kevin Wittek <[email protected]>
  • Loading branch information
rnorth and kiview authored Sep 29, 2020
1 parent 63e2c49 commit 1e597cc
Show file tree
Hide file tree
Showing 45 changed files with 692 additions and 364 deletions.
2 changes: 1 addition & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ subprojects {
}

lombok {
version = '1.18.8'
version = '1.18.12'
}

task delombok(type: io.franzbecker.gradle.lombok.task.DelombokTask) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package org.testcontainers.containers;

import static com.google.common.collect.Lists.newArrayList;
import static org.testcontainers.utility.CommandLine.runShellCommand;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.MapperFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.github.dockerjava.api.DockerClient;
import com.github.dockerjava.api.command.CreateContainerCmd;
Expand All @@ -21,6 +23,39 @@
import com.google.common.base.Strings;
import com.google.common.collect.ImmutableMap;
import com.google.common.hash.Hashing;
import java.io.File;
import java.io.IOException;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.lang.reflect.UndeclaredThrowableException;
import java.nio.charset.Charset;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Consumer;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import java.util.zip.Adler32;
import java.util.zip.Checksum;
import lombok.AccessLevel;
import lombok.Data;
import lombok.NonNull;
Expand Down Expand Up @@ -62,43 +97,6 @@
import org.testcontainers.utility.ResourceReaper;
import org.testcontainers.utility.TestcontainersConfiguration;

import java.io.File;
import java.io.IOException;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.lang.reflect.UndeclaredThrowableException;
import java.nio.charset.Charset;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Consumer;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import java.util.zip.Adler32;
import java.util.zip.Checksum;

import static com.google.common.collect.Lists.newArrayList;
import static org.testcontainers.utility.CommandLine.runShellCommand;

/**
* Base class for that allows a container to be launched and controlled.
*/
Expand Down Expand Up @@ -241,7 +239,7 @@ public GenericContainer(@NonNull final RemoteDockerImage image) {
*/
@Deprecated
public GenericContainer() {
this(TestcontainersConfiguration.getInstance().getTinyImage());
this(TestcontainersConfiguration.getInstance().getTinyDockerImageName().asCanonicalNameString());
}

/**
Expand Down
152 changes: 99 additions & 53 deletions core/src/main/java/org/testcontainers/utility/DockerImageName.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,26 +4,32 @@
import com.google.common.net.HostAndPort;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Data;
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")
@EqualsAndHashCode(exclude = { "rawName", "compatibleSubstituteFor" })
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public final class DockerImageName {

/* Regex patterns used for validation */
private static final String ALPHA_NUMERIC = "[a-z0-9]+";
private static final String SEPARATOR = "([\\.]{1}|_{1,2}|-+)";
private static final String SEPARATOR = "([.]|_{1,2}|-+)";
private static final String REPO_NAME_PART = ALPHA_NUMERIC + "(" + SEPARATOR + ALPHA_NUMERIC + ")*";
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;
@NotNull private final Versioning versioning;
@NotNull @With(AccessLevel.PRIVATE)
private final Versioning versioning;
@Nullable @With(AccessLevel.PRIVATE)
private final DockerImageName compatibleSubstituteFor;

/**
* Parses a docker image name from a provided string.
Expand Down Expand Up @@ -52,8 +58,8 @@ public DockerImageName(String fullImageName) {
String remoteName;
if (slashIndex == -1 ||
(!fullImageName.substring(0, slashIndex).contains(".") &&
!fullImageName.substring(0, slashIndex).contains(":") &&
!fullImageName.substring(0, slashIndex).equals("localhost"))) {
!fullImageName.substring(0, slashIndex).contains(":") &&
!fullImageName.substring(0, slashIndex).equals("localhost"))) {
registry = "";
remoteName = fullImageName;
} else {
Expand All @@ -69,8 +75,10 @@ public DockerImageName(String fullImageName) {
versioning = new TagVersioning(remoteName.split(":")[1]);
} else {
repo = remoteName;
versioning = new TagVersioning("latest");
versioning = Versioning.ANY;
}

compatibleSubstituteFor = null;
}

/**
Expand All @@ -92,8 +100,8 @@ public DockerImageName(String nameWithoutTag, @NotNull String version) {
String remoteName;
if (slashIndex == -1 ||
(!nameWithoutTag.substring(0, slashIndex).contains(".") &&
!nameWithoutTag.substring(0, slashIndex).contains(":") &&
!nameWithoutTag.substring(0, slashIndex).equals("localhost"))) {
!nameWithoutTag.substring(0, slashIndex).contains(":") &&
!nameWithoutTag.substring(0, slashIndex).equals("localhost"))) {
registry = "";
remoteName = nameWithoutTag;
} else {
Expand All @@ -108,6 +116,8 @@ public DockerImageName(String nameWithoutTag, @NotNull String version) {
repo = remoteName;
versioning = new TagVersioning(version);
}

compatibleSubstituteFor = null;
}

/**
Expand All @@ -132,7 +142,7 @@ public String getVersionPart() {
* @return canonical name for the image
*/
public String asCanonicalNameString() {
return getUnversionedPart() + versioning.getSeparator() + versioning.toString();
return getUnversionedPart() + versioning.getSeparator() + getVersionPart();
}

@Override
Expand All @@ -146,7 +156,8 @@ public String toString() {
* @throws IllegalArgumentException if not valid
*/
public void assertValid() {
HostAndPort.fromString(registry);
//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 + ")");
}
Expand All @@ -159,63 +170,98 @@ 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
*/
public DockerImageName withTag(final String newTag) {
return new DockerImageName(rawName, registry, repo, new TagVersioning(newTag));
return withVersioning(new TagVersioning(newTag));
}

private interface Versioning {
boolean isValid();

String getSeparator();
/**
* Declare that this {@link DockerImageName} is a compatible substitute for another image - i.e. that this image
* behaves as the other does, and is compatible with Testcontainers' assumptions about the other image.
*
* @param otherImageName the image name of the other image
* @return an immutable copy of this {@link DockerImageName} with the compatibility declaration attached.
*/
public DockerImageName asCompatibleSubstituteFor(String otherImageName) {
return withCompatibleSubstituteFor(DockerImageName.parse(otherImageName));
}

@Data
private static class TagVersioning implements Versioning {
public static final String TAG_REGEX = "[\\w][\\w\\.\\-]{0,127}";
private final String tag;

TagVersioning(String tag) {
this.tag = tag;
}
/**
* Declare that this {@link DockerImageName} is a compatible substitute for another image - i.e. that this image
* behaves as the other does, and is compatible with Testcontainers' assumptions about the other image.
*
* @param otherImageName the image name of the other image
* @return an immutable copy of this {@link DockerImageName} with the compatibility declaration attached.
*/
public DockerImageName asCompatibleSubstituteFor(DockerImageName otherImageName) {
return withCompatibleSubstituteFor(otherImageName);
}

@Override
public boolean isValid() {
return tag.matches(TAG_REGEX);
/**
* Test whether this {@link DockerImageName} has declared compatibility with another image (set using
* {@link DockerImageName#asCompatibleSubstituteFor(String)} or
* {@link DockerImageName#asCompatibleSubstituteFor(DockerImageName)}.
* <p>
* If a version tag part is present in the <code>other</code> image name, the tags must exactly match, unless it
* is 'latest'. If a version part is not present in the <code>other</code> image name, the tag contents are ignored.
*
* @param other the other image that we are trying to test compatibility with
* @return whether this image has declared compatibility.
*/
public boolean isCompatibleWith(DockerImageName other) {
// is this image already the same or equivalent?
if (other.equals(this)) {
return true;
}

@Override
public String getSeparator() {
return ":";
if (this.compatibleSubstituteFor == null) {
return false;
}

@Override
public String toString() {
return tag;
}
return this.compatibleSubstituteFor.isCompatibleWith(other);
}

@Data
private static class Sha256Versioning implements Versioning {
public static final String HASH_REGEX = "[0-9a-fA-F]{32,}";
private final String hash;

Sha256Versioning(String hash) {
this.hash = hash;
}

@Override
public boolean isValid() {
return hash.matches(HASH_REGEX);
/**
* Behaves as {@link DockerImageName#isCompatibleWith(DockerImageName)} but throws an exception
* rather than returning false if a mismatch is detected.
*
* @param anyOthers the other image(s) that we are trying to check compatibility with. If more
* than one is provided, this method will check compatibility with at least one
* of them.
* @throws IllegalStateException if {@link DockerImageName#isCompatibleWith(DockerImageName)}
* returns false
*/
public void assertCompatibleWith(DockerImageName... anyOthers) {
if (anyOthers.length == 0) {
throw new IllegalArgumentException("anyOthers parameter must be non-empty");
}

@Override
public String getSeparator() {
return "@";
for (DockerImageName anyOther : anyOthers) {
if (this.isCompatibleWith(anyOther)) {
return;
}
}

@Override
public String toString() {
return "sha256:" + hash;
}
final DockerImageName exampleOther = anyOthers[0];

throw new IllegalStateException(
String.format(
"Failed to verify that image '%s' is a compatible substitute for '%s'. This generally means that "
+
"you are trying to use an image that Testcontainers has not been designed to use. If this is "
+
"deliberate, and if you are confident that the image is compatible, you should declare "
+
"compatibility in code using the `asCompatibleSubstituteFor` method. For example:\n"
+
" DockerImageName myImage = DockerImageName.parse(\"%s\").asCompatibleSubstituteFor(\"%s\");\n"
+
"and then use `myImage` instead.",
this.rawName, exampleOther.rawName, this.rawName, exampleOther.rawName
)
);
}
}
Loading

0 comments on commit 1e597cc

Please sign in to comment.