From 0f032c290a2e21902ee637e5bb0b79445927e73e Mon Sep 17 00:00:00 2001 From: Phillip Webb Date: Mon, 24 Apr 2023 16:05:03 -0700 Subject: [PATCH] Allow for predicate based checking of image names Update `DockerComposeConnectionDetailsFactory` to accept a `Predicate` based check to determine if the source should be accepted. The existing name based checks have also been improved to allow names outside of official docker images. The `ImageReference` and `ImageName` classes have been mainly copied from `org.springframework.boot.buildpack.platform.docker.type`. Closes gh-35154 --- .../boot/docker/compose/core/ImageName.java | 127 ++++++++++++++ .../docker/compose/core/ImageReference.java | 152 ++++++++++++---- .../boot/docker/compose/core/Regex.java | 121 +++++++++++++ .../connection/ConnectionNamePredicate.java | 52 ++++++ ...DockerComposeConnectionDetailsFactory.java | 22 ++- ...DockerComposeConnectionDetailsFactory.java | 3 +- .../core/DefaultDockerComposeTests.java | 2 +- .../core/DefaultRunningServiceTests.java | 4 +- .../docker/compose/core/ImageNameTests.java | 163 ++++++++++++++++++ .../compose/core/ImageReferenceTests.java | 143 ++++++++++----- .../ConnectionNamePredicateTests.java | 96 +++++++++++ .../asciidoc/features/docker-compose.adoc | 2 +- 12 files changed, 802 insertions(+), 85 deletions(-) create mode 100644 spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/core/ImageName.java create mode 100644 spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/core/Regex.java create mode 100644 spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/ConnectionNamePredicate.java create mode 100644 spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/core/ImageNameTests.java create mode 100644 spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/ConnectionNamePredicateTests.java diff --git a/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/core/ImageName.java b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/core/ImageName.java new file mode 100644 index 00000000000..e6b980765b6 --- /dev/null +++ b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/core/ImageName.java @@ -0,0 +1,127 @@ +/* + * Copyright 2012-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.docker.compose.core; + +import org.springframework.util.Assert; + +/** + * A Docker image name of the form {@literal "docker.io/library/ubuntu"}. + * + * @author Phillip Webb + * @author Scott Frederick + */ +class ImageName { + + private static final String DEFAULT_DOMAIN = "docker.io"; + + private static final String OFFICIAL_REPOSITORY_NAME = "library"; + + private static final String LEGACY_DOMAIN = "index.docker.io"; + + private final String domain; + + private final String name; + + private final String string; + + ImageName(String domain, String path) { + Assert.hasText(path, "Path must not be empty"); + this.domain = getDomainOrDefault(domain); + this.name = getNameWithDefaultPath(this.domain, path); + this.string = this.domain + "/" + this.name; + } + + /** + * Return the domain for this image name. + * @return the domain + */ + String getDomain() { + return this.domain; + } + + /** + * Return the name of this image. + * @return the image name + */ + String getName() { + return this.name; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + ImageName other = (ImageName) obj; + boolean result = true; + result = result && this.domain.equals(other.domain); + result = result && this.name.equals(other.name); + return result; + } + + @Override + public int hashCode() { + final int prime = 31; + int result = 1; + result = prime * result + this.domain.hashCode(); + result = prime * result + this.name.hashCode(); + return result; + } + + @Override + public String toString() { + return this.string; + } + + private String getDomainOrDefault(String domain) { + if (domain == null || LEGACY_DOMAIN.equals(domain)) { + return DEFAULT_DOMAIN; + } + return domain; + } + + private String getNameWithDefaultPath(String domain, String name) { + if (DEFAULT_DOMAIN.equals(domain) && !name.contains("/")) { + return OFFICIAL_REPOSITORY_NAME + "/" + name; + } + return name; + } + + static String parseDomain(String value) { + int firstSlash = value.indexOf('/'); + String candidate = (firstSlash != -1) ? value.substring(0, firstSlash) : null; + if (candidate != null && Regex.DOMAIN.matcher(candidate).matches()) { + return candidate; + } + return null; + } + + static ImageName of(String value) { + Assert.hasText(value, "Value must not be empty"); + String domain = parseDomain(value); + String path = (domain != null) ? value.substring(domain.length() + 1) : value; + Assert.isTrue(Regex.PATH.matcher(path).matches(), + () -> "Unable to parse name \"" + value + "\". " + + "Image name must be in the form '[domainHost:port/][path/]name', " + + "with 'path' and 'name' containing only [a-z0-9][.][_][-]"); + return new ImageName(domain, path); + } + +} diff --git a/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/core/ImageReference.java b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/core/ImageReference.java index 81423e99e6f..a33fb023e8f 100644 --- a/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/core/ImageReference.java +++ b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/core/ImageReference.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2022 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,31 +16,68 @@ package org.springframework.boot.docker.compose.core; +import java.util.regex.Matcher; + +import org.springframework.util.Assert; +import org.springframework.util.ObjectUtils; + /** - * A docker image reference of form - * {@code [/][/][:|@]}. + * A reference to a Docker image of the form {@code "imagename[:tag|@digest]"}. * - * @author Moritz Halbritter - * @author Andy Wilkinson * @author Phillip Webb - * @since 3.1.0 - * @see docker - * documentation + * @author Scott Frederick + * @since 2.3.0 */ public final class ImageReference { - private final String reference; + private final ImageName name; - private final String imageName; + private final String tag; - ImageReference(String reference) { - this.reference = reference; - int lastSlashIndex = reference.lastIndexOf('/'); - String imageTagDigest = (lastSlashIndex != -1) ? reference.substring(lastSlashIndex + 1) : reference; - int digestIndex = imageTagDigest.indexOf('@'); - String imageTag = (digestIndex != -1) ? imageTagDigest.substring(0, digestIndex) : imageTagDigest; - int colon = imageTag.indexOf(':'); - this.imageName = (colon != -1) ? imageTag.substring(0, colon) : imageTag; + private final String digest; + + private final String string; + + private ImageReference(ImageName name, String tag, String digest) { + Assert.notNull(name, "Name must not be null"); + this.name = name; + this.tag = tag; + this.digest = digest; + this.string = buildString(name.toString(), tag, digest); + } + + /** + * Return the domain for this image name. + * @return the domain + * @see ImageName#getDomain() + */ + public String getDomain() { + return this.name.getDomain(); + } + + /** + * Return the name of this image. + * @return the image name + * @see ImageName#getName() + */ + public String getName() { + return this.name.getName(); + } + + /** + * Return the tag from the reference or {@code null}. + * @return the referenced tag + */ + public String getTag() { + return this.tag; + } + + /** + * Return the digest from the reference or {@code null}. + * @return the referenced digest + */ + public String getDigest() { + return this.digest; } @Override @@ -52,35 +89,84 @@ public final class ImageReference { return false; } ImageReference other = (ImageReference) obj; - return this.reference.equals(other.reference); + boolean result = true; + result = result && this.name.equals(other.name); + result = result && ObjectUtils.nullSafeEquals(this.tag, other.tag); + result = result && ObjectUtils.nullSafeEquals(this.digest, other.digest); + return result; } @Override public int hashCode() { - return this.reference.hashCode(); + final int prime = 31; + int result = 1; + result = prime * result + this.name.hashCode(); + result = prime * result + ObjectUtils.nullSafeHashCode(this.tag); + result = prime * result + ObjectUtils.nullSafeHashCode(this.digest); + return result; } @Override public String toString() { - return this.reference; + return this.string; + } + + private String buildString(String name, String tag, String digest) { + StringBuilder string = new StringBuilder(name); + if (tag != null) { + string.append(":").append(tag); + } + if (digest != null) { + string.append("@").append(digest); + } + return string.toString(); } /** - * Return the referenced image, excluding the registry or project. For example, a - * reference of {@code my_private.registry:5000/redis:5} would return {@code redis}. - * @return the referenced image - */ - public String getImageName() { - return this.imageName; - } - - /** - * Create an image reference from the given String value. - * @param value the string used to create the reference + * Create a new {@link ImageReference} from the given value. The following value forms + * can be used: + *
    + *
  • {@code name} (maps to {@code docker.io/library/name})
  • + *
  • {@code domain/name}
  • + *
  • {@code domain:port/name}
  • + *
  • {@code domain:port/name:tag}
  • + *
  • {@code domain:port/name@digest}
  • + *
+ * @param value the value to parse * @return an {@link ImageReference} instance */ public static ImageReference of(String value) { - return (value != null) ? new ImageReference(value) : null; + Assert.hasText(value, "Value must not be null"); + String domain = ImageName.parseDomain(value); + String path = (domain != null) ? value.substring(domain.length() + 1) : value; + String digest = null; + int digestSplit = path.indexOf("@"); + if (digestSplit != -1) { + String remainder = path.substring(digestSplit + 1); + Matcher matcher = Regex.DIGEST.matcher(remainder); + if (matcher.find()) { + digest = remainder.substring(0, matcher.end()); + remainder = remainder.substring(matcher.end()); + path = path.substring(0, digestSplit) + remainder; + } + } + String tag = null; + int tagSplit = path.lastIndexOf(":"); + if (tagSplit != -1) { + String remainder = path.substring(tagSplit + 1); + Matcher matcher = Regex.TAG.matcher(remainder); + if (matcher.find()) { + tag = remainder.substring(0, matcher.end()); + remainder = remainder.substring(matcher.end()); + path = path.substring(0, tagSplit) + remainder; + } + } + Assert.isTrue(Regex.PATH.matcher(path).matches(), + () -> "Unable to parse image reference \"" + value + "\". " + + "Image reference must be in the form '[domainHost:port/][path/]name[:tag][@digest]', " + + "with 'path' and 'name' containing only [a-z0-9][.][_][-]"); + ImageName name = new ImageName(domain, path); + return new ImageReference(name, tag, digest); } } diff --git a/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/core/Regex.java b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/core/Regex.java new file mode 100644 index 00000000000..c417ae0d1d4 --- /dev/null +++ b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/core/Regex.java @@ -0,0 +1,121 @@ +/* + * Copyright 2012-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.docker.compose.core; + +import java.util.regex.Pattern; + +/** + * Regular Expressions for image names and references based on those found in the Docker + * codebase. + * + * @author Scott Frederick + * @author Phillip Webb + * @see Docker + * grammar reference + * @see Docker grammar + * implementation + * @see How + * are Docker image names parsed? + */ +final class Regex implements CharSequence { + + static final Pattern DOMAIN; + static { + Regex component = Regex.oneOf("[a-zA-Z0-9]", "[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]"); + Regex dotComponent = Regex.group("[.]", component); + Regex colonPort = Regex.of("[:][0-9]+"); + Regex dottedDomain = Regex.group(component, dotComponent.oneOrMoreTimes()); + Regex dottedDomainAndPort = Regex.group(component, dotComponent.oneOrMoreTimes(), colonPort); + Regex nameAndPort = Regex.group(component, colonPort); + DOMAIN = Regex.oneOf(dottedDomain, nameAndPort, dottedDomainAndPort, "localhost").compile(); + } + + private static final Regex PATH_COMPONENT; + static { + Regex segment = Regex.of("[a-z0-9]+"); + Regex separator = Regex.group("[._]|__|[-]*"); + Regex separatedSegment = Regex.group(separator, segment).oneOrMoreTimes(); + PATH_COMPONENT = Regex.of(segment, Regex.group(separatedSegment).zeroOrOnce()); + } + + static final Pattern PATH; + static { + Regex component = PATH_COMPONENT; + Regex slashComponent = Regex.group("[/]", component); + Regex slashComponents = Regex.group(slashComponent.oneOrMoreTimes()); + PATH = Regex.of(component, slashComponents.zeroOrOnce()).compile(); + } + + static final Pattern TAG = Regex.of("^[\\w][\\w.-]{0,127}").compile(); + + static final Pattern DIGEST = Regex.of("^[A-Za-z][A-Za-z0-9]*(?:[-_+.][A-Za-z][A-Za-z0-9]*)*[:][[A-Fa-f0-9]]{32,}") + .compile(); + + private final String value; + + private Regex(CharSequence value) { + this.value = value.toString(); + } + + private Regex oneOrMoreTimes() { + return new Regex(this.value + "+"); + } + + private Regex zeroOrOnce() { + return new Regex(this.value + "?"); + } + + Pattern compile() { + return Pattern.compile("^" + this.value + "$"); + } + + @Override + public int length() { + return this.value.length(); + } + + @Override + public char charAt(int index) { + return this.value.charAt(index); + } + + @Override + public CharSequence subSequence(int start, int end) { + return this.value.subSequence(start, end); + } + + @Override + public String toString() { + return this.value; + } + + private static Regex of(CharSequence... expressions) { + return new Regex(String.join("", expressions)); + } + + private static Regex oneOf(CharSequence... expressions) { + return new Regex("(?:" + String.join("|", expressions) + ")"); + } + + private static Regex group(CharSequence... expressions) { + return new Regex("(?:" + String.join("", expressions) + ")"); + } + +} diff --git a/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/ConnectionNamePredicate.java b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/ConnectionNamePredicate.java new file mode 100644 index 00000000000..3e4546c2198 --- /dev/null +++ b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/ConnectionNamePredicate.java @@ -0,0 +1,52 @@ +/* + * Copyright 2012-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.docker.compose.service.connection; + +import java.util.function.Predicate; + +import org.springframework.boot.docker.compose.core.ImageReference; +import org.springframework.boot.docker.compose.core.RunningService; + +/** + * {@link Predicate} that matches against connection names. + * + * @author Phillip Webb + */ +class ConnectionNamePredicate implements Predicate { + + private String required; + + ConnectionNamePredicate(String required) { + this.required = asCanonicalName(required); + } + + @Override + public boolean test(DockerComposeConnectionSource source) { + String actual = getActual(source.getRunningService()); + return this.required.equals(actual); + } + + private String getActual(RunningService service) { + String label = service.labels().get("org.springframework.boot.service-connection"); + return (label != null) ? asCanonicalName(label) : service.image().getName(); + } + + private String asCanonicalName(String name) { + return ImageReference.of(name).getName(); + } + +} diff --git a/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/DockerComposeConnectionDetailsFactory.java b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/DockerComposeConnectionDetailsFactory.java index 938d82dadaf..6d29c8bb024 100644 --- a/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/DockerComposeConnectionDetailsFactory.java +++ b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/DockerComposeConnectionDetailsFactory.java @@ -17,6 +17,7 @@ package org.springframework.boot.docker.compose.service.connection; import java.util.Arrays; +import java.util.function.Predicate; import org.springframework.boot.autoconfigure.service.connection.ConnectionDetails; import org.springframework.boot.autoconfigure.service.connection.ConnectionDetailsFactory; @@ -40,7 +41,7 @@ import org.springframework.util.ObjectUtils; public abstract class DockerComposeConnectionDetailsFactory implements ConnectionDetailsFactory { - private final String connectionName; + private final Predicate predicate; private final String[] requiredClassNames; @@ -50,7 +51,17 @@ public abstract class DockerComposeConnectionDetailsFactory predicate, + String... requiredClassNames) { + this.predicate = predicate; this.requiredClassNames = requiredClassNames; } @@ -60,12 +71,7 @@ public abstract class DockerComposeConnectionDetailsFactory ImageName.of(null)) + .withMessage("Value must not be empty"); + } + + @Test + void ofWhenNameIsEmptyThrowsException() { + assertThatIllegalArgumentException().isThrownBy(() -> ImageName.of("")).withMessage("Value must not be empty"); + } + + @Test + void ofWhenContainsUppercaseThrowsException() { + assertThatIllegalArgumentException().isThrownBy(() -> ImageName.of("Test")) + .withMessageContaining("Unable to parse name") + .withMessageContaining("Test"); + } + + @Test + void ofWhenNameIncludesTagThrowsException() { + assertThatIllegalArgumentException().isThrownBy(() -> ImageName.of("ubuntu:latest")) + .withMessageContaining("Unable to parse name") + .withMessageContaining(":latest"); + } + + @Test + void ofWhenNameIncludeDigestThrowsException() { + assertThatIllegalArgumentException().isThrownBy( + () -> ImageName.of("ubuntu@sha256:47bfdb88c3ae13e488167607973b7688f69d9e8c142c2045af343ec199649c09")) + .withMessageContaining("Unable to parse name") + .withMessageContaining("@sha256:47b"); + } + + @Test + void hashCodeAndEquals() { + ImageName n1 = ImageName.of("ubuntu"); + ImageName n2 = ImageName.of("library/ubuntu"); + ImageName n3 = ImageName.of("docker.io/ubuntu"); + ImageName n4 = ImageName.of("docker.io/library/ubuntu"); + ImageName n5 = ImageName.of("index.docker.io/library/ubuntu"); + ImageName n6 = ImageName.of("alpine"); + assertThat(n1).hasSameHashCodeAs(n2).hasSameHashCodeAs(n3).hasSameHashCodeAs(n4).hasSameHashCodeAs(n5); + assertThat(n1).isEqualTo(n1).isEqualTo(n2).isEqualTo(n3).isEqualTo(n4).isNotEqualTo(n6); + } + +} diff --git a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/core/ImageReferenceTests.java b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/core/ImageReferenceTests.java index d8f78ea018b..797b82e09d0 100644 --- a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/core/ImageReferenceTests.java +++ b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/core/ImageReferenceTests.java @@ -19,85 +19,150 @@ package org.springframework.boot.docker.compose.core; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; /** * Tests for {@link ImageReference}. * - * @author Moritz Halbritter - * @author Andy Wilkinson * @author Phillip Webb + * @author Scott Frederick */ class ImageReferenceTests { @Test - void getImageNameWhenImageOnly() { - ImageReference imageReference = ImageReference.of("redis"); - assertThat(imageReference.getImageName()).isEqualTo("redis"); + void ofSimpleName() { + ImageReference reference = ImageReference.of("ubuntu"); + assertThat(reference.getDomain()).isEqualTo("docker.io"); + assertThat(reference.getName()).isEqualTo("library/ubuntu"); + assertThat(reference.getTag()).isNull(); + assertThat(reference.getDigest()).isNull(); + assertThat(reference).hasToString("docker.io/library/ubuntu"); } @Test - void getImageNameWhenImageAndTag() { - ImageReference imageReference = ImageReference.of("redis:5"); - assertThat(imageReference.getImageName()).isEqualTo("redis"); + void ofLibrarySlashName() { + ImageReference reference = ImageReference.of("library/ubuntu"); + assertThat(reference.getDomain()).isEqualTo("docker.io"); + assertThat(reference.getName()).isEqualTo("library/ubuntu"); + assertThat(reference.getTag()).isNull(); + assertThat(reference.getDigest()).isNull(); + assertThat(reference).hasToString("docker.io/library/ubuntu"); } @Test - void getImageNameWhenImageAndDigest() { - ImageReference imageReference = ImageReference - .of("redis@sha256:0ed5d5928d4737458944eb604cc8509e245c3e19d02ad83935398bc4b991aac7"); - assertThat(imageReference.getImageName()).isEqualTo("redis"); + void ofSlashName() { + ImageReference reference = ImageReference.of("adoptopenjdk/openjdk11"); + assertThat(reference.getDomain()).isEqualTo("docker.io"); + assertThat(reference.getName()).isEqualTo("adoptopenjdk/openjdk11"); + assertThat(reference.getTag()).isNull(); + assertThat(reference.getDigest()).isNull(); + assertThat(reference).hasToString("docker.io/adoptopenjdk/openjdk11"); } @Test - void getImageNameWhenProjectAndImage() { - ImageReference imageReference = ImageReference.of("library/redis"); - assertThat(imageReference.getImageName()).isEqualTo("redis"); + void ofCustomDomain() { + ImageReference reference = ImageReference.of("repo.example.com/java/jdk"); + assertThat(reference.getDomain()).isEqualTo("repo.example.com"); + assertThat(reference.getName()).isEqualTo("java/jdk"); + assertThat(reference.getTag()).isNull(); + assertThat(reference.getDigest()).isNull(); + assertThat(reference).hasToString("repo.example.com/java/jdk"); } @Test - void getImageNameWhenRegistryLibraryAndImage() { - ImageReference imageReference = ImageReference.of("docker.io/library/redis"); - assertThat(imageReference.getImageName()).isEqualTo("redis"); + void ofCustomDomainAndPort() { + ImageReference reference = ImageReference.of("repo.example.com:8080/java/jdk"); + assertThat(reference.getDomain()).isEqualTo("repo.example.com:8080"); + assertThat(reference.getName()).isEqualTo("java/jdk"); + assertThat(reference.getTag()).isNull(); + assertThat(reference.getDigest()).isNull(); + assertThat(reference).hasToString("repo.example.com:8080/java/jdk"); } @Test - void getImageNameWhenRegistryLibraryImageAndTag() { - ImageReference imageReference = ImageReference.of("docker.io/library/redis:5"); - assertThat(imageReference.getImageName()).isEqualTo("redis"); + void ofLegacyDomain() { + ImageReference reference = ImageReference.of("index.docker.io/ubuntu"); + assertThat(reference.getDomain()).isEqualTo("docker.io"); + assertThat(reference.getName()).isEqualTo("library/ubuntu"); + assertThat(reference.getTag()).isNull(); + assertThat(reference.getDigest()).isNull(); + assertThat(reference).hasToString("docker.io/library/ubuntu"); } @Test - void getImageNameWhenRegistryLibraryImageAndDigest() { - ImageReference imageReference = ImageReference - .of("docker.io/library/redis@sha256:0ed5d5928d4737458944eb604cc8509e245c3e19d02ad83935398bc4b991aac7"); - assertThat(imageReference.getImageName()).isEqualTo("redis"); + void ofNameAndTag() { + ImageReference reference = ImageReference.of("ubuntu:bionic"); + assertThat(reference.getDomain()).isEqualTo("docker.io"); + assertThat(reference.getName()).isEqualTo("library/ubuntu"); + assertThat(reference.getTag()).isEqualTo("bionic"); + assertThat(reference.getDigest()).isNull(); + assertThat(reference).hasToString("docker.io/library/ubuntu:bionic"); } @Test - void getImageNameWhenRegistryWithPort() { - ImageReference imageReference = ImageReference.of("my_private.registry:5000/redis"); - assertThat(imageReference.getImageName()).isEqualTo("redis"); + void ofDomainPortAndTag() { + ImageReference reference = ImageReference.of("repo.example.com:8080/library/ubuntu:v1"); + assertThat(reference.getDomain()).isEqualTo("repo.example.com:8080"); + assertThat(reference.getName()).isEqualTo("library/ubuntu"); + assertThat(reference.getTag()).isEqualTo("v1"); + assertThat(reference.getDigest()).isNull(); + assertThat(reference).hasToString("repo.example.com:8080/library/ubuntu:v1"); } @Test - void getImageNameWhenRegistryWithPortAndTag() { - ImageReference imageReference = ImageReference.of("my_private.registry:5000/redis:5"); - assertThat(imageReference.getImageName()).isEqualTo("redis"); + void ofNameAndDigest() { + ImageReference reference = ImageReference + .of("ubuntu@sha256:6e9f67fa63b0323e9a1e587fd71c561ba48a034504fb804fd26fd8800039835d"); + assertThat(reference.getDomain()).isEqualTo("docker.io"); + assertThat(reference.getName()).isEqualTo("library/ubuntu"); + assertThat(reference.getTag()).isNull(); + assertThat(reference.getDigest()) + .isEqualTo("sha256:6e9f67fa63b0323e9a1e587fd71c561ba48a034504fb804fd26fd8800039835d"); + assertThat(reference).hasToString( + "docker.io/library/ubuntu@sha256:6e9f67fa63b0323e9a1e587fd71c561ba48a034504fb804fd26fd8800039835d"); } @Test - void toStringReturnsReferenceString() { - ImageReference imageReference = ImageReference.of("docker.io/library/redis"); - assertThat(imageReference).hasToString("docker.io/library/redis"); + void ofNameAndTagAndDigest() { + ImageReference reference = ImageReference + .of("ubuntu:bionic@sha256:6e9f67fa63b0323e9a1e587fd71c561ba48a034504fb804fd26fd8800039835d"); + assertThat(reference.getDomain()).isEqualTo("docker.io"); + assertThat(reference.getName()).isEqualTo("library/ubuntu"); + assertThat(reference.getTag()).isEqualTo("bionic"); + assertThat(reference.getDigest()) + .isEqualTo("sha256:6e9f67fa63b0323e9a1e587fd71c561ba48a034504fb804fd26fd8800039835d"); + assertThat(reference).hasToString( + "docker.io/library/ubuntu:bionic@sha256:6e9f67fa63b0323e9a1e587fd71c561ba48a034504fb804fd26fd8800039835d"); + } + + @Test + void ofCustomDomainAndPortWithTag() { + ImageReference reference = ImageReference + .of("example.com:8080/canonical/ubuntu:bionic@sha256:6e9f67fa63b0323e9a1e587fd71c561ba48a034504fb804fd26fd8800039835d"); + assertThat(reference.getDomain()).isEqualTo("example.com:8080"); + assertThat(reference.getName()).isEqualTo("canonical/ubuntu"); + assertThat(reference.getTag()).isEqualTo("bionic"); + assertThat(reference.getDigest()) + .isEqualTo("sha256:6e9f67fa63b0323e9a1e587fd71c561ba48a034504fb804fd26fd8800039835d"); + assertThat(reference).hasToString( + "example.com:8080/canonical/ubuntu:bionic@sha256:6e9f67fa63b0323e9a1e587fd71c561ba48a034504fb804fd26fd8800039835d"); + } + + @Test + void ofWhenHasIllegalCharacter() { + assertThatIllegalArgumentException() + .isThrownBy(() -> ImageReference + .of("registry.example.com/example/example-app:1.6.0-dev.2.uncommitted+wip.foo.c75795d")) + .withMessageContaining("Unable to parse image reference"); } @Test void equalsAndHashCode() { - ImageReference imageReference1 = ImageReference.of("docker.io/library/redis"); - ImageReference imageReference2 = ImageReference.of("docker.io/library/redis"); - ImageReference imageReference3 = ImageReference.of("docker.io/library/other"); - assertThat(imageReference1.hashCode()).isEqualTo(imageReference2.hashCode()); - assertThat(imageReference1).isEqualTo(imageReference1).isEqualTo(imageReference2).isNotEqualTo(imageReference3); + ImageReference r1 = ImageReference.of("ubuntu:bionic"); + ImageReference r2 = ImageReference.of("docker.io/library/ubuntu:bionic"); + ImageReference r3 = ImageReference.of("docker.io/library/ubuntu:latest"); + assertThat(r1).hasSameHashCodeAs(r2); + assertThat(r1).isEqualTo(r1).isEqualTo(r2).isNotEqualTo(r3); } } diff --git a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/ConnectionNamePredicateTests.java b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/ConnectionNamePredicateTests.java new file mode 100644 index 00000000000..183ca38ec17 --- /dev/null +++ b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/ConnectionNamePredicateTests.java @@ -0,0 +1,96 @@ +/* + * Copyright 2012-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.docker.compose.service.connection; + +import java.util.Map; +import java.util.function.Predicate; + +import org.junit.jupiter.api.Test; + +import org.springframework.boot.docker.compose.core.ImageReference; +import org.springframework.boot.docker.compose.core.RunningService; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link ConnectionNamePredicate}. + * + * @author Phillip Webb + */ +class ConnectionNamePredicateTests { + + @Test + void offical() { + assertThat(predicateOf("elasticsearch")).accepts(sourceOf("elasticsearch")); + assertThat(predicateOf("elasticsearch")).accepts(sourceOf("library/elasticsearch")); + assertThat(predicateOf("elasticsearch")).accepts(sourceOf("docker.io/library/elasticsearch")); + assertThat(predicateOf("elasticsearch")).accepts(sourceOf("docker.io/elasticsearch")); + assertThat(predicateOf("elasticsearch")).accepts(sourceOf("docker.io/elasticsearch:latest")); + assertThat(predicateOf("elasticsearch")).rejects(sourceOf("redis")); + assertThat(predicateOf("elasticsearch")).rejects(sourceOf("library/redis")); + assertThat(predicateOf("elasticsearch")).rejects(sourceOf("docker.io/library/redis")); + assertThat(predicateOf("elasticsearch")).rejects(sourceOf("docker.io/redis")); + assertThat(predicateOf("elasticsearch")).rejects(sourceOf("docker.io/redis")); + assertThat(predicateOf("zipkin")).rejects(sourceOf("openzipkin/zipkin")); + } + + @Test + void organization() { + assertThat(predicateOf("openzipkin/zipkin")).accepts(sourceOf("openzipkin/zipkin")); + assertThat(predicateOf("openzipkin/zipkin")).accepts(sourceOf("openzipkin/zipkin:latest")); + assertThat(predicateOf("openzipkin/zipkin")).rejects(sourceOf("openzipkin/zapkin")); + assertThat(predicateOf("openzipkin/zipkin")).rejects(sourceOf("zipkin")); + } + + @Test + void customDomain() { + assertThat(predicateOf("redis")).accepts(sourceOf("internalhost:8080/library/redis")); + assertThat(predicateOf("redis")).accepts(sourceOf("myhost.com/library/redis")); + assertThat(predicateOf("redis")).accepts(sourceOf("myhost.com:8080/library/redis")); + assertThat(predicateOf("redis")).rejects(sourceOf("internalhost:8080/redis")); + } + + @Test + void labeled() { + assertThat(predicateOf("redis")).accepts(sourceOf("internalhost:8080/myredis", "redis")); + assertThat(predicateOf("redis")).accepts(sourceOf("internalhost:8080/myredis", "library/redis")); + assertThat(predicateOf("openzipkin/zipkin")) + .accepts(sourceOf("internalhost:8080/libs/libs/mzipkin", "openzipkin/zipkin")); + } + + private Predicate predicateOf(String required) { + return new ConnectionNamePredicate(required); + } + + private DockerComposeConnectionSource sourceOf(String connectioName) { + return sourceOf(connectioName, null); + } + + private DockerComposeConnectionSource sourceOf(String connectioName, String label) { + DockerComposeConnectionSource source = mock(DockerComposeConnectionSource.class); + RunningService runningService = mock(RunningService.class); + given(source.getRunningService()).willReturn(runningService); + given(runningService.image()).willReturn(ImageReference.of(connectioName)); + if (label != null) { + given(runningService.labels()).willReturn(Map.of("org.springframework.boot.service-connection", label)); + } + return source; + } + +} diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/docker-compose.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/docker-compose.adoc index 45623ccb0a5..d1ee8622a54 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/docker-compose.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/docker-compose.adoc @@ -56,7 +56,7 @@ The following service connections are currently supported: | Containers named "redis" | `ZipkinConnectionDetails` -| Containers named "zipkin". +| Containers named "openzipkin/zipkin". |===