Skip to content

Commit

Permalink
Implement image compatibility checks
Browse files Browse the repository at this point in the history
So that compatibility assurances can be made in code rather than just being assumed.
  • Loading branch information
rnorth committed Aug 14, 2020
1 parent 22eb3f6 commit 484f3cd
Show file tree
Hide file tree
Showing 39 changed files with 549 additions and 271 deletions.
166 changes: 103 additions & 63 deletions core/src/main/java/org/testcontainers/utility/DockerImageName.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,28 +2,27 @@


import com.google.common.net.HostAndPort;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

import java.util.regex.Pattern;

@EqualsAndHashCode(exclude = "rawName")
@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;
private final Versioning versioning;
@Nullable
private final DockerImageName compatibleSubstituteFor;

/**
* Parses a docker image name from a provided string.
Expand Down Expand Up @@ -52,8 +51,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 @@ -63,14 +62,16 @@ 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 = new TagVersioning("latest");
versioning = Versioning.TagVersioning.LATEST;
}

compatibleSubstituteFor = null;
}

/**
Expand All @@ -92,8 +93,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 @@ -103,11 +104,26 @@ 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;
}

private DockerImageName(String rawName,
String registry,
String repo,
@Nullable Versioning versioning,
@Nullable DockerImageName compatibleSubstituteFor) {

this.rawName = rawName;
this.registry = registry;
this.repo = repo;
this.versioning = versioning;
this.compatibleSubstituteFor = compatibleSubstituteFor;
}

/**
Expand All @@ -125,14 +141,14 @@ 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();
}

/**
* @return canonical name for the image
*/
public String asCanonicalNameString() {
return getUnversionedPart() + versioning.getSeparator() + versioning.toString();
return getUnversionedPart() + (versioning == null ? ":" : versioning.getSeparator()) + getVersionPart();
}

@Override
Expand All @@ -146,11 +162,12 @@ public String toString() {
* @throws IllegalArgumentException if not valid
*/
public void assertValid() {
//noinspection UnstableApiUsage
HostAndPort.fromString(registry);
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 + ")");
}
}
Expand All @@ -159,63 +176,86 @@ 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 new DockerImageName(rawName, registry, repo, new Versioning.TagVersioning(newTag), compatibleSubstituteFor);
}

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 asCompatibleSubstituteFor(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;
}

@Override
public boolean isValid() {
return tag.matches(TAG_REGEX);
}

@Override
public String getSeparator() {
return ":";
}

@Override
public String toString() {
return 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 new DockerImageName(rawName, registry, repo, versioning, otherImageName);
}

@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;
/**
* 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?
final boolean thisRegistrySame = other.registry.equals(this.registry);
final boolean thisRepoSame = other.repo.equals(this.repo);
final boolean thisVersioningNotSpecifiedOrSame = other.versioning == null ||
other.versioning.equals(Versioning.TagVersioning.LATEST) ||
other.versioning.equals(this.versioning);

if (thisRegistrySame && thisRepoSame && thisVersioningNotSpecifiedOrSame) {
return true;
}

@Override
public boolean isValid() {
return hash.matches(HASH_REGEX);
if (this.compatibleSubstituteFor == null) {
return false;
}

@Override
public String getSeparator() {
return "@";
}
return this.compatibleSubstituteFor.isCompatibleWith(other);
}

@Override
public String toString() {
return "sha256:" + hash;
/**
* Behaves as {@link DockerImageName#isCompatibleWith(DockerImageName)} but throws an exception rather than
* returning false if a mismatch is detected.
*
* @param other the other image that we are trying to check compatibility with
* @throws IllegalStateException if {@link DockerImageName#isCompatibleWith(DockerImageName)} returns false
*/
public void assertCompatibleWith(DockerImageName other) {
if (!this.isCompatibleWith(other)) {
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, other.rawName, this.rawName, other.rawName
)
);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -58,45 +58,56 @@ static AtomicReference<TestcontainersConfiguration> getInstanceField() {
this.properties.putAll(environmentProperties);
}

private DockerImageName getImage(final String key, final String defaultValue) {
return DockerImageName
.parse(properties.getProperty(key, defaultValue))
.asCompatibleSubstituteFor(defaultValue);
}

@Deprecated
public String getAmbassadorContainerImage() {
return (String) properties.getOrDefault("ambassador.container.image", "richnorth/ambassador:latest");
return getAmbassadorContainerDockerImageName().asCanonicalNameString();
}

@Deprecated
public DockerImageName getAmbassadorContainerDockerImageName() {
return getImage("ambassador.container.image", "richnorth/ambassador:latest");
}

@Deprecated
public String getSocatContainerImage() {
return (String) properties.getOrDefault("socat.container.image", "alpine/socat:latest");
return getSocatDockerImageName().asCanonicalNameString();
}

public DockerImageName getSocatDockerImageName() {
return DockerImageName.parse(getSocatContainerImage());
return getImage("socat.container.image", "alpine/socat:latest");
}

@Deprecated
public String getVncRecordedContainerImage() {
return (String) properties.getOrDefault("vncrecorder.container.image", "testcontainers/vnc-recorder:1.1.0");
return getVncDockerImageName().asCanonicalNameString();
}

public DockerImageName getVncDockerImageName() {
return DockerImageName.parse(getVncRecordedContainerImage());
return getImage("vncrecorder.container.image", "testcontainers/vnc-recorder:1.1.0");
}

@Deprecated
public String getDockerComposeContainerImage() {
return (String) properties.getOrDefault("compose.container.image", "docker/compose:1.24.1");
return getDockerComposeDockerImageName().asCanonicalNameString();
}

public DockerImageName getDockerComposeDockerImageName() {
return DockerImageName.parse(getDockerComposeContainerImage());
return getImage("compose.container.image", "docker/compose:1.24.1");
}

@Deprecated
public String getTinyImage() {
return (String) properties.getOrDefault("tinyimage.container.image", "alpine:3.5");
return getTinyDockerImageName().asCanonicalNameString();
}

public DockerImageName getTinyDockerImageName() {
return DockerImageName.parse(getTinyImage());
return getImage("tinyimage.container.image", "alpine:3.5");
}

public boolean isRyukPrivileged() {
Expand All @@ -105,20 +116,20 @@ public boolean isRyukPrivileged() {

@Deprecated
public String getRyukImage() {
return (String) properties.getOrDefault("ryuk.container.image", "testcontainers/ryuk:0.3.0");
return getRyukDockerImageName().asCanonicalNameString();
}

public DockerImageName getRyukDockerImageName() {
return DockerImageName.parse(getRyukImage());
return getImage("ryuk.container.image", "testcontainers/ryuk:0.3.0");
}

@Deprecated
public String getSSHdImage() {
return (String) properties.getOrDefault("sshd.container.image", "testcontainers/sshd:1.0.0");
return getSSHdDockerImageName().asCanonicalNameString();
}

public DockerImageName getSSHdDockerImageName() {
return DockerImageName.parse(getSSHdImage());
return getImage("sshd.container.image", "testcontainers/sshd:1.0.0");
}

public Integer getRyukTimeout() {
Expand All @@ -127,29 +138,29 @@ public Integer getRyukTimeout() {

@Deprecated
public String getKafkaImage() {
return (String) properties.getOrDefault("kafka.container.image", "confluentinc/cp-kafka");
return getKafkaDockerImageName().asCanonicalNameString();
}

public DockerImageName getKafkaDockerImageName() {
return DockerImageName.parse(getKafkaImage());
return getImage("kafka.container.image", "confluentinc/cp-kafka");
}

@Deprecated
public String getPulsarImage() {
return (String) properties.getOrDefault("pulsar.container.image", "apachepulsar/pulsar");
return getPulsarDockerImageName().asCanonicalNameString();
}

public DockerImageName getPulsarDockerImageName() {
return DockerImageName.parse(getPulsarImage());
return getImage("pulsar.container.image", "apachepulsar/pulsar");
}

@Deprecated
public String getLocalStackImage() {
return (String) properties.getOrDefault("localstack.container.image", "localstack/localstack");
return getLocalstackDockerImageName().asCanonicalNameString();
}

public DockerImageName getLocalstackDockerImageName() {
return DockerImageName.parse(getLocalStackImage());
return getImage("localstack.container.image", "localstack/localstack");
}

public boolean isDisableChecks() {
Expand Down
Loading

0 comments on commit 484f3cd

Please sign in to comment.