11package org .testcontainers .containers ;
22
3+ import com .fasterxml .jackson .core .JsonProcessingException ;
4+ import com .fasterxml .jackson .databind .MapperFeature ;
5+ import com .fasterxml .jackson .databind .ObjectMapper ;
6+ import com .fasterxml .jackson .databind .SerializationFeature ;
37import com .github .dockerjava .api .DockerClient ;
48import com .github .dockerjava .api .command .CreateContainerCmd ;
59import com .github .dockerjava .api .command .InspectContainerResponse ;
1216import com .github .dockerjava .api .model .PortBinding ;
1317import com .github .dockerjava .api .model .Volume ;
1418import com .github .dockerjava .api .model .VolumesFrom ;
19+ import com .google .common .annotations .VisibleForTesting ;
1520import com .google .common .base .Strings ;
21+ import com .google .common .collect .ImmutableMap ;
1622import lombok .AccessLevel ;
1723import lombok .Data ;
18- import lombok .EqualsAndHashCode ;
1924import lombok .NonNull ;
2025import lombok .Setter ;
2126import lombok .SneakyThrows ;
27+ import org .apache .commons .codec .digest .DigestUtils ;
2228import org .apache .commons .compress .archivers .tar .TarArchiveInputStream ;
2329import org .apache .commons .compress .archivers .tar .TarArchiveOutputStream ;
2430import org .apache .commons .compress .utils .IOUtils ;
3339import org .rnorth .visibleassertions .VisibleAssertions ;
3440import org .slf4j .Logger ;
3541import org .testcontainers .DockerClientFactory ;
42+ import org .testcontainers .UnstableAPI ;
3643import org .testcontainers .containers .output .OutputFrame ;
3744import org .testcontainers .containers .startupcheck .IsRunningStartupCheckStrategy ;
3845import org .testcontainers .containers .startupcheck .MinimumDurationRunningStartupCheckStrategy ;
6269import java .io .FileOutputStream ;
6370import java .io .IOException ;
6471import java .io .InputStream ;
72+ import java .lang .reflect .Method ;
6573import java .nio .charset .Charset ;
6674import java .nio .file .Path ;
6775import java .time .Duration ;
76+ import java .time .Instant ;
6877import java .util .ArrayList ;
6978import java .util .Arrays ;
79+ import java .util .Collection ;
7080import java .util .Collections ;
7181import java .util .HashMap ;
7282import java .util .HashSet ;
@@ -100,6 +110,8 @@ public class GenericContainer<SELF extends GenericContainer<SELF>>
100110
101111 public static final String INTERNAL_HOST_HOSTNAME = "host.testcontainers.internal" ;
102112
113+ static final String HASH_LABEL = "org.testcontainers.hash" ;
114+
103115 /*
104116 * Default settings
105117 */
@@ -168,11 +180,12 @@ public class GenericContainer<SELF extends GenericContainer<SELF>>
168180
169181 protected final Set <Startable > dependencies = new HashSet <>();
170182
171- /*
183+ /**
172184 * Unique instance of DockerClient for use by this container object.
185+ * We use {@link LazyDockerClient} here to avoid eager client creation
173186 */
174187 @ Setter (AccessLevel .NONE )
175- protected DockerClient dockerClient = DockerClientFactory . instance (). client () ;
188+ protected DockerClient dockerClient = LazyDockerClient . INSTANCE ;
176189
177190 /*
178191 * Info about the Docker server; lazily fetched.
@@ -222,6 +235,8 @@ public class GenericContainer<SELF extends GenericContainer<SELF>>
222235 @ Nullable
223236 private Map <String , String > tmpFsMapping ;
224237
238+ @ Setter (AccessLevel .NONE )
239+ private boolean shouldBeReused = false ;
225240
226241 public GenericContainer () {
227242 this (TestcontainersConfiguration .getInstance ().getTinyImage ());
@@ -276,13 +291,15 @@ protected void doStart() {
276291 try {
277292 configure ();
278293
294+ Instant startedAt = Instant .now ();
295+
279296 logger ().debug ("Starting container: {}" , getDockerImageName ());
280297 logger ().debug ("Trying to start container: {}" , image .get ());
281298
282299 AtomicInteger attempt = new AtomicInteger (0 );
283300 Unreliables .retryUntilSuccess (startupAttempts , () -> {
284301 logger ().debug ("Trying to start container: {} (attempt {}/{})" , image .get (), attempt .incrementAndGet (), startupAttempts );
285- tryStart ();
302+ tryStart (startedAt );
286303 return true ;
287304 });
288305
@@ -291,7 +308,25 @@ protected void doStart() {
291308 }
292309 }
293310
294- private void tryStart () {
311+ @ UnstableAPI
312+ @ SneakyThrows
313+ protected boolean canBeReused () {
314+ for (Class <?> type = getClass (); type != GenericContainer .class ; type = type .getSuperclass ()) {
315+ try {
316+ Method method = type .getDeclaredMethod ("containerIsCreated" , String .class );
317+ if (method .getDeclaringClass () != GenericContainer .class ) {
318+ logger ().warn ("{} can't be reused because it overrides {}" , getClass (), method .getName ());
319+ return false ;
320+ }
321+ } catch (NoSuchMethodException e ) {
322+ // ignore
323+ }
324+ }
325+
326+ return true ;
327+ }
328+
329+ private void tryStart (Instant startedAt ) {
295330 try {
296331 String dockerImageName = image .get ();
297332 logger ().debug ("Starting container: {}" , dockerImageName );
@@ -300,16 +335,49 @@ private void tryStart() {
300335 CreateContainerCmd createCommand = dockerClient .createContainerCmd (dockerImageName );
301336 applyConfiguration (createCommand );
302337
303- containerId = createCommand .exec ().getId ( );
338+ createCommand .getLabels ().put ( DockerClientFactory . TESTCONTAINERS_LABEL , "true" );
304339
305- connectToPortForwardingNetwork (createCommand .getNetworkMode ());
340+ boolean reused = false ;
341+ if (shouldBeReused ) {
342+ if (!canBeReused ()) {
343+ throw new IllegalStateException ("This container does not support reuse" );
344+ }
306345
307- copyToFileContainerPathMap .forEach (this ::copyFileToContainer );
346+ if (TestcontainersConfiguration .getInstance ().environmentSupportsReuse ()) {
347+ String hash = hash (createCommand );
308348
309- containerIsCreated ( containerId );
349+ containerId = findContainerForReuse ( hash ). orElse ( null );
310350
311- logger ().info ("Starting container with ID: {}" , containerId );
312- dockerClient .startContainerCmd (containerId ).exec ();
351+ if (containerId != null ) {
352+ logger ().info ("Reusing container with ID: {} and hash: {}" , containerId , hash );
353+ reused = true ;
354+ } else {
355+ logger ().debug ("Can't find a reusable running container with hash: {}" , hash );
356+
357+ createCommand .getLabels ().put (HASH_LABEL , hash );
358+ }
359+ } else {
360+ logger ().info ("Reuse was requested but the environment does not support the reuse of containers" );
361+ }
362+ } else {
363+ createCommand .getLabels ().put (DockerClientFactory .TESTCONTAINERS_SESSION_ID_LABEL , DockerClientFactory .SESSION_ID );
364+ }
365+
366+ if (!reused ) {
367+ containerId = createCommand .exec ().getId ();
368+
369+ // TODO add to the hash
370+ copyToFileContainerPathMap .forEach (this ::copyFileToContainer );
371+ }
372+
373+ connectToPortForwardingNetwork (createCommand .getNetworkMode ());
374+
375+ if (!reused ) {
376+ containerIsCreated (containerId );
377+
378+ logger ().info ("Starting container with ID: {}" , containerId );
379+ dockerClient .startContainerCmd (containerId ).exec ();
380+ }
313381
314382 logger ().info ("Container {} is starting: {}" , dockerImageName , containerId );
315383
@@ -331,7 +399,7 @@ private void tryStart() {
331399 // Wait until the process within the container has become ready for use (e.g. listening on network, log message emitted, etc).
332400 waitUntilContainerStarted ();
333401
334- logger ().info ("Container {} started" , dockerImageName );
402+ logger ().info ("Container {} started in {} " , dockerImageName , Duration . between ( startedAt , Instant . now ()) );
335403 containerIsStarted (containerInfo );
336404 } catch (Exception e ) {
337405 logger ().error ("Could not start container" , e );
@@ -351,6 +419,31 @@ private void tryStart() {
351419 }
352420 }
353421
422+ @ UnstableAPI
423+ @ SneakyThrows (JsonProcessingException .class )
424+ final String hash (CreateContainerCmd createCommand ) {
425+ // TODO add Testcontainers' version to the hash
426+ String commandJson = new ObjectMapper ()
427+ .enable (MapperFeature .SORT_PROPERTIES_ALPHABETICALLY )
428+ .enable (SerializationFeature .ORDER_MAP_ENTRIES_BY_KEYS )
429+ .writeValueAsString (createCommand );
430+
431+ return DigestUtils .sha1Hex (commandJson );
432+ }
433+
434+ @ VisibleForTesting
435+ Optional <String > findContainerForReuse (String hash ) {
436+ // TODO locking
437+ return dockerClient .listContainersCmd ()
438+ .withLabelFilter (ImmutableMap .of (HASH_LABEL , hash ))
439+ .withLimit (1 )
440+ .withStatusFilter (Arrays .asList ("running" ))
441+ .exec ()
442+ .stream ()
443+ .findAny ()
444+ .map (it -> it .getId ());
445+ }
446+
354447 /**
355448 * Set any custom settings for the create command such as shared memory size.
356449 */
@@ -613,7 +706,6 @@ private void applyConfiguration(CreateContainerCmd createCommand) {
613706 if (createCommand .getLabels () != null ) {
614707 combinedLabels .putAll (createCommand .getLabels ());
615708 }
616- combinedLabels .putAll (DockerClientFactory .DEFAULT_LABELS );
617709
618710 createCommand .withLabels (combinedLabels );
619711 }
@@ -1215,7 +1307,7 @@ public SELF withStartupAttempts(int attempts) {
12151307 }
12161308
12171309 /**
1218- * Allow low level modifications of {@link CreateContainerCmd} after it was pre-configured in {@link #tryStart()}.
1310+ * Allow low level modifications of {@link CreateContainerCmd} after it was pre-configured in {@link #tryStart(Instant )}.
12191311 * Invocation happens eagerly on a moment when container is created.
12201312 * Warning: this does expose the underlying docker-java API so might change outside of our control.
12211313 *
@@ -1247,6 +1339,12 @@ public SELF withTmpFs(Map<String, String> mapping) {
12471339 return self ();
12481340 }
12491341
1342+ @ UnstableAPI
1343+ public SELF withReuse (boolean reusable ) {
1344+ this .shouldBeReused = reusable ;
1345+ return self ();
1346+ }
1347+
12501348 @ Override
12511349 public boolean equals (Object o ) {
12521350 return this == o ;
0 commit comments