44import com .google .common .net .HostAndPort ;
55import lombok .AccessLevel ;
66import lombok .AllArgsConstructor ;
7- import lombok .Data ;
87import lombok .EqualsAndHashCode ;
8+ import lombok .With ;
99import org .jetbrains .annotations .NotNull ;
10+ import org .jetbrains .annotations .Nullable ;
11+ import org .testcontainers .utility .Versioning .Sha256Versioning ;
12+ import org .testcontainers .utility .Versioning .TagVersioning ;
1013
1114import java .util .regex .Pattern ;
1215
13- @ EqualsAndHashCode (exclude = "rawName" )
16+ @ EqualsAndHashCode (exclude = { "rawName" , "compatibleSubstituteFor" } )
1417@ AllArgsConstructor (access = AccessLevel .PRIVATE )
1518public final class DockerImageName {
1619
1720 /* Regex patterns used for validation */
1821 private static final String ALPHA_NUMERIC = "[a-z0-9]+" ;
19- private static final String SEPARATOR = "([\\ .]{1} |_{1,2}|-+)" ;
22+ private static final String SEPARATOR = "([.] |_{1,2}|-+)" ;
2023 private static final String REPO_NAME_PART = ALPHA_NUMERIC + "(" + SEPARATOR + ALPHA_NUMERIC + ")*" ;
2124 private static final Pattern REPO_NAME = Pattern .compile (REPO_NAME_PART + "(/" + REPO_NAME_PART + ")*" );
2225
2326 private final String rawName ;
2427 private final String registry ;
2528 private final String repo ;
26- @ NotNull private final Versioning versioning ;
29+ @ NotNull @ With (AccessLevel .PRIVATE )
30+ private final Versioning versioning ;
31+ @ Nullable @ With (AccessLevel .PRIVATE )
32+ private final DockerImageName compatibleSubstituteFor ;
2733
2834 /**
2935 * Parses a docker image name from a provided string.
@@ -52,8 +58,8 @@ public DockerImageName(String fullImageName) {
5258 String remoteName ;
5359 if (slashIndex == -1 ||
5460 (!fullImageName .substring (0 , slashIndex ).contains ("." ) &&
55- !fullImageName .substring (0 , slashIndex ).contains (":" ) &&
56- !fullImageName .substring (0 , slashIndex ).equals ("localhost" ))) {
61+ !fullImageName .substring (0 , slashIndex ).contains (":" ) &&
62+ !fullImageName .substring (0 , slashIndex ).equals ("localhost" ))) {
5763 registry = "" ;
5864 remoteName = fullImageName ;
5965 } else {
@@ -69,8 +75,10 @@ public DockerImageName(String fullImageName) {
6975 versioning = new TagVersioning (remoteName .split (":" )[1 ]);
7076 } else {
7177 repo = remoteName ;
72- versioning = new TagVersioning ( "latest" ) ;
78+ versioning = Versioning . ANY ;
7379 }
80+
81+ compatibleSubstituteFor = null ;
7482 }
7583
7684 /**
@@ -92,8 +100,8 @@ public DockerImageName(String nameWithoutTag, @NotNull String version) {
92100 String remoteName ;
93101 if (slashIndex == -1 ||
94102 (!nameWithoutTag .substring (0 , slashIndex ).contains ("." ) &&
95- !nameWithoutTag .substring (0 , slashIndex ).contains (":" ) &&
96- !nameWithoutTag .substring (0 , slashIndex ).equals ("localhost" ))) {
103+ !nameWithoutTag .substring (0 , slashIndex ).contains (":" ) &&
104+ !nameWithoutTag .substring (0 , slashIndex ).equals ("localhost" ))) {
97105 registry = "" ;
98106 remoteName = nameWithoutTag ;
99107 } else {
@@ -108,6 +116,8 @@ public DockerImageName(String nameWithoutTag, @NotNull String version) {
108116 repo = remoteName ;
109117 versioning = new TagVersioning (version );
110118 }
119+
120+ compatibleSubstituteFor = null ;
111121 }
112122
113123 /**
@@ -132,7 +142,7 @@ public String getVersionPart() {
132142 * @return canonical name for the image
133143 */
134144 public String asCanonicalNameString () {
135- return getUnversionedPart () + versioning .getSeparator () + versioning . toString ();
145+ return getUnversionedPart () + versioning .getSeparator () + getVersionPart ();
136146 }
137147
138148 @ Override
@@ -146,7 +156,8 @@ public String toString() {
146156 * @throws IllegalArgumentException if not valid
147157 */
148158 public void assertValid () {
149- HostAndPort .fromString (registry );
159+ //noinspection UnstableApiUsage
160+ HostAndPort .fromString (registry ); // return value ignored - this throws if registry is not a valid host:port string
150161 if (!REPO_NAME .matcher (repo ).matches ()) {
151162 throw new IllegalArgumentException (repo + " is not a valid Docker image name (in " + rawName + ")" );
152163 }
@@ -159,63 +170,98 @@ public String getRegistry() {
159170 return registry ;
160171 }
161172
173+ /**
174+ * @param newTag version tag for the copy to use
175+ * @return an immutable copy of this {@link DockerImageName} with the new version tag
176+ */
162177 public DockerImageName withTag (final String newTag ) {
163- return new DockerImageName ( rawName , registry , repo , new TagVersioning (newTag ));
178+ return withVersioning ( new TagVersioning (newTag ));
164179 }
165180
166- private interface Versioning {
167- boolean isValid ();
168-
169- String getSeparator ();
181+ /**
182+ * Declare that this {@link DockerImageName} is a compatible substitute for another image - i.e. that this image
183+ * behaves as the other does, and is compatible with Testcontainers' assumptions about the other image.
184+ *
185+ * @param otherImageName the image name of the other image
186+ * @return an immutable copy of this {@link DockerImageName} with the compatibility declaration attached.
187+ */
188+ public DockerImageName asCompatibleSubstituteFor (String otherImageName ) {
189+ return withCompatibleSubstituteFor (DockerImageName .parse (otherImageName ));
170190 }
171191
172- @ Data
173- private static class TagVersioning implements Versioning {
174- public static final String TAG_REGEX = "[\\ w][\\ w\\ .\\ -]{0,127}" ;
175- private final String tag ;
176-
177- TagVersioning (String tag ) {
178- this .tag = tag ;
179- }
192+ /**
193+ * Declare that this {@link DockerImageName} is a compatible substitute for another image - i.e. that this image
194+ * behaves as the other does, and is compatible with Testcontainers' assumptions about the other image.
195+ *
196+ * @param otherImageName the image name of the other image
197+ * @return an immutable copy of this {@link DockerImageName} with the compatibility declaration attached.
198+ */
199+ public DockerImageName asCompatibleSubstituteFor (DockerImageName otherImageName ) {
200+ return withCompatibleSubstituteFor (otherImageName );
201+ }
180202
181- @ Override
182- public boolean isValid () {
183- return tag .matches (TAG_REGEX );
203+ /**
204+ * Test whether this {@link DockerImageName} has declared compatibility with another image (set using
205+ * {@link DockerImageName#asCompatibleSubstituteFor(String)} or
206+ * {@link DockerImageName#asCompatibleSubstituteFor(DockerImageName)}.
207+ * <p>
208+ * If a version tag part is present in the <code>other</code> image name, the tags must exactly match, unless it
209+ * is 'latest'. If a version part is not present in the <code>other</code> image name, the tag contents are ignored.
210+ *
211+ * @param other the other image that we are trying to test compatibility with
212+ * @return whether this image has declared compatibility.
213+ */
214+ public boolean isCompatibleWith (DockerImageName other ) {
215+ // is this image already the same or equivalent?
216+ if (other .equals (this )) {
217+ return true ;
184218 }
185219
186- @ Override
187- public String getSeparator () {
188- return ":" ;
220+ if (this .compatibleSubstituteFor == null ) {
221+ return false ;
189222 }
190223
191- @ Override
192- public String toString () {
193- return tag ;
194- }
224+ return this .compatibleSubstituteFor .isCompatibleWith (other );
195225 }
196226
197- @ Data
198- private static class Sha256Versioning implements Versioning {
199- public static final String HASH_REGEX = "[0-9a-fA-F]{32,}" ;
200- private final String hash ;
201-
202- Sha256Versioning (String hash ) {
203- this .hash = hash ;
204- }
205-
206- @ Override
207- public boolean isValid () {
208- return hash .matches (HASH_REGEX );
227+ /**
228+ * Behaves as {@link DockerImageName#isCompatibleWith(DockerImageName)} but throws an exception
229+ * rather than returning false if a mismatch is detected.
230+ *
231+ * @param anyOthers the other image(s) that we are trying to check compatibility with. If more
232+ * than one is provided, this method will check compatibility with at least one
233+ * of them.
234+ * @throws IllegalStateException if {@link DockerImageName#isCompatibleWith(DockerImageName)}
235+ * returns false
236+ */
237+ public void assertCompatibleWith (DockerImageName ... anyOthers ) {
238+ if (anyOthers .length == 0 ) {
239+ throw new IllegalArgumentException ("anyOthers parameter must be non-empty" );
209240 }
210241
211- @ Override
212- public String getSeparator () {
213- return "@" ;
242+ for (DockerImageName anyOther : anyOthers ) {
243+ if (this .isCompatibleWith (anyOther )) {
244+ return ;
245+ }
214246 }
215247
216- @ Override
217- public String toString () {
218- return "sha256:" + hash ;
219- }
248+ final DockerImageName exampleOther = anyOthers [0 ];
249+
250+ throw new IllegalStateException (
251+ String .format (
252+ "Failed to verify that image '%s' is a compatible substitute for '%s'. This generally means that "
253+ +
254+ "you are trying to use an image that Testcontainers has not been designed to use. If this is "
255+ +
256+ "deliberate, and if you are confident that the image is compatible, you should declare "
257+ +
258+ "compatibility in code using the `asCompatibleSubstituteFor` method. For example:\n "
259+ +
260+ " DockerImageName myImage = DockerImageName.parse(\" %s\" ).asCompatibleSubstituteFor(\" %s\" );\n "
261+ +
262+ "and then use `myImage` instead." ,
263+ this .rawName , exampleOther .rawName , this .rawName , exampleOther .rawName
264+ )
265+ );
220266 }
221267}
0 commit comments