diff --git a/src/Container/Container.php b/src/Container/Container.php index b3f7696..2dd943f 100644 --- a/src/Container/Container.php +++ b/src/Container/Container.php @@ -4,6 +4,8 @@ namespace Testcontainers\Container; +use Testcontainers\Utils\PortGenerator\FixedPortGenerator; + /** * Added for backward compatibility. * @deprecated Use GenericContainer instead. @@ -52,6 +54,7 @@ public function withPrivileged(bool $privileged = true): self */ public function withPort(string $localPort, string $containerPort): self { + $this->withPortGenerator(new FixedPortGenerator([(int)$localPort])); return $this->withExposedPorts($containerPort); } diff --git a/src/Container/GenericContainer.php b/src/Container/GenericContainer.php index ca0ba6f..326291b 100644 --- a/src/Container/GenericContainer.php +++ b/src/Container/GenericContainer.php @@ -5,16 +5,21 @@ namespace Testcontainers\Container; use Docker\API\Exception\ContainerCreateNotFoundException; +use Docker\API\Model\ContainerCreateResponse; use Docker\API\Model\ContainersCreatePostBody; +use Docker\API\Model\EndpointSettings; use Docker\API\Model\HealthConfig; use Docker\API\Model\HostConfig; use Docker\API\Model\Mount; +use Docker\API\Model\NetworkingConfig; use Docker\API\Model\PortBinding; use Docker\Docker; use Docker\Stream\CreateImageStream; use InvalidArgumentException; use Testcontainers\ContainerClient\DockerContainerClient; +use Testcontainers\Utils\PortGenerator\PortGenerator; use Testcontainers\Utils\PortGenerator\RandomUniquePortGenerator; +use Testcontainers\Utils\PortNormalizer; use Testcontainers\Wait\WaitForContainer; use Testcontainers\Wait\WaitStrategy; @@ -40,9 +45,14 @@ class GenericContainer implements TestContainer protected WaitStrategy $waitStrategy; + protected PortGenerator $portGenerator; + protected bool $isPrivileged = false; protected ?string $networkName = null; + protected int $startAttempts = 0; + protected const MAX_START_ATTEMPTS = 2; + /** * @var array */ @@ -55,6 +65,8 @@ public function __construct(string $image) { $this->image = $image; $this->dockerClient = DockerContainerClient::getDockerClient(); + $this->waitStrategy = new WaitForContainer(); + $this->portGenerator = new RandomUniquePortGenerator(); } public function getId(): string @@ -111,12 +123,19 @@ public function withWait(WaitStrategy $waitStrategy): static return $this; } - public function withHealthCheckCommand(string $command, int $healthCheckIntervalInMS = 1000): static - { - $this->healthConfig = new HealthConfig([ - 'Test' => ['CMD', $command], - 'Interval' => $healthCheckIntervalInMS, - ]); + public function withHealthCheckCommand( + string $command, + int $intervalInMilliseconds = 1000, + int $timeoutInMilliseconds = 3000, + int $retries = 3, + int $startPeriodInMilliseconds = 0 + ): static { + $this->healthConfig = new HealthConfig(); + $this->healthConfig->setTest(['CMD-SHELL', $command]); + $this->healthConfig->setInterval($intervalInMilliseconds * 1_000_000); + $this->healthConfig->setTimeout($timeoutInMilliseconds * 1_000_000); + $this->healthConfig->setRetries($retries); + $this->healthConfig->setStartPeriod($startPeriodInMilliseconds * 1_000_000); return $this; } @@ -144,37 +163,13 @@ public function withExposedPorts(...$ports): static $this->withExposedPorts(...$port); } else { // Handle single port entry, either string or int - $this->exposedPorts[] = $this->normalizePort($port); + $this->exposedPorts[] = PortNormalizer::normalizePort($port); } } return $this; } - /** - * Normalize a port specification to ensure it includes a protocol. - * Defaults to 'tcp' if no protocol is specified. - * - * @param string|int $port Port to normalize. - * @return string Normalized port string. - * - * TODO: move this to a utility class - */ - private function normalizePort(string|int $port): string - { - if (is_int($port)) { - // Direct integer ports default to tcp - return "{$port}/tcp"; - } - - // Check if the port specification already includes a protocol - if (is_string($port) && !str_contains($port, '/')) { - return "{$port}/tcp"; - } - - return $port; - } - public function withPrivilegedMode(bool $privileged = true): static { $this->isPrivileged = $privileged; @@ -190,66 +185,125 @@ public function withNetwork(string $networkName): static return $this; } - //TODO: needs refactoring + public function withPortGenerator(PortGenerator $portGenerator): static + { + $this->portGenerator = $portGenerator; + + return $this; + } + public function start(): StartedGenericContainer { + $this->startAttempts++; + $containerConfig = $this->createContainerConfig(); try { - $containerCreatePostBody = new ContainersCreatePostBody(); - //handle withExposedPorts - if (!empty($this->exposedPorts)) { - $portGenerator = new RandomUniquePortGenerator(); - $portMap = new \ArrayObject(); - - foreach ($this->exposedPorts as $port) { - $portBinding = new PortBinding(); - $portBinding->setHostPort((string) $portGenerator->generatePort()); - $portBinding->setHostIp('0.0.0.0'); - $portMap[$port] = [$portBinding]; - } - - $hostConfig = new HostConfig(); - $hostConfig->setPortBindings($portMap); - //handle withPrivilegedMode - if ($this->isPrivileged) { - $hostConfig->setPrivileged($this->isPrivileged); - } - $containerCreatePostBody->setHostConfig($hostConfig); - } - //handle withPrivilegedMode - if ($this->isPrivileged) { - $hostConfig = new HostConfig(); - $hostConfig->setPrivileged($this->isPrivileged); - } - $containerCreatePostBody->setImage($this->image); - $containerCreatePostBody->setCmd($this->command); - $envs = []; - foreach ($this->env as $key => $value) { - $envs[] = $key . '=' . $value; - } - $containerCreatePostBody->setEnv($envs); - - $containerCreateResponse = $this->dockerClient->containerCreate($containerCreatePostBody); + /** @var ContainerCreateResponse|null $containerCreateResponse */ + $containerCreateResponse = $this->dockerClient->containerCreate($containerConfig); $this->id = $containerCreateResponse?->getId() ?? ''; } catch (ContainerCreateNotFoundException) { - /** @var CreateImageStream $imageCreateResponse */ - $imageCreateResponse = $this->dockerClient->imageCreate(null, [ - 'fromImage' => explode(':', $this->image)[0], - 'tag' => explode(':', $this->image)[1] ?? 'latest', - ]); - $imageCreateResponse->wait(); - + if ($this->startAttempts >= self::MAX_START_ATTEMPTS) { + throw new \RuntimeException("Failed to start container after pulling image."); + } + // If the image is not found, pull it and try again + // TODO: add withPullPolicy support + $this->pullImage(); return $this->start(); } $this->dockerClient->containerStart($this->id); - if (!isset($this->waitStrategy)) { - $this->withWait(new WaitForContainer()); - } - $startedContainer = new StartedGenericContainer($this->id); $this->waitStrategy->wait($startedContainer); return $startedContainer; } + + protected function createContainerConfig(): ContainersCreatePostBody + { + $containerCreatePostBody = new ContainersCreatePostBody(); + $containerCreatePostBody->setImage($this->image); + $containerCreatePostBody->setCmd($this->command); + + $envs = array_map(static fn ($key, $value) => "$key=$value", array_keys($this->env), $this->env); + $containerCreatePostBody->setEnv($envs); + + $hostConfig = $this->createHostConfig(); + $containerCreatePostBody->setHostConfig($hostConfig); + + if ($this->entryPoint !== null) { + $containerCreatePostBody->setEntrypoint([$this->entryPoint]); + } + + if ($this->healthConfig !== null) { + $containerCreatePostBody->setHealthcheck($this->healthConfig); + } + + if ($this->networkName !== null) { + $networkingConfig = new NetworkingConfig(); + $endpointsConfig = new \ArrayObject([ + $this->networkName => new EndpointSettings(), + ]); + $networkingConfig->setEndpointsConfig($endpointsConfig); + $containerCreatePostBody->setNetworkingConfig($networkingConfig); + } + + return $containerCreatePostBody; + } + + protected function createHostConfig(): ?HostConfig + { + /** + * For some reason, if some of the properties are not set, but HostConfig is returned, + * the API will throw ContainerCreateBadRequestException: bad parameter. + * Until it will be checked and fixed, we just return null if these properties are not set. + * */ + if ($this->exposedPorts === [] && !$this->isPrivileged && $this->mounts === []) { + return null; + } + + $hostConfig = new HostConfig(); + + if ($this->exposedPorts !== []) { + $portBindings = $this->createPortBindings(); + $hostConfig->setPortBindings($portBindings); + } + + if ($this->isPrivileged) { + $hostConfig->setPrivileged(true); + } + + if ($this->mounts !== []) { + $hostConfig->setMounts($this->mounts); + } + + return $hostConfig; + } + + /** + * @return array> + */ + protected function createPortBindings(): array + { + $portBindings = []; + + foreach ($this->exposedPorts as $port) { + $portBinding = new PortBinding(); + $portBinding->setHostPort((string)$this->portGenerator->generatePort()); + $portBinding->setHostIp('0.0.0.0'); + $portBindings[$port] = [$portBinding]; + } + + return $portBindings; + } + + protected function pullImage(): void + { + [$fromImage, $tag] = explode(':', $this->image) + [1 => 'latest']; + /** @var CreateImageStream $imageCreateResponse */ + $imageCreateResponse = $this->dockerClient->imageCreate(null, [ + 'fromImage' => $fromImage, + 'tag' => $tag, + ]); + $imageCreateResponse->wait(); + } } diff --git a/src/Container/InternetProtocol.php b/src/Container/InternetProtocol.php new file mode 100644 index 0000000..8b4e8c1 --- /dev/null +++ b/src/Container/InternetProtocol.php @@ -0,0 +1,24 @@ +value); + } + + public static function fromDockerNotation(string $protocol): self + { + return self::from(strtoupper($protocol)); + } +} diff --git a/src/Container/MariaDBContainer.php b/src/Container/MariaDBContainer.php index b806783..dfd5dbc 100644 --- a/src/Container/MariaDBContainer.php +++ b/src/Container/MariaDBContainer.php @@ -4,6 +4,7 @@ namespace Testcontainers\Container; +use Testcontainers\Utils\PortGenerator\FixedPortGenerator; use Testcontainers\Wait\WaitForExec; /** @@ -16,6 +17,7 @@ class MariaDBContainer extends Container public function __construct(string $version = 'latest', string $mysqlRootPassword = 'root') { parent::__construct('mariadb:' . $version); + $this->withPortGenerator(new FixedPortGenerator([3306])); $this->withExposedPorts(3306); $this->withEnvironment('MARIADB_ROOT_PASSWORD', $mysqlRootPassword); $this->withWait(new WaitForExec([ diff --git a/src/Container/MySQLContainer.php b/src/Container/MySQLContainer.php index a6e7efb..ee8f653 100644 --- a/src/Container/MySQLContainer.php +++ b/src/Container/MySQLContainer.php @@ -4,6 +4,7 @@ namespace Testcontainers\Container; +use Testcontainers\Utils\PortGenerator\FixedPortGenerator; use Testcontainers\Wait\WaitForExec; /** @@ -16,6 +17,7 @@ class MySQLContainer extends Container public function __construct(string $version = 'latest', string $mysqlRootPassword = 'root') { parent::__construct('mysql:' . $version); + $this->withPortGenerator(new FixedPortGenerator([3306])); $this->withExposedPorts(3306); $this->withEnvironment('MYSQL_ROOT_PASSWORD', $mysqlRootPassword); $this->withWait(new WaitForExec([ diff --git a/src/Container/OpenSearchContainer.php b/src/Container/OpenSearchContainer.php index c3ba7cc..dde44a7 100644 --- a/src/Container/OpenSearchContainer.php +++ b/src/Container/OpenSearchContainer.php @@ -4,6 +4,7 @@ namespace Testcontainers\Container; +use Testcontainers\Utils\PortGenerator\FixedPortGenerator; use Testcontainers\Wait\WaitForLog; /** @@ -16,6 +17,7 @@ class OpenSearchContainer extends Container public function __construct(string $version = 'latest') { parent::__construct('opensearchproject/opensearch:' . $version); + $this->withPortGenerator(new FixedPortGenerator([9200])); $this->withExposedPorts(9200); $this->withEnvironment('discovery.type', 'single-node'); $this->withEnvironment('OPENSEARCH_INITIAL_ADMIN_PASSWORD', 'c3o_ZPHo!'); diff --git a/src/Container/PostgresContainer.php b/src/Container/PostgresContainer.php index 5bb2e0a..073144e 100644 --- a/src/Container/PostgresContainer.php +++ b/src/Container/PostgresContainer.php @@ -4,6 +4,7 @@ namespace Testcontainers\Container; +use Testcontainers\Utils\PortGenerator\FixedPortGenerator; use Testcontainers\Wait\WaitForExec; /** @@ -20,6 +21,7 @@ public function __construct( public readonly string $database = 'test' ) { parent::__construct('postgres:' . $version); + $this->withPortGenerator(new FixedPortGenerator([5432])); $this->withExposedPorts(5432); $this->withEnvironment('POSTGRES_USER', $this->username); $this->withEnvironment('POSTGRES_PASSWORD', $this->password); diff --git a/src/Container/RedisContainer.php b/src/Container/RedisContainer.php index 1ec3f24..8b895ec 100644 --- a/src/Container/RedisContainer.php +++ b/src/Container/RedisContainer.php @@ -4,6 +4,7 @@ namespace Testcontainers\Container; +use Testcontainers\Utils\PortGenerator\FixedPortGenerator; use Testcontainers\Wait\WaitForLog; /** @@ -16,6 +17,7 @@ class RedisContainer extends Container public function __construct(string $version = 'latest') { parent::__construct('redis:' . $version); + $this->withPortGenerator(new FixedPortGenerator([6379])); $this->withExposedPorts(6379); $this->withWait(new WaitForLog('Ready to accept connections')); } diff --git a/src/Exception/ContainerException.php b/src/Exception/ContainerException.php new file mode 100644 index 0000000..f0d02a3 --- /dev/null +++ b/src/Exception/ContainerException.php @@ -0,0 +1,21 @@ +containerId = $containerId; + parent::__construct($message, 0, $previous); + } + + public function getContainerId(): string + { + return $this->containerId; + } +} diff --git a/src/Exception/ContainerNotReadyException.php b/src/Exception/ContainerNotReadyException.php index 1b5f677..200983f 100644 --- a/src/Exception/ContainerNotReadyException.php +++ b/src/Exception/ContainerNotReadyException.php @@ -4,10 +4,6 @@ namespace Testcontainers\Exception; -class ContainerNotReadyException extends \RuntimeException +class ContainerNotReadyException extends ContainerException { - public function __construct(string $id, ?\Throwable $previous = null) - { - parent::__construct(sprintf('Container %s is not ready', $id), 0, $previous); - } } diff --git a/src/Exception/ContainerStateException.php b/src/Exception/ContainerStateException.php new file mode 100644 index 0000000..c91cb07 --- /dev/null +++ b/src/Exception/ContainerStateException.php @@ -0,0 +1,14 @@ +containerId = $containerId; $message ??= sprintf('Timeout reached while waiting for container %s', $containerId); - parent::__construct($message, 0, $previous); - } - - public function getContainerId(): string - { - return $this->containerId; + parent::__construct($message, $containerId, $previous); } } diff --git a/src/Exception/HealthCheckFailedException.php b/src/Exception/HealthCheckFailedException.php new file mode 100644 index 0000000..b1b7c08 --- /dev/null +++ b/src/Exception/HealthCheckFailedException.php @@ -0,0 +1,14 @@ +toDockerNotation()}"; + } + + // Check if the port specification already includes a protocol + if (is_string($port) && !str_contains($port, '/')) { + return "{$port}/{$internetProtocol->toDockerNotation()}"; + } + + return $port; + } +} diff --git a/src/Wait/WaitForHealthCheck.php b/src/Wait/WaitForHealthCheck.php index 9bef414..289adaf 100644 --- a/src/Wait/WaitForHealthCheck.php +++ b/src/Wait/WaitForHealthCheck.php @@ -4,43 +4,70 @@ namespace Testcontainers\Wait; -use Docker\Docker; -use Http\Client\Socket\Exception\TimeoutException; +use Docker\API\Model\ContainersIdJsonGetResponse200; use Testcontainers\Container\StartedTestContainer; -use Testcontainers\Exception\ContainerNotReadyException; +use Testcontainers\Exception\ContainerStateException; +use Testcontainers\Exception\ContainerWaitingTimeoutException; +use Testcontainers\Exception\HealthCheckFailedException; +use Testcontainers\Exception\HealthCheckNotConfiguredException; +use Testcontainers\Exception\UnknownHealthStatusException; -//TODO: not ready yet +/** + * Wait strategy that waits until the container's health status is 'healthy'. + * + * Possible health statuses: + * - "none": No health check configured. + * - "starting": Health check is in progress. + * - "healthy": Container is healthy. + * - "unhealthy": Container is unhealthy. + */ class WaitForHealthCheck extends BaseWaitStrategy { - public function __construct(protected int $timeout = 5000, protected int $pollInterval = 1000) - { - parent::__construct($timeout, $pollInterval); - } - public function wait(StartedTestContainer $container): void { - $startTime = microtime(true) * 1000; + $startTime = microtime(true); while (true) { - $elapsedTime = (microtime(true) * 1000) - $startTime; + $elapsedTime = (microtime(true) - $startTime) * 1000; if ($elapsedTime > $this->timeout) { - throw new TimeoutException(sprintf("Health check not healthy after %d ms", $this->timeout)); + throw new ContainerWaitingTimeoutException($container->getId()); } - /** @var \Psr\Http\Message\ResponseInterface | null $containerInspect */ - $containerInspect = $container->getClient()->containerInspect($container->getId(), [], Docker::FETCH_RESPONSE); - //$containerStatus = $containerInspect?->getArrayCopy() ?? null; - $containerStatus = ''; - if ($containerStatus === 'healthy') { - return; - } + /** @var ContainersIdJsonGetResponse200|null $containerInspect */ + $containerInspect = $container->getClient()->containerInspect($container->getId()); + + $containerState = $containerInspect?->getState(); + + if ($containerState !== null) { + $health = $containerState->getHealth(); + + if ($health !== null) { + $status = $health->getStatus(); - if ($containerStatus === 'unhealthy') { - throw new ContainerNotReadyException(sprintf("Health check failed: %s", $containerStatus)); + switch ($status) { + case 'healthy': + return; // Container is healthy + case 'starting': + // Health check is still in progress; continue waiting + break; + case 'unhealthy': + throw new HealthCheckFailedException($container->getId()); + case 'none': + throw new HealthCheckNotConfiguredException($container->getId()); + default: + throw new UnknownHealthStatusException($container->getId(), (string)$status); + } + } else { + // Health is null; treat as 'none' status + throw new HealthCheckNotConfiguredException($container->getId()); + } + } else { + // Container state is null + throw new ContainerStateException($container->getId()); } - usleep($this->pollInterval * 1000); // Sleep for the polling interval + usleep($this->pollInterval * 1000); } } } diff --git a/tests/Integration/OldTests/ContainerTest.php b/tests/Integration/OldTests/ContainerTest.php index ed65412..0f36535 100644 --- a/tests/Integration/OldTests/ContainerTest.php +++ b/tests/Integration/OldTests/ContainerTest.php @@ -17,13 +17,6 @@ */ class ContainerTest extends TestCase { - //TODO: remove after check - //To make it work, fixed port should be first implemented - protected function setUp(): void - { - $this->markTestIncomplete(); - } - public function testMySQL(): void { $container = MySQLContainer::make(); @@ -117,6 +110,8 @@ public function testOpenSearch(): void $this->assertArrayHasKey('cluster_name', $data); $this->assertEquals('docker-cluster', $data['cluster_name']); + + $container->stop(); } public function testPostgreSQLContainer(): void diff --git a/tests/Integration/OldTests/WaitStrategyTest.php b/tests/Integration/OldTests/WaitStrategyTest.php index 9c164e9..52a6d47 100644 --- a/tests/Integration/OldTests/WaitStrategyTest.php +++ b/tests/Integration/OldTests/WaitStrategyTest.php @@ -8,7 +8,8 @@ use Predis\Client; use Predis\Connection\ConnectionException; use Testcontainers\Container\Container; -use Testcontainers\Exception\ContainerNotReadyException; +use Testcontainers\Container\MySQLContainer; +use Testcontainers\Container\RedisContainer; use Testcontainers\Wait\WaitForExec; use Testcontainers\Wait\WaitForHealthCheck; use Testcontainers\Wait\WaitForHttp; @@ -28,7 +29,7 @@ protected function setUp(): void public function testWaitForExec(): void { - $container = Container::make('mysql') + $container = MySQLContainer::make() ->withEnvironment('MYSQL_ROOT_PASSWORD', 'root') ->withWait( new WaitForExec([ @@ -52,11 +53,13 @@ public function testWaitForExec(): void $version = $query->fetchColumn(); $this->assertNotEmpty($version); + + $container->stop(); } public function testWaitForLog(): void { - $container = Container::make('redis:6.2.5') + $container = RedisContainer::make() ->withWait(new WaitForLog('Ready to accept connections')); $container->run(); @@ -138,6 +141,7 @@ public function testWaitForHealthCheck(): void { $container = Container::make('nginx') ->withHealthCheckCommand('curl --fail http://localhost') + ->withPort('80', '80') ->withWait(new WaitForHealthCheck()); $container->run(); @@ -153,5 +157,7 @@ public function testWaitForHealthCheck(): void $this->assertIsString($response); $this->assertStringContainsString('Welcome to nginx!', $response); + + $container->stop(); } }