66
77use Closure ;
88use Eznix86 \PestPluginTestContainers \Container \PortMapping \FixedPortSequenceGenerator ;
9- use Eznix86 \PestPluginTestContainers \Container \PortMapping \PortAllocator ;
10- use Eznix86 \PestPluginTestContainers \Container \PortMapping \ProtocolAwareRandomUniquePortAllocator ;
11- use Eznix86 \PestPluginTestContainers \Container \PortMapping \SaferRandomUniquePortGenerator ;
12- use Eznix86 \PestPluginTestContainers \Container \PortMapping \WorkerPortAllocator ;
13- use Eznix86 \PestPluginTestContainers \Container \PortMapping \WorkerPortGenerator ;
149use Eznix86 \PestPluginTestContainers \Container \Reuse \ReusableContainerResolver ;
1510use Eznix86 \PestPluginTestContainers \Container \Reuse \ReuseOptions ;
1611use Eznix86 \PestPluginTestContainers \Container \Reuse \WorkerTokenResolver ;
1712use InvalidArgumentException ;
18- use RuntimeException ;
1913use Testcontainers \Container \GenericContainer ;
2014use Testcontainers \Container \HttpMethod ;
2115use Testcontainers \ContainerClient \DockerContainerClient ;
2620
2721final readonly class ContainerBuilder
2822{
23+ private const string MANAGED_BY_LABEL_KEY = 'pest-plugin-testcontainers.managed ' ;
24+
25+ private const string MANAGED_BY_LABEL_VALUE = '1 ' ;
26+
2927 private const int START_MAX_ATTEMPTS = 6 ;
3028
3129 private const int START_RETRY_BASE_DELAY_MICROSECONDS = 500_000 ;
3230
3331 private const int START_RETRY_MAX_DELAY_MICROSECONDS = 5_000_000 ;
3432
35- /**
36- * @var list<string>
37- */
38- private const array TRANSIENT_DOCKER_START_ERROR_MARKERS = [
39- 'server error ' ,
40- ];
41-
4233 private GenericContainer $ container ;
4334
44- private PortAllocator $ portAllocator ;
45-
4635 private ReuseOptions $ reuseOptions ;
4736
4837 private ReusableContainerResolver $ reusableContainerResolver ;
@@ -68,15 +57,10 @@ public function __construct(
6857 callable $ registerContainer ,
6958 callable $ skipTest ,
7059 ) {
71- $ this ->container = new GenericContainer ($ image );
72-
73- if ($ this ->isRunningInParallel ()) {
74- $ this ->container ->withPortGenerator (new WorkerPortGenerator );
75- $ this ->portAllocator = new WorkerPortAllocator ;
76- } else {
77- $ this ->container ->withPortGenerator (new SaferRandomUniquePortGenerator );
78- $ this ->portAllocator = new ProtocolAwareRandomUniquePortAllocator ;
79- }
60+ $ this ->container = (new GenericContainer ($ image ))
61+ ->withLabels ([
62+ self ::MANAGED_BY_LABEL_KEY => self ::MANAGED_BY_LABEL_VALUE ,
63+ ]);
8064
8165 $ this ->reuseOptions = new ReuseOptions ;
8266 $ this ->reusableContainerResolver = new ReusableContainerResolver ;
@@ -85,11 +69,6 @@ public function __construct(
8569 $ this ->skipTest = Closure::fromCallable ($ skipTest );
8670 }
8771
88- private function isRunningInParallel (): bool
89- {
90- return ($ _SERVER ['PEST_PARALLEL ' ] ?? $ _ENV ['PEST_PARALLEL ' ] ?? '0 ' ) === '1 ' ;
91- }
92-
9372 /**
9473 * @param list<int|string>|array<int|string, int|string> $ports
9574 */
@@ -98,10 +77,8 @@ public function ports(array $ports): self
9877 if (array_is_list ($ ports )) {
9978 /** @var list<int|string> $containerPorts */
10079 $ containerPorts = array_map ($ this ->normalizeContainerPort (...), $ ports );
101- /** @var list<int> $hostPorts */
102- $ hostPorts = array_map ($ this ->portAllocator ->allocateForContainerPort (...), $ containerPorts );
10380
104- $ this ->configurePortMapping ( $ containerPorts, $ hostPorts );
81+ $ this ->container -> withExposedPorts (... $ containerPorts );
10582
10683 return $ this ;
10784 }
@@ -329,9 +306,18 @@ public function start(): StartedContainer
329306 if ($ reusedContainer instanceof StartedContainer) {
330307 return ($ this ->registerContainer )($ reusedContainer );
331308 }
309+
310+ try {
311+ $ recreatedContainer = $ this ->startContainerWithRetry ();
312+ $ recreatedContainer ->skipAutoCleanup ();
313+
314+ return ($ this ->registerContainer )($ recreatedContainer );
315+ } catch (Throwable $ recreateException ) {
316+ $ exception = $ recreateException ;
317+ }
332318 }
333319
334- ($ this ->skipTest )('Container startup issue: ' .$ exception -> getMessage ( ));
320+ ($ this ->skipTest )('Container startup issue: ' .$ this -> describeThrowable ( $ exception ));
335321 }
336322 }
337323
@@ -343,38 +329,54 @@ private function startContainerWithRetry(): StartedContainer
343329 try {
344330 return new StartedContainer ($ this ->container ->start ());
345331 } catch (Throwable $ exception ) {
346- if (! $ this ->isTransientDockerStartError ($ exception )) {
347- throw $ exception ;
348- }
349-
350- $ this ->cleanupFailedContainerAfterTransientStartError ();
332+ $ this ->cleanupFailedContainerAfterStartError ();
351333 $ lastException = $ exception ;
352334
353335 if ($ attempt < self ::START_MAX_ATTEMPTS - 1 ) {
354336 usleep ($ this ->startRetryDelayForAttempt ($ attempt ));
355337 }
356338 }
357339 }
358-
359- throw new RuntimeException (
360- 'Container failed to start after retrying transient Docker errors. ' ,
361- previous: $ lastException ,
362- );
340+ throw $ lastException ;
363341 }
364342
365- private function isTransientDockerStartError (Throwable $ exception ): bool
343+ private function describeThrowable (Throwable $ exception ): string
366344 {
345+ $ descriptions = [];
346+
367347 for ($ current = $ exception ; $ current instanceof Throwable; $ current = $ current ->getPrevious ()) {
368- $ message = strtolower ($ current ->getMessage ());
348+ $ message = trim ($ current ->getMessage ());
349+
350+ if (method_exists ($ current , 'getErrorResponse ' )) {
351+ $ errorResponse = $ current ->getErrorResponse ();
352+
353+ if (is_object ($ errorResponse ) && method_exists ($ errorResponse , 'getMessage ' )) {
354+ $ dockerMessage = $ errorResponse ->getMessage ();
369355
370- foreach ( self :: TRANSIENT_DOCKER_START_ERROR_MARKERS as $ marker ) {
371- if ( str_contains ( $ message, $ marker )) {
372- return true ;
356+ if ( is_string ( $ dockerMessage ) && $ dockerMessage !== '' && ! str_contains ( $ message , $ dockerMessage ) ) {
357+ $ message = trim ( $ message. ' | docker: ' . $ dockerMessage );
358+ }
373359 }
374360 }
361+
362+ if (method_exists ($ current , 'getResponse ' )) {
363+ $ response = $ current ->getResponse ();
364+
365+ if (is_object ($ response ) && method_exists ($ response , 'getStatusCode ' )) {
366+ $ statusCode = $ response ->getStatusCode ();
367+ $ reason = method_exists ($ response , 'getReasonPhrase ' ) ? $ response ->getReasonPhrase () : '' ;
368+
369+ if (is_int ($ statusCode )) {
370+ $ httpPart = sprintf ('HTTP %d%s ' , $ statusCode , is_string ($ reason ) && $ reason !== '' ? ' ' .$ reason : '' );
371+ $ message = trim ($ message .' | ' .$ httpPart );
372+ }
373+ }
374+ }
375+
376+ $ descriptions [] = sprintf ('%s: %s ' , $ current ::class, $ message === '' ? '(empty message) ' : $ message );
375377 }
376378
377- return false ;
379+ return implode ( ' <- ' , $ descriptions ) ;
378380 }
379381
380382 private function startRetryDelayForAttempt (int $ attempt ): int
@@ -384,7 +386,7 @@ private function startRetryDelayForAttempt(int $attempt): int
384386 return min ($ delay , self ::START_RETRY_MAX_DELAY_MICROSECONDS );
385387 }
386388
387- private function cleanupFailedContainerAfterTransientStartError (): void
389+ private function cleanupFailedContainerAfterStartError (): void
388390 {
389391 try {
390392 $ containerId = $ this ->container ->getId ();
0 commit comments