From c82e974ab9ab7a52de5781f33bef72b6f448bc22 Mon Sep 17 00:00:00 2001 From: Sergei Shitikov Date: Thu, 22 Aug 2024 16:41:51 +0200 Subject: [PATCH 01/27] added beluga-php/docker-php client and some basic updates to the base Container class --- composer.json | 6 +- src/Container/Container.php | 188 ++++++++++++------------- src/Wait/WaitForExec.php | 30 ++-- src/Wait/WaitForHealthCheck.php | 22 ++- src/Wait/WaitForHttp.php | 15 +- src/Wait/WaitForLog.php | 9 +- src/Wait/WaitForTcpPortOpen.php | 16 ++- tests/Integration/WaitStrategyTest.php | 1 - 8 files changed, 154 insertions(+), 133 deletions(-) diff --git a/composer.json b/composer.json index dc070bd..1925555 100644 --- a/composer.json +++ b/composer.json @@ -15,7 +15,8 @@ ], "require": { "php": ">= 8.1", - "symfony/process": "^5.0|^6.0|^7.0" + "beluga-php/docker-php": "^1.45", + "symfony/http-client": "^7.1" }, "require-dev": { "phpunit/phpunit": "^9.5", @@ -44,7 +45,8 @@ }, "config": { "allow-plugins": { - "phpstan/extension-installer": true + "phpstan/extension-installer": true, + "php-http/discovery": true } } } diff --git a/src/Container/Container.php b/src/Container/Container.php index 0ea8ebf..c7fac17 100644 --- a/src/Container/Container.php +++ b/src/Container/Container.php @@ -4,11 +4,17 @@ declare(strict_types=1); namespace Testcontainers\Container; -use Symfony\Component\Process\Process; +use Docker\API\Model\ContainersCreatePostBody; +use Docker\API\Model\ContainersIdExecPostBody; +use Docker\API\Model\EndpointSettings; +use Docker\API\Model\HealthConfig; +use Docker\API\Model\Mount; +use Docker\API\Model\NetworkingConfig; +use Docker\API\Model\Port; +use Docker\Docker; +use Psr\Http\Message\ResponseInterface; use Testcontainers\Exception\ContainerNotReadyException; use Testcontainers\Registry; -use Testcontainers\Trait\DockerContainerAwareTrait; -use Testcontainers\Wait\WaitForNothing; use Testcontainers\Wait\WaitInterface; /** @@ -19,43 +25,44 @@ use Testcontainers\Wait\WaitInterface; */ class Container { - use DockerContainerAwareTrait; + protected Docker $dockerClient; - private string $id; + protected ContainersCreatePostBody $containerConfig; - private ?string $entryPoint = null; + protected string $image; + + protected string $containerName; + + protected string $id; + + protected ?string $entryPoint = null; + + protected ?HealthConfig $healthConfig = null; /** * @var array */ - private array $env = []; + protected array $env = []; - private Process $process; - private WaitInterface $wait; + protected WaitInterface $wait; - private bool $privileged = false; - private ?string $network = null; - private ?string $healthCheckCommand = null; - private int $healthCheckIntervalInMS; + protected bool $privileged = false; + protected ?string $networkName = null; /** - * @var ContainerInspect + * @var array */ - private array $inspectedData; + protected array $mounts = []; /** - * @var array + * @var array */ - private array $mounts = []; + protected array $ports = []; - /** - * @var array - */ - private array $ports = []; - - protected function __construct(private string $image) + protected function __construct(string $image) { - $this->wait = new WaitForNothing(); + $this->image = $image; + $this->dockerClient = Docker::create(); } public static function make(string $image): self @@ -98,24 +105,24 @@ class Container public function withHealthCheckCommand(string $command, int $healthCheckIntervalInMS = 1000): self { - $this->healthCheckCommand = $command; - $this->healthCheckIntervalInMS = $healthCheckIntervalInMS; + $this->healthConfig = new HealthConfig([ + 'Test' => ['CMD', $command], + 'Interval' => $healthCheckIntervalInMS, + ]); return $this; } public function withMount(string $localPath, string $containerPath): self { - $this->mounts[] = '-v'; - $this->mounts[] = sprintf('%s:%s', $localPath, $containerPath); + $this->mounts[] = new Mount(['type' => 'bind', 'source' => $localPath, 'target' => $containerPath]); return $this; } public function withPort(string $localPort, string $containerPort): self { - $this->ports[] = '-p'; - $this->ports[] = sprintf('%s:%s', $localPort, $containerPort); + $this->ports[] = new Port(['privatePort' => (int) $containerPort, 'publicPort' => (int) $localPort]); return $this; } @@ -127,60 +134,52 @@ class Container return $this; } - public function withNetwork(string $network): self + public function withNetwork(string $networkName): self { - $this->network = $network; + $this->networkName = $networkName; return $this; } public function run(bool $wait = true): self { - $this->id = uniqid('testcontainer', true); + $this->containerName = uniqid('testcontainer', true); - $params = [ - 'docker', - 'run', - '--rm', - '--detach', - '--name', - $this->id, - ...$this->mounts, - ...$this->ports, - ]; + $this->containerConfig = new ContainersCreatePostBody(); + $this->containerConfig->setImage($this->image); + $envs = []; foreach ($this->env as $name => $value) { - $params[] = '--env'; - $params[] = $name . '=' . $value; + $envs[] = $name . '=' . $value; } - if ($this->healthCheckCommand !== null) { - $params[] = '--health-cmd'; - $params[] = $this->healthCheckCommand; - $params[] = '--health-interval'; - $params[] = $this->healthCheckIntervalInMS . 'ms'; + $this->containerConfig->setEnv($envs); + + if ($this->healthConfig !== null) { + $this->containerConfig->setHealthcheck($this->healthConfig); } - if ($this->network !== null) { - $params[] = '--network'; - $params[] = $this->network; + if ($this->networkName !== null) { + $this->containerConfig->setNetworkingConfig(new NetworkingConfig([ + 'endpointsConfig' => [ + $this->networkName => new EndpointSettings([ + 'aliases' => [$this->containerName], + 'networkID' => $this->networkName, + ]), + ]])); } if ($this->entryPoint !== null) { - $params[] = '--entrypoint'; - $params[] = $this->entryPoint; + $this->containerConfig->setEntrypoint([$this->entryPoint]); } if ($this->privileged) { - $params[] = '--privileged'; + //TODO: Implement privileged mode } - $params[] = $this->image; + $containerCreateResponse = $this->dockerClient->containerCreate($this->containerConfig, ['name' => $this->containerName]); - $this->process = new Process($params); - $this->process->mustRun(); - - $this->inspectedData = self::dockerContainerInspect($this->id); + $this->id = $containerCreateResponse->getId(); Registry::add($this); @@ -193,46 +192,45 @@ class Container public function wait(int $wait = 100): self { - for ($i = 0; $i < $wait; $i++) { - try { - $this->wait->wait($this->id); - return $this; - } catch (ContainerNotReadyException $e) { - usleep(500000); - } - } + usleep(500000); + return $this; - throw new ContainerNotReadyException($this->id); +// for ($i = 0; $i < $wait; $i++) { +// try { +// $this->dockerClient->containerWait($this->id); +// return $this; +// } catch (ContainerNotReadyException $e) { +// usleep(500000); +// } +// } +// +// throw new ContainerNotReadyException($this->id); } public function stop(): self { - $stop = new Process(['docker', 'stop', $this->id]); - $stop->mustRun(); + $this->dockerClient->containerStop($this->id); return $this; } public function start(): self { - $start = new Process(['docker', 'start', $this->id]); - $start->mustRun(); + $this->dockerClient->containerStart($this->id); return $this; } public function restart(): self { - $restart = new Process(['docker', 'restart', $this->id]); - $restart->mustRun(); + $this->dockerClient->containerRestart($this->id); return $this; } public function remove(): self { - $remove = new Process(['docker', 'rm', '-f', $this->id]); - $remove->mustRun(); + $this->dockerClient->containerDelete($this->id); Registry::remove($this); @@ -241,37 +239,37 @@ class Container public function kill(): self { - $kill = new Process(['docker', 'kill', $this->id]); - $kill->mustRun(); + $this->dockerClient->containerKill($this->id); return $this; } /** - * @param array $command + * @param array $commandAsArray */ - public function execute(array $command): Process + public function execute(array $commandAsArray): ResponseInterface { - $process = new Process(['docker', 'exec', $this->id, ...$command]); - $process->mustRun(); - - return $process; + $command = new ContainersIdExecPostBody(); + $command->setCmd($commandAsArray); + return $this->dockerClient->containerExec($this->id, $command); } public function logs(): string { - $logs = new Process(['docker', 'logs', $this->id]); - $logs->mustRun(); - - return $logs->getOutput(); + return $this->dockerClient->containerLogs($this->id)?->getBody()?->getContents() ?? ''; } public function getAddress(): string { - return self::dockerContainerAddress( - containerId: $this->id, - networkName: $this->network, - inspectedData: $this->inspectedData - ); + $containerNetworks = $this->dockerClient->containerInspect($this->id) + ->getNetworkSettings()->getNetworks(); + $containerAddress = ''; + foreach ($containerNetworks as $network) { + if($network->getNetworkID() === $this->id) { + $containerAddress = $network->getIpAddress(); + break; + } + } + return $containerAddress; } } diff --git a/src/Wait/WaitForExec.php b/src/Wait/WaitForExec.php index 0c5c5c9..f4c0f0f 100644 --- a/src/Wait/WaitForExec.php +++ b/src/Wait/WaitForExec.php @@ -5,31 +5,35 @@ declare(strict_types=1); namespace Testcontainers\Wait; use Closure; -use Symfony\Component\Process\Process; +use Docker\API\Model\ContainersIdExecPostBody; +use Docker\API\Model\ExecIdStartPostBody; +use Docker\Docker; use Testcontainers\Exception\ContainerNotReadyException; class WaitForExec implements WaitInterface { + protected Docker $dockerClient; + + protected ContainersIdExecPostBody $execConfig; + /** * @param array $command */ public function __construct(private array $command, private ?Closure $checkFunction = null) { + $this->dockerClient = Docker::create(); + $execConfig = new ContainersIdExecPostBody(); + $execConfig->setTty(true); + $execConfig->setAttachStdout(true); + $execConfig->setAttachStderr(true); + $execConfig->setCmd($this->command); } public function wait(string $id): void { - $process = new Process(['docker', 'exec', $id, ...$this->command]); - - try { - $process->mustRun(); - } catch (\Exception $e) { - throw new ContainerNotReadyException($id, $e); - } - - if ($this->checkFunction !== null) { - $func = $this->checkFunction; - $func($process); - } + $execid = $this->dockerClient->containerExec($id, $this->execConfig)->getId() ?? ''; + $execStartConfig = new ExecIdStartPostBody(); + $execStartConfig->setDetach(false); + $this->dockerClient->execStart($execid, $execStartConfig); } } diff --git a/src/Wait/WaitForHealthCheck.php b/src/Wait/WaitForHealthCheck.php index 2836346..f658c10 100644 --- a/src/Wait/WaitForHealthCheck.php +++ b/src/Wait/WaitForHealthCheck.php @@ -4,24 +4,22 @@ declare(strict_types=1); namespace Testcontainers\Wait; -use RuntimeException; -use Symfony\Component\Process\Process; +use Docker\Docker; use Testcontainers\Exception\ContainerNotReadyException; class WaitForHealthCheck implements WaitInterface { + protected Docker $dockerClient; + + public function __construct() + { + $this->dockerClient = Docker::create(); + } public function wait(string $id): void { - $process = new Process(['docker', 'inspect', '--format', '{{json .State.Health.Status}}', $id]); - $process->mustRun(); - - $status = json_decode($process->getOutput(), true, 512, JSON_THROW_ON_ERROR); - - if (!is_string($status)) { - throw new ContainerNotReadyException($id, new RuntimeException('Invalid json output')); - } - - $status = trim($status, '"'); + $containerInspect = $this->dockerClient->containerInspect($id); + $containerInspect->getBody()->getContents(); + dd($containerInspect->getStatusCode()); if ($status !== 'healthy') { throw new ContainerNotReadyException($id); diff --git a/src/Wait/WaitForHttp.php b/src/Wait/WaitForHttp.php index d65ff04..f8927e9 100644 --- a/src/Wait/WaitForHttp.php +++ b/src/Wait/WaitForHttp.php @@ -4,13 +4,11 @@ declare(strict_types=1); namespace Testcontainers\Wait; +use Docker\Docker; use Testcontainers\Exception\ContainerNotReadyException; -use Testcontainers\Trait\DockerContainerAwareTrait; class WaitForHttp implements WaitInterface { - use DockerContainerAwareTrait; - public const METHOD_GET = 'GET'; public const METHOD_POST = 'POST'; public const METHOD_PUT = 'PUT'; @@ -22,9 +20,11 @@ class WaitForHttp implements WaitInterface private string $method = 'GET'; private string $path = '/'; private int $statusCode = 200; + private Docker $dockerClient; public function __construct(private int $port) { + $this->dockerClient = Docker::create(); } public static function make(int $port): self @@ -58,7 +58,14 @@ class WaitForHttp implements WaitInterface public function wait(string $id): void { - $containerAddress = self::dockerContainerAddress(containerId: $id); + $containerNetworks = $this->dockerClient->containerInspect($id)->getNetworkSettings()->getNetworks(); + $containerAddress = null; + foreach ($containerNetworks as $network) { + if($network->getNetworkID() === $id) { + $containerAddress = $network->getIpAddress(); + break; + } + } $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, sprintf('http://%s:%d%s', $containerAddress, $this->port, $this->path)); diff --git a/src/Wait/WaitForLog.php b/src/Wait/WaitForLog.php index 63f5ace..8d70b15 100644 --- a/src/Wait/WaitForLog.php +++ b/src/Wait/WaitForLog.php @@ -4,21 +4,24 @@ declare(strict_types=1); namespace Testcontainers\Wait; +use Docker\Docker; use Symfony\Component\Process\Process; use Testcontainers\Exception\ContainerNotReadyException; class WaitForLog implements WaitInterface { + protected Docker $dockerClient; + public function __construct(private string $message, private bool $enableRegex = false) { + $this->dockerClient = Docker::create(); } public function wait(string $id): void { - $process = new Process(['docker', 'logs', $id]); - $process->mustRun(); + $logs = $this->dockerClient->containerLogs($id); - $output = $process->getOutput() . PHP_EOL . $process->getErrorOutput(); + $output = $logs->getBody()->getContents(); if ($this->enableRegex) { if (!preg_match($this->message, $output)) { diff --git a/src/Wait/WaitForTcpPortOpen.php b/src/Wait/WaitForTcpPortOpen.php index 4c89828..6fea945 100644 --- a/src/Wait/WaitForTcpPortOpen.php +++ b/src/Wait/WaitForTcpPortOpen.php @@ -4,17 +4,18 @@ declare(strict_types=1); namespace Testcontainers\Wait; +use Docker\Docker; use JsonException; use RuntimeException; use Testcontainers\Exception\ContainerNotReadyException; -use Testcontainers\Trait\DockerContainerAwareTrait; final class WaitForTcpPortOpen implements WaitInterface { - use DockerContainerAwareTrait; + private Docker $dockerClient; public function __construct(private readonly int $port, private readonly ?string $network = null) { + $this->dockerClient = Docker::create(); } public static function make(int $port, ?string $network = null): self @@ -27,7 +28,16 @@ final class WaitForTcpPortOpen implements WaitInterface */ public function wait(string $id): void { - if (@fsockopen(self::dockerContainerAddress(containerId: $id, networkName: $this->network), $this->port) === false) { + $containerInspectResult = $this->dockerClient->containerInspect($id); + $dockerContainerNetworks = $containerInspectResult->getNetworkSettings()->getNetworks(); + $dockerContainerAddress = ''; + foreach ($dockerContainerNetworks as $network) { + if ($network->getNetworkID() === $this->network) { + $dockerContainerAddress = $network->getIPAddress(); + break; + } + } + if (@fsockopen($dockerContainerAddress, $this->port) === false) { throw new ContainerNotReadyException($id, new RuntimeException('Unable to connect to container TCP port')); } } diff --git a/tests/Integration/WaitStrategyTest.php b/tests/Integration/WaitStrategyTest.php index 09abdf7..8bf14ca 100644 --- a/tests/Integration/WaitStrategyTest.php +++ b/tests/Integration/WaitStrategyTest.php @@ -20,7 +20,6 @@ use Testcontainers\Wait\WaitForTcpPortOpen; class WaitStrategyTest extends TestCase { - use DockerContainerAwareTrait; public static function tearDownAfterClass(): void { From 27caae56c04184fd37f5d55e1d37ad35b4a33d7f Mon Sep 17 00:00:00 2001 From: Sergei Shitikov Date: Thu, 22 Aug 2024 16:45:47 +0200 Subject: [PATCH 02/27] refactor Container to GenericContainer for better align to other Testcontainers language packages --- README.md | 4 ++-- .../{Container.php => GenericContainer.php} | 4 ++-- src/Container/MariaDBContainer.php | 2 +- src/Container/MySQLContainer.php | 2 +- src/Container/OpenSearchContainer.php | 2 +- src/Container/PostgresContainer.php | 2 +- src/Container/RedisContainer.php | 2 +- src/Registry.php | 8 ++++---- src/Trait/DockerContainerAwareTrait.php | 6 +++--- tests/Integration/WaitStrategyTest.php | 12 ++++++------ 10 files changed, 22 insertions(+), 22 deletions(-) rename src/Container/{Container.php => GenericContainer.php} (99%) diff --git a/README.md b/README.md index ab5a7fd..3e2c2e1 100644 --- a/README.md +++ b/README.md @@ -17,9 +17,9 @@ composer req --dev testcontainers/testcontainers ```php withEnvironment('name', 'var'); diff --git a/src/Container/Container.php b/src/Container/GenericContainer.php similarity index 99% rename from src/Container/Container.php rename to src/Container/GenericContainer.php index c7fac17..772001e 100644 --- a/src/Container/Container.php +++ b/src/Container/GenericContainer.php @@ -23,7 +23,7 @@ use Testcontainers\Wait\WaitInterface; * @phpstan-type ContainerInspect ContainerInspectSingleNetwork|ContainerInspectMultipleNetworks * @phpstan-type DockerNetwork array{CreatedAt: string, Driver: string, ID: string, IPv6: string, Internal: string, Labels: string, Name: string, Scope: string} */ -class Container +class GenericContainer { protected Docker $dockerClient; @@ -67,7 +67,7 @@ class Container public static function make(string $image): self { - return new Container($image); + return new GenericContainer($image); } public function getId(): string diff --git a/src/Container/MariaDBContainer.php b/src/Container/MariaDBContainer.php index 4ff9c4e..fe65531 100644 --- a/src/Container/MariaDBContainer.php +++ b/src/Container/MariaDBContainer.php @@ -6,7 +6,7 @@ namespace Testcontainers\Container; use Testcontainers\Wait\WaitForExec; -class MariaDBContainer extends Container +class MariaDBContainer extends GenericContainer { private function __construct(string $version, string $mysqlRootPassword) { diff --git a/src/Container/MySQLContainer.php b/src/Container/MySQLContainer.php index 7b1fdbb..dffc1e4 100644 --- a/src/Container/MySQLContainer.php +++ b/src/Container/MySQLContainer.php @@ -6,7 +6,7 @@ namespace Testcontainers\Container; use Testcontainers\Wait\WaitForExec; -class MySQLContainer extends Container +class MySQLContainer extends GenericContainer { private function __construct(string $version, string $mysqlRootPassword) { diff --git a/src/Container/OpenSearchContainer.php b/src/Container/OpenSearchContainer.php index 783edb3..6d009f6 100644 --- a/src/Container/OpenSearchContainer.php +++ b/src/Container/OpenSearchContainer.php @@ -6,7 +6,7 @@ namespace Testcontainers\Container; use Testcontainers\Wait\WaitForHttp; -class OpenSearchContainer extends Container +class OpenSearchContainer extends GenericContainer { private function __construct(string $version) { diff --git a/src/Container/PostgresContainer.php b/src/Container/PostgresContainer.php index 46a912f..66bdf18 100644 --- a/src/Container/PostgresContainer.php +++ b/src/Container/PostgresContainer.php @@ -6,7 +6,7 @@ namespace Testcontainers\Container; use Testcontainers\Wait\WaitForExec; -class PostgresContainer extends Container +class PostgresContainer extends GenericContainer { private function __construct(string $version, string $rootPassword) { diff --git a/src/Container/RedisContainer.php b/src/Container/RedisContainer.php index a219e57..8846801 100644 --- a/src/Container/RedisContainer.php +++ b/src/Container/RedisContainer.php @@ -6,7 +6,7 @@ namespace Testcontainers\Container; use Testcontainers\Wait\WaitForLog; -class RedisContainer extends Container +class RedisContainer extends GenericContainer { private function __construct(string $version) { diff --git a/src/Registry.php b/src/Registry.php index 0d1d95a..07a6cbe 100644 --- a/src/Registry.php +++ b/src/Registry.php @@ -4,18 +4,18 @@ declare(strict_types=1); namespace Testcontainers; -use Testcontainers\Container\Container; +use Testcontainers\Container\GenericContainer; class Registry { private static bool $registeredCleanup = false; /** - * @var array + * @var array */ private static array $registry = []; - public static function add(Container $container): void + public static function add(GenericContainer $container): void { self::$registry[spl_object_id($container)] = $container; @@ -25,7 +25,7 @@ class Registry } } - public static function remove(Container $container): void + public static function remove(GenericContainer $container): void { unset(self::$registry[spl_object_id($container)]); } diff --git a/src/Trait/DockerContainerAwareTrait.php b/src/Trait/DockerContainerAwareTrait.php index 79323bc..f96209d 100644 --- a/src/Trait/DockerContainerAwareTrait.php +++ b/src/Trait/DockerContainerAwareTrait.php @@ -6,12 +6,12 @@ namespace Testcontainers\Trait; use JsonException; use Symfony\Component\Process\Process; -use Testcontainers\Container\Container; +use Testcontainers\Container\GenericContainer; use UnexpectedValueException; /** - * @phpstan-import-type ContainerInspect from Container - * @phpstan-import-type DockerNetwork from Container + * @phpstan-import-type ContainerInspect from GenericContainer + * @phpstan-import-type DockerNetwork from GenericContainer */ trait DockerContainerAwareTrait { diff --git a/tests/Integration/WaitStrategyTest.php b/tests/Integration/WaitStrategyTest.php index 8bf14ca..168a719 100644 --- a/tests/Integration/WaitStrategyTest.php +++ b/tests/Integration/WaitStrategyTest.php @@ -8,7 +8,7 @@ use PHPUnit\Framework\TestCase; use Predis\Client; use Predis\Connection\ConnectionException; use Symfony\Component\Process\Process; -use Testcontainers\Container\Container; +use Testcontainers\Container\GenericContainer; use Testcontainers\Exception\ContainerNotReadyException; use Testcontainers\Registry; use Testcontainers\Trait\DockerContainerAwareTrait; @@ -32,7 +32,7 @@ class WaitStrategyTest extends TestCase { $called = false; - $container = Container::make('mysql') + $container = GenericContainer::make('mysql') ->withEnvironment('MYSQL_ROOT_PASSWORD', 'root') ->withWait(new WaitForExec(['mysqladmin', 'ping', '-h', '127.0.0.1'], function (Process $process) use (&$called) { $called = true; @@ -60,7 +60,7 @@ class WaitStrategyTest extends TestCase public function testWaitForLog(): void { - $container = Container::make('redis:6.2.5') + $container = GenericContainer::make('redis:6.2.5') ->withWait(new WaitForLog('Ready to accept connections')); $container->run(); @@ -86,7 +86,7 @@ class WaitStrategyTest extends TestCase public function testWaitForHTTP(): void { - $container = Container::make('nginx:alpine') + $container = GenericContainer::make('nginx:alpine') ->withWait(WaitForHttp::make(80)); $container->run(); @@ -107,7 +107,7 @@ class WaitStrategyTest extends TestCase */ public function testWaitForTcpPortOpen(bool $wait): void { - $container = Container::make('nginx:alpine'); + $container = GenericContainer::make('nginx:alpine'); if ($wait) { $container->withWait(WaitForTcpPortOpen::make(80)); @@ -140,7 +140,7 @@ class WaitStrategyTest extends TestCase public function testWaitForHealthCheck(): void { - $container = Container::make('nginx') + $container = GenericContainer::make('nginx') ->withHealthCheckCommand('curl --fail http://localhost') ->withWait(new WaitForHealthCheck()); From b7a274b1d92f7e7fbab9d290821fef44f612b1fa Mon Sep 17 00:00:00 2001 From: Sergei Shitikov Date: Thu, 29 Aug 2024 18:01:04 +0200 Subject: [PATCH 03/27] first working version with simple WaitForContainerRunning implementation --- src/Container/Container.php | 14 ++ src/Container/GenericContainer.php | 227 ++++++++++++++--------- src/Registry.php | 6 +- src/Trait/DockerContainerAwareTrait.php | 108 ----------- src/Wait/WaitForContainerRunning.php | 47 +++++ src/Wait/WaitForHealthCheck.php | 38 +++- tests/Integration/ContainerTest.php | 1 + tests/Integration/RedisContainerTest.php | 36 ++++ 8 files changed, 276 insertions(+), 201 deletions(-) create mode 100644 src/Container/Container.php delete mode 100644 src/Trait/DockerContainerAwareTrait.php create mode 100644 src/Wait/WaitForContainerRunning.php create mode 100644 tests/Integration/RedisContainerTest.php diff --git a/src/Container/Container.php b/src/Container/Container.php new file mode 100644 index 0000000..0134203 --- /dev/null +++ b/src/Container/Container.php @@ -0,0 +1,14 @@ + - */ - protected array $ports = []; + /** @var array List of exposed ports in the format ['8080/tcp'] */ + protected array $exposedPorts = []; - protected function __construct(string $image) + public function __construct(string $image) { $this->image = $image; - $this->dockerClient = Docker::create(); + $this->dockerClient = ContainerRuntimeClient::getDockerClient(); } + /** + * @deprecated Use constructor instead + * Left for backward compatibility + */ public static function make(string $image): self { return new GenericContainer($image); @@ -120,16 +123,67 @@ class GenericContainer return $this; } + /** + * @deprecated Use `withExposedPorts` instead + */ public function withPort(string $localPort, string $containerPort): self { - $this->ports[] = new Port(['privatePort' => (int) $containerPort, 'publicPort' => (int) $localPort]); + return $this->withExposedPorts($containerPort); + } + + /** + * @psalm-param string|int|array $port + */ + /** + * Add ports to be exposed by the Docker container. + * This method accepts multiple inputs: single port, multiple ports, or ports with specific protocols + * to attempt to align with other language implementations. + * + * @psalm-param int|string|array $ports One or more ports to expose. + * @return self Fluent interface for chaining. + */ + public function withExposedPorts(...$ports): self + { + foreach ($ports as $port) { + if (is_array($port)) { + // Flatten the array and recurse + $this->withExposedPorts(...$port); + } else { + // Handle single port entry, either string or int + $this->exposedPorts[] = $this->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 withPrivileged(bool $privileged = true): self { - $this->privileged = $privileged; + $this->isPrivileged = $privileged; return $this; } @@ -141,72 +195,12 @@ class GenericContainer return $this; } - public function run(bool $wait = true): self + public function wait(): self { - $this->containerName = uniqid('testcontainer', true); - - $this->containerConfig = new ContainersCreatePostBody(); - $this->containerConfig->setImage($this->image); - - $envs = []; - foreach ($this->env as $name => $value) { - $envs[] = $name . '=' . $value; - } - - $this->containerConfig->setEnv($envs); - - if ($this->healthConfig !== null) { - $this->containerConfig->setHealthcheck($this->healthConfig); - } - - if ($this->networkName !== null) { - $this->containerConfig->setNetworkingConfig(new NetworkingConfig([ - 'endpointsConfig' => [ - $this->networkName => new EndpointSettings([ - 'aliases' => [$this->containerName], - 'networkID' => $this->networkName, - ]), - ]])); - } - - if ($this->entryPoint !== null) { - $this->containerConfig->setEntrypoint([$this->entryPoint]); - } - - if ($this->privileged) { - //TODO: Implement privileged mode - } - - $containerCreateResponse = $this->dockerClient->containerCreate($this->containerConfig, ['name' => $this->containerName]); - - $this->id = $containerCreateResponse->getId(); - - Registry::add($this); - - if ($wait) { - $this->wait(); - } - + $this->wait->wait($this->id); return $this; } - public function wait(int $wait = 100): self - { - usleep(500000); - return $this; - -// for ($i = 0; $i < $wait; $i++) { -// try { -// $this->dockerClient->containerWait($this->id); -// return $this; -// } catch (ContainerNotReadyException $e) { -// usleep(500000); -// } -// } -// -// throw new ContainerNotReadyException($this->id); - } - public function stop(): self { $this->dockerClient->containerStop($this->id); @@ -216,8 +210,41 @@ class GenericContainer public function start(): self { + try { + $containerCreatePostBody = new ContainersCreatePostBody(); + $portMap = new \ArrayObject(); + + foreach ($this->exposedPorts as $port) { + $portBinding = new PortBinding(); + $portBinding->setHostPort(explode('/', $port)[0]); + $portBinding->setHostIp('0.0.0.0'); + $portMap[$port] = [$portBinding]; + } + + $hostConfig = new HostConfig(); + $hostConfig->setPortBindings($portMap); + $containerCreatePostBody->setHostConfig($hostConfig); + $containerCreatePostBody->setImage($this->image); + //$containerCreatePostBody->setEnv($this->env); + + $containerCreateResponse = $this->dockerClient->containerCreate($containerCreatePostBody); + $this->id = $containerCreateResponse?->getId() ?? ''; + } catch (ContainerCreateNotFoundException) { + $this->dockerClient->imageCreate(null, [ + 'fromImage' => explode(':', $this->image)[0], + 'tag' => explode(':', $this->image)[1] ?? 'latest', + ]); + return $this->start(); + } + + Registry::add($this); + $this->dockerClient->containerStart($this->id); + if(!isset($this->wait)) { + $this->withWait(new WaitForContainerRunning()); + } + $this->wait(); return $this; } @@ -230,6 +257,7 @@ class GenericContainer public function remove(): self { + $this->dockerClient->containerStop($this->id); $this->dockerClient->containerDelete($this->id); Registry::remove($this); @@ -244,6 +272,15 @@ class GenericContainer return $this; } + /** + * @deprecated Use `start` instead + * Left for backward compatibility + */ + public function run(): self + { + return $this->start(); + } + /** * @param array $commandAsArray */ @@ -261,15 +298,39 @@ class GenericContainer public function getAddress(): string { - $containerNetworks = $this->dockerClient->containerInspect($this->id) - ->getNetworkSettings()->getNetworks(); - $containerAddress = ''; - foreach ($containerNetworks as $network) { - if($network->getNetworkID() === $this->id) { - $containerAddress = $network->getIpAddress(); - break; + $inspection = $this->inspect(); + return $inspection['gateway']; + // foreach ($containerNetworks as $network) { + // var_dump($network->getNetworkID(), $this->id, $network->getIPAddress()); + // if($network->getNetworkID() === $this->id) { + // $containerAddress = $network->getIpAddress(); + // break; + // } + // } + // return $containerAddress; + } + + /** + * @return array{gateway: string, ports: array} + */ + public function inspect(): array + { + $response = $this->dockerClient->containerInspect($this->id); + $settings = $response->getNetworkSettings(); + var_dump($settings); + + $ports = []; + foreach ($settings->getPorts() as $port => $value) { + if ($value === null) { + continue; } + + $ports[$port] = (int) $value[0]->getHostPort(); } - return $containerAddress; + + return [ + 'gateway' => $settings->getGateway(), + 'ports' => $ports, + ]; } } diff --git a/src/Registry.php b/src/Registry.php index 07a6cbe..7ca9837 100644 --- a/src/Registry.php +++ b/src/Registry.php @@ -32,8 +32,8 @@ class Registry public static function cleanup(): void { - foreach (self::$registry as $container) { - $container->remove(); - } +// foreach (self::$registry as $container) { +// $container->remove(); +// } } } diff --git a/src/Trait/DockerContainerAwareTrait.php b/src/Trait/DockerContainerAwareTrait.php deleted file mode 100644 index f96209d..0000000 --- a/src/Trait/DockerContainerAwareTrait.php +++ /dev/null @@ -1,108 +0,0 @@ -mustRun(); - - /** @var ContainerInspect */ - return json_decode($process->getOutput(), true, 512, JSON_THROW_ON_ERROR); - } - - /** - * @param string $networkName - * @return DockerNetwork|false - * - * @throws JsonException - */ - private static function dockerNetworkFind(string $networkName): array|false - { - $process = new Process(['docker', 'network', 'ls', '--format', 'json', '--filter', 'name=' . $networkName]); - $process->mustRun(); - - $json = $process->getOutput(); - - if ($json === '') { - return false; - } - - $json = str_replace("\n", ',', $json); - $json = '['. rtrim($json, ',') .']'; - - /** @var array $output */ - $output = json_decode($json, true, 512, JSON_THROW_ON_ERROR); - - /** @var array $matchingNetworks */ - $matchingNetworks = array_filter($output, static fn (array $network) => $network['Name'] === $networkName); - - if (count($matchingNetworks) === 0) { - return false; - } - - return $matchingNetworks[0]; - } - - private static function dockerNetworkCreate(string $networkName, string $driver = 'bridge'): void - { - $process = new Process(['docker', 'network', 'create', '--driver', $driver, $networkName]); - $process->mustRun(); - } - - private static function dockerNetworkRemove(string $networkName): void - { - $process = new Process(['docker', 'network', 'rm', $networkName, '-f']); - $process->mustRun(); - } -} diff --git a/src/Wait/WaitForContainerRunning.php b/src/Wait/WaitForContainerRunning.php new file mode 100644 index 0000000..fd394ca --- /dev/null +++ b/src/Wait/WaitForContainerRunning.php @@ -0,0 +1,47 @@ +dockerClient = ContainerRuntimeClient::getDockerClient(); + } + + public function wait(string $id): void + { + $startTime = microtime(true) * 1000; + + while (true) { + $elapsedTime = (microtime(true) * 1000) - $startTime; + + if ($elapsedTime > $this->timeout) { + throw new ContainerNotReadyException($id); + } + + /** @var ContainersIdJsonGetResponse200 | null $containerInspect */ + $containerInspect = $this->dockerClient->containerInspect($id); + $containerStatus = $containerInspect?->getState()?->getStatus(); + + if ($containerStatus === 'running') { + return; + } + + usleep($this->pollInterval * 1000); + } + } +} diff --git a/src/Wait/WaitForHealthCheck.php b/src/Wait/WaitForHealthCheck.php index f658c10..995b99d 100644 --- a/src/Wait/WaitForHealthCheck.php +++ b/src/Wait/WaitForHealthCheck.php @@ -5,24 +5,48 @@ declare(strict_types=1); namespace Testcontainers\Wait; use Docker\Docker; +use Docker\DockerClientFactory; +use Http\Client\Socket\Exception\TimeoutException; +use Testcontainers\ContainerRuntime\ContainerRuntimeClient; use Testcontainers\Exception\ContainerNotReadyException; class WaitForHealthCheck implements WaitInterface { protected Docker $dockerClient; + protected int $timeout; + protected int $pollInterval; - public function __construct() + public function __construct(int $timeout = 5000, int $pollInterval = 1000) { - $this->dockerClient = Docker::create(); + $this->dockerClient = ContainerRuntimeClient::getDockerClient(); + $this->timeout = $timeout; + $this->pollInterval = $pollInterval; } + public function wait(string $id): void { - $containerInspect = $this->dockerClient->containerInspect($id); - $containerInspect->getBody()->getContents(); - dd($containerInspect->getStatusCode()); + $startTime = microtime(true) * 1000; - if ($status !== 'healthy') { - throw new ContainerNotReadyException($id); + while (true) { + $elapsedTime = (microtime(true) * 1000) - $startTime; + + if ($elapsedTime > $this->timeout) { + throw new TimeoutException(sprintf("Health check not healthy after %d ms", $this->timeout)); + } + + $containerInspect = $this->dockerClient->containerInspect($id, [], Docker::FETCH_RESPONSE); + //$containerStatus = $containerInspect?->getArrayCopy() ?? null; + var_dump($containerInspect->getBody()->getContents()); + $containerStatus=''; + if ($containerStatus === 'healthy') { + return; + } + + if ($containerStatus === 'unhealthy') { + throw new ContainerNotReadyException(sprintf("Health check failed: %s", $containerStatus)); + } + + usleep($this->pollInterval * 1000); // Sleep for the polling interval } } } diff --git a/tests/Integration/ContainerTest.php b/tests/Integration/ContainerTest.php index 30de7b2..994fa33 100644 --- a/tests/Integration/ContainerTest.php +++ b/tests/Integration/ContainerTest.php @@ -21,6 +21,7 @@ class ContainerTest extends TestCase $container->withMySQLUser('bar', 'baz'); $container->run(); + die(123); $pdo = new \PDO( sprintf('mysql:host=%s;port=3306', $container->getAddress()), diff --git a/tests/Integration/RedisContainerTest.php b/tests/Integration/RedisContainerTest.php new file mode 100644 index 0000000..c154c25 --- /dev/null +++ b/tests/Integration/RedisContainerTest.php @@ -0,0 +1,36 @@ +withExposedPorts(6379) + ->start(); + + $redisClient = new \Predis\Client([ + 'host' => 'localhost', + 'port' => 6379, + ]); + + $redisClient->set('greetings', 'Hello, World!'); + + $this->assertEquals('Hello, World!', $redisClient->get('greetings')); + $redisContainer->remove(); + } +} From 58f811ec8ab3de9ece437b47ea5331a876d1d974 Mon Sep 17 00:00:00 2001 From: Sergei Shitikov Date: Sun, 1 Sep 2024 14:41:46 +0200 Subject: [PATCH 04/27] Adjust the requirements for dev section. --- composer.json | 2 + src/Registry.php | 39 --------- tests/Integration/ContainerTest.php | 126 ---------------------------- 3 files changed, 2 insertions(+), 165 deletions(-) delete mode 100644 src/Registry.php delete mode 100644 tests/Integration/ContainerTest.php diff --git a/composer.json b/composer.json index 1925555..c0a3fb1 100644 --- a/composer.json +++ b/composer.json @@ -19,6 +19,8 @@ "symfony/http-client": "^7.1" }, "require-dev": { + "ext-curl": "*", + "ext-pdo": "*", "phpunit/phpunit": "^9.5", "brianium/paratest": "^6.6", "friendsofphp/php-cs-fixer": "^3.12", diff --git a/src/Registry.php b/src/Registry.php deleted file mode 100644 index 7ca9837..0000000 --- a/src/Registry.php +++ /dev/null @@ -1,39 +0,0 @@ - - */ - private static array $registry = []; - - public static function add(GenericContainer $container): void - { - self::$registry[spl_object_id($container)] = $container; - - if (!self::$registeredCleanup) { - register_shutdown_function([self::class, 'cleanup']); - self::$registeredCleanup = true; - } - } - - public static function remove(GenericContainer $container): void - { - unset(self::$registry[spl_object_id($container)]); - } - - public static function cleanup(): void - { -// foreach (self::$registry as $container) { -// $container->remove(); -// } - } -} diff --git a/tests/Integration/ContainerTest.php b/tests/Integration/ContainerTest.php deleted file mode 100644 index 994fa33..0000000 --- a/tests/Integration/ContainerTest.php +++ /dev/null @@ -1,126 +0,0 @@ -withMySQLDatabase('foo'); - $container->withMySQLUser('bar', 'baz'); - - $container->run(); - die(123); - - $pdo = new \PDO( - sprintf('mysql:host=%s;port=3306', $container->getAddress()), - 'bar', - 'baz', - ); - - $query = $pdo->query('SHOW databases'); - - $this->assertInstanceOf(\PDOStatement::class, $query); - - $databases = $query->fetchAll(\PDO::FETCH_COLUMN); - - $this->assertContains('foo', $databases); - } - - public function testMariaDB(): void - { - $container = MariaDBContainer::make(); - $container->withMariaDBDatabase('foo'); - $container->withMariaDBUser('bar', 'baz'); - - $container->run(); - - $pdo = new \PDO( - sprintf('mysql:host=%s;port=3306', $container->getAddress()), - 'bar', - 'baz', - ); - - $query = $pdo->query('SHOW databases'); - - $this->assertInstanceOf(\PDOStatement::class, $query); - - $databases = $query->fetchAll(\PDO::FETCH_COLUMN); - - $this->assertContains('foo', $databases); - } - - public function testRedis(): void - { - $container = RedisContainer::make(); - - $container->run(); - - $redis = new Client([ - 'scheme' => 'tcp', - 'host' => $container->getAddress(), - 'port' => 6379, - ]); - - $redis->ping(); - - $this->assertTrue($redis->isConnected()); - } - - public function testOpenSearch(): void - { - $container = OpenSearchContainer::make(); - $container->disableSecurityPlugin(); - - $container->run(); - - $ch = curl_init(); - curl_setopt($ch, CURLOPT_URL, sprintf('http://%s:%d', $container->getAddress(), 9200)); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - - $response = (string) curl_exec($ch); - - $this->assertNotEmpty($response); - - /** @var array{cluster_name: string} $data */ - $data = json_decode($response, true, JSON_THROW_ON_ERROR); - - $this->assertArrayHasKey('cluster_name', $data); - - $this->assertEquals('docker-cluster', $data['cluster_name']); - } - - public function testPostgreSQLContainer(): void - { - $container = PostgresContainer::make('latest', 'test') - ->withPostgresUser('test') - ->withPostgresDatabase('foo') - ->run(); - - - $pdo = new \PDO( - sprintf('pgsql:host=%s;port=5432;dbname=foo', $container->getAddress()), - 'test', - 'test', - ); - - $query = $pdo->query('SELECT datname FROM pg_database'); - - $this->assertInstanceOf(\PDOStatement::class, $query); - - $databases = $query->fetchAll(\PDO::FETCH_COLUMN); - - $this->assertContains('foo', $databases); - } -} From b0e4f6059285da2129d6e8dbfb805ca193e1e935 Mon Sep 17 00:00:00 2001 From: Sergei Shitikov Date: Sun, 1 Sep 2024 14:43:34 +0200 Subject: [PATCH 05/27] fix issue with passing envs into the container. Remove container registry usage. --- src/Container/GenericContainer.php | 13 +++++------ src/Container/MariaDBContainer.php | 17 +++++--------- src/Container/MySQLContainer.php | 8 +++++-- src/Container/OpenSearchContainer.php | 17 ++++++++++++-- src/Container/PostgresContainer.php | 6 ++++- src/Container/RedisContainer.php | 9 ++++++-- src/Wait/WaitForContainerRunning.php | 2 ++ src/Wait/WaitForExec.php | 1 - tests/Integration/RedisContainerTest.php | 29 +++++++++++------------- tests/Integration/WaitStrategyTest.php | 4 ---- 10 files changed, 60 insertions(+), 46 deletions(-) diff --git a/src/Container/GenericContainer.php b/src/Container/GenericContainer.php index 423862e..0a3e42f 100644 --- a/src/Container/GenericContainer.php +++ b/src/Container/GenericContainer.php @@ -14,7 +14,6 @@ use Docker\API\Model\PortBinding; use Docker\Docker; use Psr\Http\Message\ResponseInterface; use Testcontainers\ContainerRuntime\ContainerRuntimeClient; -use Testcontainers\Registry; use Testcontainers\Wait\WaitForContainerRunning; use Testcontainers\Wait\WaitInterface; @@ -225,7 +224,11 @@ class GenericContainer $hostConfig->setPortBindings($portMap); $containerCreatePostBody->setHostConfig($hostConfig); $containerCreatePostBody->setImage($this->image); - //$containerCreatePostBody->setEnv($this->env); + $envs = []; + foreach ($this->env as $key => $value) { + $envs[] = $key . '=' . $value; + } + $containerCreatePostBody->setEnv($envs); $containerCreateResponse = $this->dockerClient->containerCreate($containerCreatePostBody); $this->id = $containerCreateResponse?->getId() ?? ''; @@ -237,8 +240,6 @@ class GenericContainer return $this->start(); } - Registry::add($this); - $this->dockerClient->containerStart($this->id); if(!isset($this->wait)) { @@ -260,8 +261,6 @@ class GenericContainer $this->dockerClient->containerStop($this->id); $this->dockerClient->containerDelete($this->id); - Registry::remove($this); - return $this; } @@ -317,7 +316,7 @@ class GenericContainer { $response = $this->dockerClient->containerInspect($this->id); $settings = $response->getNetworkSettings(); - var_dump($settings); + //var_dump($settings); $ports = []; foreach ($settings->getPorts() as $port => $value) { diff --git a/src/Container/MariaDBContainer.php b/src/Container/MariaDBContainer.php index fe65531..60205f9 100644 --- a/src/Container/MariaDBContainer.php +++ b/src/Container/MariaDBContainer.php @@ -4,24 +4,19 @@ declare(strict_types=1); namespace Testcontainers\Container; -use Testcontainers\Wait\WaitForExec; - class MariaDBContainer extends GenericContainer { - private function __construct(string $version, string $mysqlRootPassword) + public function __construct(string $version = 'latest', string $mysqlRootPassword = 'root') { parent::__construct('mariadb:' . $version); + $this->withExposedPorts(3306); $this->withEnvironment('MARIADB_ROOT_PASSWORD', $mysqlRootPassword); - - $binary = 'mysqladmin'; - - if ($version === 'latest' || version_compare($version, '11.0.0', '>')) { - $binary = 'mariadb-admin'; - } - - $this->withWait(new WaitForExec([$binary, 'ping', '-h', '127.0.0.1'])); } + /** + * @deprecated Use constructor instead + * Left for backward compatibility + */ public static function make(string $version = 'latest', string $mysqlRootPassword = 'root'): self { return new self($version, $mysqlRootPassword); diff --git a/src/Container/MySQLContainer.php b/src/Container/MySQLContainer.php index dffc1e4..d1dfdeb 100644 --- a/src/Container/MySQLContainer.php +++ b/src/Container/MySQLContainer.php @@ -8,13 +8,17 @@ use Testcontainers\Wait\WaitForExec; class MySQLContainer extends GenericContainer { - private function __construct(string $version, string $mysqlRootPassword) + public function __construct(string $version = 'latest', string $mysqlRootPassword = 'root') { parent::__construct('mysql:' . $version); + $this->withExposedPorts(3306); $this->withEnvironment('MYSQL_ROOT_PASSWORD', $mysqlRootPassword); - $this->withWait(new WaitForExec(['mysqladmin', 'ping', '-h', '127.0.0.1'])); } + /** + * @deprecated Use constructor instead + * Left for backward compatibility + */ public static function make(string $version = 'latest', string $mysqlRootPassword = 'root'): self { return new self($version, $mysqlRootPassword); diff --git a/src/Container/OpenSearchContainer.php b/src/Container/OpenSearchContainer.php index 6d009f6..186df17 100644 --- a/src/Container/OpenSearchContainer.php +++ b/src/Container/OpenSearchContainer.php @@ -8,23 +8,36 @@ use Testcontainers\Wait\WaitForHttp; class OpenSearchContainer extends GenericContainer { - private function __construct(string $version) + public function __construct(string $version = 'latest') { parent::__construct('opensearchproject/opensearch:' . $version); + $this->withExposedPorts(9200); $this->withEnvironment('discovery.type', 'single-node'); $this->withEnvironment('OPENSEARCH_INITIAL_ADMIN_PASSWORD', 'c3o_ZPHo!'); $this->withWait(WaitForHttp::make(9200)); } + /** + * @deprecated Use constructor instead + * Left for backward compatibility + */ public static function make(string $version = 'latest'): self { return new self($version); } - public function disableSecurityPlugin(): self + public function withDisabledSecurityPlugin(): self { $this->withEnvironment('plugins.security.disabled', 'true'); return $this; } + + /** + * @deprecated Use withDisabledSecurityPlugin instead + */ + public function disableSecurityPlugin(): self + { + return $this->withDisabledSecurityPlugin(); + } } diff --git a/src/Container/PostgresContainer.php b/src/Container/PostgresContainer.php index 66bdf18..bf77084 100644 --- a/src/Container/PostgresContainer.php +++ b/src/Container/PostgresContainer.php @@ -8,13 +8,17 @@ use Testcontainers\Wait\WaitForExec; class PostgresContainer extends GenericContainer { - private function __construct(string $version, string $rootPassword) + public function __construct(string $version = 'latest', string $rootPassword = 'root') { parent::__construct('postgres:' . $version); $this->withEnvironment('POSTGRES_PASSWORD', $rootPassword); $this->withWait(new WaitForExec(["pg_isready", "-h", "127.0.0.1"])); } + /** + * @deprecated Use constructor instead + * Left for backward compatibility + */ public static function make(string $version = 'latest', string $dbPassword = 'root'): self { return new self($version, $dbPassword); diff --git a/src/Container/RedisContainer.php b/src/Container/RedisContainer.php index 8846801..cba2a4c 100644 --- a/src/Container/RedisContainer.php +++ b/src/Container/RedisContainer.php @@ -8,12 +8,17 @@ use Testcontainers\Wait\WaitForLog; class RedisContainer extends GenericContainer { - private function __construct(string $version) + public function __construct(string $version = 'latest') { parent::__construct('redis:' . $version); - $this->withWait(new WaitForLog('Ready to accept connections')); + $this->withExposedPorts(6379); + //$this->withWait(new WaitForLog('Ready to accept connections')); } + /** + * @deprecated Use constructor instead + * Left for backward compatibility + */ public static function make(string $version = 'latest'): self { return new self($version); diff --git a/src/Wait/WaitForContainerRunning.php b/src/Wait/WaitForContainerRunning.php index fd394ca..cafeb70 100644 --- a/src/Wait/WaitForContainerRunning.php +++ b/src/Wait/WaitForContainerRunning.php @@ -41,6 +41,8 @@ class WaitForContainerRunning implements WaitInterface return; } + var_dump($containerStatus); + usleep($this->pollInterval * 1000); } } diff --git a/src/Wait/WaitForExec.php b/src/Wait/WaitForExec.php index f4c0f0f..d48a22f 100644 --- a/src/Wait/WaitForExec.php +++ b/src/Wait/WaitForExec.php @@ -8,7 +8,6 @@ use Closure; use Docker\API\Model\ContainersIdExecPostBody; use Docker\API\Model\ExecIdStartPostBody; use Docker\Docker; -use Testcontainers\Exception\ContainerNotReadyException; class WaitForExec implements WaitInterface { diff --git a/tests/Integration/RedisContainerTest.php b/tests/Integration/RedisContainerTest.php index c154c25..08dec78 100644 --- a/tests/Integration/RedisContainerTest.php +++ b/tests/Integration/RedisContainerTest.php @@ -4,33 +4,30 @@ declare(strict_types=1); namespace Testcontainers\Tests\Integration; -use PHPUnit\Framework\TestCase; -use Testcontainers\Container\GenericContainer; -use Testcontainers\Registry; +use Predis\Client; +use Testcontainers\Container\RedisContainer; -class RedisContainerTest extends TestCase +class RedisContainerTest extends ContainerTestCase { -// public static function tearDownAfterClass(): void -// { -// parent::tearDownAfterClass(); -// -// Registry::cleanup(); -// } + public static function setUpBeforeClass(): void + { + self::$container = (new RedisContainer()) + ->start(); + } public function testRedisContainer(): void { - $redisContainer = (new GenericContainer('redis:alpine')) - ->withExposedPorts(6379) - ->start(); - - $redisClient = new \Predis\Client([ + $redisClient = new Client([ 'host' => 'localhost', 'port' => 6379, ]); + $redisClient->ping(); + + $this->assertTrue($redisClient->isConnected()); + $redisClient->set('greetings', 'Hello, World!'); $this->assertEquals('Hello, World!', $redisClient->get('greetings')); - $redisContainer->remove(); } } diff --git a/tests/Integration/WaitStrategyTest.php b/tests/Integration/WaitStrategyTest.php index 168a719..7bdbd3d 100644 --- a/tests/Integration/WaitStrategyTest.php +++ b/tests/Integration/WaitStrategyTest.php @@ -10,8 +10,6 @@ use Predis\Connection\ConnectionException; use Symfony\Component\Process\Process; use Testcontainers\Container\GenericContainer; use Testcontainers\Exception\ContainerNotReadyException; -use Testcontainers\Registry; -use Testcontainers\Trait\DockerContainerAwareTrait; use Testcontainers\Wait\WaitForExec; use Testcontainers\Wait\WaitForHealthCheck; use Testcontainers\Wait\WaitForHttp; @@ -24,8 +22,6 @@ class WaitStrategyTest extends TestCase public static function tearDownAfterClass(): void { parent::tearDownAfterClass(); - - Registry::cleanup(); } public function testWaitForExec(): void From 5ee4fdc80435a620093f6dcbb6a90c5c80da0962 Mon Sep 17 00:00:00 2001 From: Sergei Shitikov Date: Sun, 1 Sep 2024 14:45:04 +0200 Subject: [PATCH 06/27] some reordering and test optimizations to align new API. --- .../ContainerRuntimeClient.php | 45 +++++++++++++++++++ tests/Integration/ContainerTestCase.php | 18 ++++++++ tests/Integration/MariaDBContainerTest.php | 35 +++++++++++++++ tests/Integration/MySQLContainerTest.php | 35 +++++++++++++++ tests/Integration/OpenSearchContainerTest.php | 38 ++++++++++++++++ tests/Integration/PostgreSQLContainerTest.php | 35 +++++++++++++++ 6 files changed, 206 insertions(+) create mode 100644 src/ContainerRuntime/ContainerRuntimeClient.php create mode 100644 tests/Integration/ContainerTestCase.php create mode 100644 tests/Integration/MariaDBContainerTest.php create mode 100644 tests/Integration/MySQLContainerTest.php create mode 100644 tests/Integration/OpenSearchContainerTest.php create mode 100644 tests/Integration/PostgreSQLContainerTest.php diff --git a/src/ContainerRuntime/ContainerRuntimeClient.php b/src/ContainerRuntime/ContainerRuntimeClient.php new file mode 100644 index 0000000..5728217 --- /dev/null +++ b/src/ContainerRuntime/ContainerRuntimeClient.php @@ -0,0 +1,45 @@ +remove(); + } +} diff --git a/tests/Integration/MariaDBContainerTest.php b/tests/Integration/MariaDBContainerTest.php new file mode 100644 index 0000000..f0629a7 --- /dev/null +++ b/tests/Integration/MariaDBContainerTest.php @@ -0,0 +1,35 @@ +withMariaDBDatabase('foo') + ->withMariaDBUser('bar', 'baz') + ->start(); + } + + public function testMySQLContainer(): void + { + $pdo = new \PDO( + sprintf('mysql:host=%s;port=3306', self::$container->getAddress()), + 'bar', + 'baz', + ); + + $query = $pdo->query('SHOW databases'); + + $this->assertInstanceOf(\PDOStatement::class, $query); + + $databases = $query->fetchAll(\PDO::FETCH_COLUMN); + + $this->assertContains('foo', $databases); + } +} diff --git a/tests/Integration/MySQLContainerTest.php b/tests/Integration/MySQLContainerTest.php new file mode 100644 index 0000000..d625a90 --- /dev/null +++ b/tests/Integration/MySQLContainerTest.php @@ -0,0 +1,35 @@ +withMySQLDatabase('foo') + ->withMySQLUser('bar', 'baz') + ->start(); + } + + public function testMySQLContainer(): void + { + $pdo = new \PDO( + sprintf('mysql:host=%s;port=3306', self::$container->getAddress()), + 'bar', + 'baz', + ); + + $query = $pdo->query('SHOW databases'); + + $this->assertInstanceOf(\PDOStatement::class, $query); + + $databases = $query->fetchAll(\PDO::FETCH_COLUMN); + + $this->assertContains('foo', $databases); + } +} diff --git a/tests/Integration/OpenSearchContainerTest.php b/tests/Integration/OpenSearchContainerTest.php new file mode 100644 index 0000000..92fd1cb --- /dev/null +++ b/tests/Integration/OpenSearchContainerTest.php @@ -0,0 +1,38 @@ +withDisabledSecurityPlugin() + ->start(); + } + + /** + * @throws \JsonException + */ + public function testOpenSearch(): void + { + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, sprintf('http://%s:%d', self::$container->getAddress(), 9200)); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + + $response = (string) curl_exec($ch); + + $this->assertNotEmpty($response); + + /** @var array{cluster_name: string} $data */ + $data = json_decode($response, true, JSON_THROW_ON_ERROR, JSON_THROW_ON_ERROR); + + $this->assertArrayHasKey('cluster_name', $data); + + $this->assertEquals('docker-cluster', $data['cluster_name']); + } +} diff --git a/tests/Integration/PostgreSQLContainerTest.php b/tests/Integration/PostgreSQLContainerTest.php new file mode 100644 index 0000000..7691435 --- /dev/null +++ b/tests/Integration/PostgreSQLContainerTest.php @@ -0,0 +1,35 @@ +withPostgresUser('test') + ->withPostgresDatabase('foo') + ->start(); + } + + public function testPostgreSQLContainer(): void + { + $pdo = new \PDO( + sprintf('pgsql:host=%s;port=5432;dbname=foo', self::$container->getAddress()), + 'test', + 'test', + ); + + $query = $pdo->query('SELECT datname FROM pg_database'); + + $this->assertInstanceOf(\PDOStatement::class, $query); + + $databases = $query->fetchAll(\PDO::FETCH_COLUMN); + + $this->assertContains('foo', $databases); + } +} From de66ea607c8a87f3215c7567faf67901f8f1b3d4 Mon Sep 17 00:00:00 2001 From: Sergei Shitikov Date: Sun, 1 Sep 2024 15:18:05 +0200 Subject: [PATCH 07/27] Fix WaitForLog implementation --- src/Container/RedisContainer.php | 2 +- .../ContainerWaitingTimeoutException.php | 22 ++++++++++ src/Wait/WaitForLog.php | 44 ++++++++++++++----- 3 files changed, 55 insertions(+), 13 deletions(-) create mode 100644 src/Exception/ContainerWaitingTimeoutException.php diff --git a/src/Container/RedisContainer.php b/src/Container/RedisContainer.php index cba2a4c..d568dd8 100644 --- a/src/Container/RedisContainer.php +++ b/src/Container/RedisContainer.php @@ -12,7 +12,7 @@ class RedisContainer extends GenericContainer { parent::__construct('redis:' . $version); $this->withExposedPorts(6379); - //$this->withWait(new WaitForLog('Ready to accept connections')); + $this->withWait(new WaitForLog('Ready to accept connections')); } /** diff --git a/src/Exception/ContainerWaitingTimeoutException.php b/src/Exception/ContainerWaitingTimeoutException.php new file mode 100644 index 0000000..e6d1c18 --- /dev/null +++ b/src/Exception/ContainerWaitingTimeoutException.php @@ -0,0 +1,22 @@ +containerId = $containerId; + $message ??= sprintf('Timeout reached while waiting for container %s', $containerId); + parent::__construct($message, 0, $previous); + } + + public function getContainerId(): string + { + return $this->containerId; + } +} diff --git a/src/Wait/WaitForLog.php b/src/Wait/WaitForLog.php index 8d70b15..04ef8cc 100644 --- a/src/Wait/WaitForLog.php +++ b/src/Wait/WaitForLog.php @@ -4,33 +4,53 @@ declare(strict_types=1); namespace Testcontainers\Wait; +use Docker\API\Runtime\Client\Client; use Docker\Docker; -use Symfony\Component\Process\Process; -use Testcontainers\Exception\ContainerNotReadyException; +use Testcontainers\Exception\ContainerWaitingTimeoutException; +/** + * Uses $timout and $pollInterval in milliseconds to set the parameters for waiting. + */ class WaitForLog implements WaitInterface { protected Docker $dockerClient; - public function __construct(private string $message, private bool $enableRegex = false) - { + public function __construct( + protected string $message, + protected bool $enableRegex = false, + protected int $timeout = 10000, + protected int $pollInterval = 500 + ) { $this->dockerClient = Docker::create(); } public function wait(string $id): void { - $logs = $this->dockerClient->containerLogs($id); + $startTime = microtime(true) * 1000; - $output = $logs->getBody()->getContents(); + while (true) { + $elapsedTime = (microtime(true) * 1000) - $startTime; - if ($this->enableRegex) { - if (!preg_match($this->message, $output)) { - throw new ContainerNotReadyException($id, new \RuntimeException('Message not found in logs')); + if ($elapsedTime > $this->timeout) { + throw new ContainerWaitingTimeoutException($id); } - } else { - if (!str_contains($output, $this->message)) { - throw new ContainerNotReadyException($id, new \RuntimeException('Message not found in logs')); + + $output = $this->dockerClient + ->containerLogs($id, ['stdout' => true, 'stderr' => true], Client::FETCH_RESPONSE) + ?->getBody() + ->getContents() ?? ''; + + $output = preg_replace('/[\x00-\x1F\x7F]/u', '', mb_convert_encoding($output, 'UTF-8', 'UTF-8')) ?? ''; + + if ($this->enableRegex) { + if (preg_match($this->message, $output)) { + return; + } + } elseif (str_contains($output, $this->message)) { + return; } + + usleep($this->pollInterval * 1000); } } } From 3dd4d2525a554ad72fb9971b640cbcb152c47e54 Mon Sep 17 00:00:00 2001 From: Sergei Shitikov Date: Sun, 1 Sep 2024 17:05:31 +0200 Subject: [PATCH 08/27] Remove WaitForNothing. WaitForContainerRunning can be used as a basic implementation instead. --- src/Container/MariaDBContainer.php | 3 +++ src/Container/MySQLContainer.php | 4 +++- src/Wait/WaitForNothing.php | 13 ------------- tests/Integration/MariaDBContainerTest.php | 2 +- 4 files changed, 7 insertions(+), 15 deletions(-) delete mode 100644 src/Wait/WaitForNothing.php diff --git a/src/Container/MariaDBContainer.php b/src/Container/MariaDBContainer.php index 60205f9..33a4cc2 100644 --- a/src/Container/MariaDBContainer.php +++ b/src/Container/MariaDBContainer.php @@ -4,12 +4,15 @@ declare(strict_types=1); namespace Testcontainers\Container; +use Testcontainers\Wait\WaitForLog; + class MariaDBContainer extends GenericContainer { public function __construct(string $version = 'latest', string $mysqlRootPassword = 'root') { parent::__construct('mariadb:' . $version); $this->withExposedPorts(3306); + $this->withWait(new WaitForLog('ready for connections')); $this->withEnvironment('MARIADB_ROOT_PASSWORD', $mysqlRootPassword); } diff --git a/src/Container/MySQLContainer.php b/src/Container/MySQLContainer.php index d1dfdeb..cdf6577 100644 --- a/src/Container/MySQLContainer.php +++ b/src/Container/MySQLContainer.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace Testcontainers\Container; -use Testcontainers\Wait\WaitForExec; +use Testcontainers\Wait\WaitForLog; class MySQLContainer extends GenericContainer { @@ -12,6 +12,8 @@ class MySQLContainer extends GenericContainer { parent::__construct('mysql:' . $version); $this->withExposedPorts(3306); + + $this->withWait(new WaitForLog('ready for connections')); $this->withEnvironment('MYSQL_ROOT_PASSWORD', $mysqlRootPassword); } diff --git a/src/Wait/WaitForNothing.php b/src/Wait/WaitForNothing.php deleted file mode 100644 index 741df17..0000000 --- a/src/Wait/WaitForNothing.php +++ /dev/null @@ -1,13 +0,0 @@ -start(); } - public function testMySQLContainer(): void + public function testMariaDBContainer(): void { $pdo = new \PDO( sprintf('mysql:host=%s;port=3306', self::$container->getAddress()), From ea07a960917398342456d740cf8a55a80505b9cb Mon Sep 17 00:00:00 2001 From: Sergei Shitikov Date: Sun, 1 Sep 2024 18:23:21 +0200 Subject: [PATCH 09/27] Implement WaitForExec using new Client library. Add BaseWait to avoid some code duplication. Adjust Postgres test --- src/Container/PostgresContainer.php | 20 ++++-- src/Wait/BaseWait.php | 20 ++++++ src/Wait/WaitForContainerRunning.php | 13 +--- src/Wait/WaitForExec.php | 67 ++++++++++++++----- src/Wait/WaitForLog.php | 11 ++- tests/Integration/PostgreSQLContainerTest.php | 8 +-- 6 files changed, 94 insertions(+), 45 deletions(-) create mode 100644 src/Wait/BaseWait.php diff --git a/src/Container/PostgresContainer.php b/src/Container/PostgresContainer.php index bf77084..705f3b6 100644 --- a/src/Container/PostgresContainer.php +++ b/src/Container/PostgresContainer.php @@ -8,11 +8,18 @@ use Testcontainers\Wait\WaitForExec; class PostgresContainer extends GenericContainer { - public function __construct(string $version = 'latest', string $rootPassword = 'root') - { + public function __construct( + string $version = 'latest', + public readonly string $username = 'test', + public readonly string $password = 'test', + public readonly string $database = 'test' + ) { parent::__construct('postgres:' . $version); - $this->withEnvironment('POSTGRES_PASSWORD', $rootPassword); - $this->withWait(new WaitForExec(["pg_isready", "-h", "127.0.0.1"])); + $this->withExposedPorts(5432); + $this->withEnvironment('POSTGRES_USER', $this->username); + $this->withEnvironment('POSTGRES_PASSWORD', $this->password); + $this->withEnvironment('POSTGRES_DB', $this->database); + $this->withWait(new WaitForExec(["pg_isready", "-h", "127.0.0.1", "-U", $this->username])); } /** @@ -21,7 +28,10 @@ class PostgresContainer extends GenericContainer */ public static function make(string $version = 'latest', string $dbPassword = 'root'): self { - return new self($version, $dbPassword); + return new self( + version: $version, + password: $dbPassword + ); } public function withPostgresUser(string $username): self diff --git a/src/Wait/BaseWait.php b/src/Wait/BaseWait.php new file mode 100644 index 0000000..b9b607a --- /dev/null +++ b/src/Wait/BaseWait.php @@ -0,0 +1,20 @@ +dockerClient = ContainerRuntimeClient::getDockerClient(); + } + + abstract public function wait(string $id): void; +} diff --git a/src/Wait/WaitForContainerRunning.php b/src/Wait/WaitForContainerRunning.php index cafeb70..570fd28 100644 --- a/src/Wait/WaitForContainerRunning.php +++ b/src/Wait/WaitForContainerRunning.php @@ -5,23 +5,14 @@ declare(strict_types=1); namespace Testcontainers\Wait; use Docker\API\Model\ContainersIdJsonGetResponse200; -use Docker\Docker; -use Testcontainers\ContainerRuntime\ContainerRuntimeClient; use Testcontainers\Exception\ContainerNotReadyException; /** * Simply makes container inspect and checks if container is running. * Uses $timout and $pollInterval in milliseconds to set the parameters for waiting. */ -class WaitForContainerRunning implements WaitInterface +class WaitForContainerRunning extends BaseWait { - protected Docker $dockerClient; - - public function __construct(protected int $timeout = 10000, protected int $pollInterval = 500) - { - $this->dockerClient = ContainerRuntimeClient::getDockerClient(); - } - public function wait(string $id): void { $startTime = microtime(true) * 1000; @@ -41,8 +32,6 @@ class WaitForContainerRunning implements WaitInterface return; } - var_dump($containerStatus); - usleep($this->pollInterval * 1000); } } diff --git a/src/Wait/WaitForExec.php b/src/Wait/WaitForExec.php index d48a22f..6d8aea9 100644 --- a/src/Wait/WaitForExec.php +++ b/src/Wait/WaitForExec.php @@ -5,34 +5,67 @@ declare(strict_types=1); namespace Testcontainers\Wait; use Closure; +use Docker\API\Client; use Docker\API\Model\ContainersIdExecPostBody; -use Docker\API\Model\ExecIdStartPostBody; -use Docker\Docker; +use Testcontainers\Exception\ContainerWaitingTimeoutException; -class WaitForExec implements WaitInterface +/** + * Uses $timout and $pollInterval in milliseconds to set the parameters for waiting. + */ +class WaitForExec extends BaseWait { - protected Docker $dockerClient; - protected ContainersIdExecPostBody $execConfig; /** * @param array $command */ - public function __construct(private array $command, private ?Closure $checkFunction = null) - { - $this->dockerClient = Docker::create(); - $execConfig = new ContainersIdExecPostBody(); - $execConfig->setTty(true); - $execConfig->setAttachStdout(true); - $execConfig->setAttachStderr(true); - $execConfig->setCmd($this->command); + public function __construct( + protected array $command, + protected ?Closure $checkFunction = null, + int $timeout = 10000, + int $pollInterval = 500 + ) { + parent::__construct($timeout, $pollInterval); } public function wait(string $id): void { - $execid = $this->dockerClient->containerExec($id, $this->execConfig)->getId() ?? ''; - $execStartConfig = new ExecIdStartPostBody(); - $execStartConfig->setDetach(false); - $this->dockerClient->execStart($execid, $execStartConfig); + $this->execConfig = (new ContainersIdExecPostBody()) + ->setCmd($this->command) + ->setAttachStdout(true) + ->setAttachStderr(true); + + $startTime = microtime(true) * 1000; + + while (true) { + $elapsedTime = (microtime(true) * 1000) - $startTime; + + if ($elapsedTime > $this->timeout) { + throw new ContainerWaitingTimeoutException($id); + } + + // Create and start the exec command + $exec = $this->dockerClient->containerExec($id, $this->execConfig); + $contents = $this->dockerClient + ->execStart($exec->getId(), null, Client::FETCH_RESPONSE) + ?->getBody() + ->getContents() ?? ''; + + // Inspect the exec to check the exit code + $execInspect = $this->dockerClient->execInspect($exec->getId()); + $exitCode = $execInspect->getExitCode(); + + // If a custom check function is provided, use it to validate the command output + if ($this->checkFunction !== null) { + $checkResult = ($this->checkFunction)($exitCode, $contents); + if ($checkResult) { + return; + } + } elseif ($exitCode === 0) { + return; // Command succeeded + } + + usleep($this->pollInterval * 1000); + } } } diff --git a/src/Wait/WaitForLog.php b/src/Wait/WaitForLog.php index 04ef8cc..c8ba18c 100644 --- a/src/Wait/WaitForLog.php +++ b/src/Wait/WaitForLog.php @@ -5,23 +5,20 @@ declare(strict_types=1); namespace Testcontainers\Wait; use Docker\API\Runtime\Client\Client; -use Docker\Docker; use Testcontainers\Exception\ContainerWaitingTimeoutException; /** * Uses $timout and $pollInterval in milliseconds to set the parameters for waiting. */ -class WaitForLog implements WaitInterface +class WaitForLog extends BaseWait { - protected Docker $dockerClient; - public function __construct( protected string $message, protected bool $enableRegex = false, - protected int $timeout = 10000, - protected int $pollInterval = 500 + int $timeout = 10000, + int $pollInterval = 500 ) { - $this->dockerClient = Docker::create(); + parent::__construct($timeout, $pollInterval); } public function wait(string $id): void diff --git a/tests/Integration/PostgreSQLContainerTest.php b/tests/Integration/PostgreSQLContainerTest.php index 7691435..2b6bd70 100644 --- a/tests/Integration/PostgreSQLContainerTest.php +++ b/tests/Integration/PostgreSQLContainerTest.php @@ -10,8 +10,8 @@ class PostgreSQLContainerTest extends ContainerTestCase { public static function setUpBeforeClass(): void { - self::$container = (new PostgresContainer('latest', 'test')) - ->withPostgresUser('test') + self::$container = (new PostgresContainer()) + ->withPostgresUser('bar') ->withPostgresDatabase('foo') ->start(); } @@ -19,8 +19,8 @@ class PostgreSQLContainerTest extends ContainerTestCase public function testPostgreSQLContainer(): void { $pdo = new \PDO( - sprintf('pgsql:host=%s;port=5432;dbname=foo', self::$container->getAddress()), - 'test', + 'pgsql:host=127.0.0.1;port=5432;dbname=foo', + 'bar', 'test', ); From 93c9895027fe415346585c2368ba1be1cb995329 Mon Sep 17 00:00:00 2001 From: Sergei Shitikov Date: Sun, 1 Sep 2024 19:12:46 +0200 Subject: [PATCH 10/27] Adjust tests for OpenSearch --- phpunit.xml.dist | 1 + src/Container/MySQLContainer.php | 3 +-- src/Container/OpenSearchContainer.php | 7 ++++++- tests/Integration/MySQLContainerTest.php | 2 +- tests/Integration/OpenSearchContainerTest.php | 2 +- 5 files changed, 10 insertions(+), 5 deletions(-) diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 9993376..fd48e82 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -14,6 +14,7 @@ tests + tests/Integration/WaitStrategyTest.php diff --git a/src/Container/MySQLContainer.php b/src/Container/MySQLContainer.php index cdf6577..e4bb4d9 100644 --- a/src/Container/MySQLContainer.php +++ b/src/Container/MySQLContainer.php @@ -12,9 +12,8 @@ class MySQLContainer extends GenericContainer { parent::__construct('mysql:' . $version); $this->withExposedPorts(3306); - - $this->withWait(new WaitForLog('ready for connections')); $this->withEnvironment('MYSQL_ROOT_PASSWORD', $mysqlRootPassword); + $this->withWait(new WaitForLog('ready for connections')); } /** diff --git a/src/Container/OpenSearchContainer.php b/src/Container/OpenSearchContainer.php index 186df17..a6d2b84 100644 --- a/src/Container/OpenSearchContainer.php +++ b/src/Container/OpenSearchContainer.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Testcontainers\Container; use Testcontainers\Wait\WaitForHttp; +use Testcontainers\Wait\WaitForLog; class OpenSearchContainer extends GenericContainer { @@ -14,7 +15,11 @@ class OpenSearchContainer extends GenericContainer $this->withExposedPorts(9200); $this->withEnvironment('discovery.type', 'single-node'); $this->withEnvironment('OPENSEARCH_INITIAL_ADMIN_PASSWORD', 'c3o_ZPHo!'); - $this->withWait(WaitForHttp::make(9200)); + $this->withWait(new WaitForLog( + '/\]\s+started\?\[/', + true, + 30000 + )); } /** diff --git a/tests/Integration/MySQLContainerTest.php b/tests/Integration/MySQLContainerTest.php index d625a90..51c98e3 100644 --- a/tests/Integration/MySQLContainerTest.php +++ b/tests/Integration/MySQLContainerTest.php @@ -19,7 +19,7 @@ class MySQLContainerTest extends ContainerTestCase public function testMySQLContainer(): void { $pdo = new \PDO( - sprintf('mysql:host=%s;port=3306', self::$container->getAddress()), + sprintf('mysql:host=%s;port=3306', '127.0.0.1'), 'bar', 'baz', ); diff --git a/tests/Integration/OpenSearchContainerTest.php b/tests/Integration/OpenSearchContainerTest.php index 92fd1cb..8e8db0d 100644 --- a/tests/Integration/OpenSearchContainerTest.php +++ b/tests/Integration/OpenSearchContainerTest.php @@ -21,7 +21,7 @@ class OpenSearchContainerTest extends ContainerTestCase public function testOpenSearch(): void { $ch = curl_init(); - curl_setopt($ch, CURLOPT_URL, sprintf('http://%s:%d', self::$container->getAddress(), 9200)); + curl_setopt($ch, CURLOPT_URL, sprintf('http://%s:%d', '127.0.0.1', 9200)); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); $response = (string) curl_exec($ch); From a956794279725e1572cf8addc6ff834a69256f22 Mon Sep 17 00:00:00 2001 From: Sergei Shitikov Date: Sun, 1 Sep 2024 19:40:55 +0200 Subject: [PATCH 11/27] Added test for GenericContainer implementation + implemented exec related stuff --- src/Container/GenericContainer.php | 61 ++++++++++++++++------ tests/Integration/GenericContainerTest.php | 23 ++++++++ 2 files changed, 67 insertions(+), 17 deletions(-) create mode 100644 tests/Integration/GenericContainerTest.php diff --git a/src/Container/GenericContainer.php b/src/Container/GenericContainer.php index 0a3e42f..04e26b2 100644 --- a/src/Container/GenericContainer.php +++ b/src/Container/GenericContainer.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Testcontainers\Container; +use Docker\API\Client; use Docker\API\Exception\ContainerCreateNotFoundException; use Docker\API\Model\ContainersCreatePostBody; use Docker\API\Model\ContainersIdExecPostBody; @@ -35,6 +36,9 @@ class GenericContainer protected string $id; + /** @var array */ + protected array $command = []; + protected ?string $entryPoint = null; protected ?HealthConfig $healthConfig = null; @@ -77,6 +81,29 @@ class GenericContainer return $this->id; } + public function withCommand(array $command): self + { + $this->command = $command; + return $this; + } + + public function exec(array $command): string + { + $execConfig = (new ContainersIdExecPostBody()) + ->setCmd($command) + ->setAttachStdout(true) + ->setAttachStderr(true); + + // Create and start the exec command + $exec = $this->dockerClient->containerExec($this->id, $execConfig); + $contents = $this->dockerClient + ->execStart($exec->getId(), null, Client::FETCH_RESPONSE) + ?->getBody() + ->getContents() ?? ''; + + return preg_replace('/[\x00-\x1F\x7F]/u', '', $contents); + } + public function withEntryPoint(string $entryPoint): self { $this->entryPoint = $entryPoint; @@ -194,12 +221,6 @@ class GenericContainer return $this; } - public function wait(): self - { - $this->wait->wait($this->id); - return $this; - } - public function stop(): self { $this->dockerClient->containerStop($this->id); @@ -211,19 +232,23 @@ class GenericContainer { try { $containerCreatePostBody = new ContainersCreatePostBody(); - $portMap = new \ArrayObject(); + //setup only if we need to expose ports + if(!empty($this->exposedPorts)) { + $portMap = new \ArrayObject(); - foreach ($this->exposedPorts as $port) { - $portBinding = new PortBinding(); - $portBinding->setHostPort(explode('/', $port)[0]); - $portBinding->setHostIp('0.0.0.0'); - $portMap[$port] = [$portBinding]; + foreach ($this->exposedPorts as $port) { + $portBinding = new PortBinding(); + $portBinding->setHostPort(explode('/', $port)[0]); + $portBinding->setHostIp('0.0.0.0'); + $portMap[$port] = [$portBinding]; + } + + $hostConfig = new HostConfig(); + $hostConfig->setPortBindings($portMap); + $containerCreatePostBody->setHostConfig($hostConfig); } - - $hostConfig = new HostConfig(); - $hostConfig->setPortBindings($portMap); - $containerCreatePostBody->setHostConfig($hostConfig); $containerCreatePostBody->setImage($this->image); + $containerCreatePostBody->setCmd($this->command); $envs = []; foreach ($this->env as $key => $value) { $envs[] = $key . '=' . $value; @@ -245,7 +270,9 @@ class GenericContainer if(!isset($this->wait)) { $this->withWait(new WaitForContainerRunning()); } - $this->wait(); + + $this->wait->wait($this->id); + return $this; } diff --git a/tests/Integration/GenericContainerTest.php b/tests/Integration/GenericContainerTest.php new file mode 100644 index 0000000..4fc644f --- /dev/null +++ b/tests/Integration/GenericContainerTest.php @@ -0,0 +1,23 @@ +withCommand(['tail', '-f', '/dev/null']) + ->start(); + } + + public function testExec(): void + { + $actual = self::$container->exec(['echo', 'testcontainers']); + self::assertSame('testcontainers', $actual); + } +} From f05ea0020d03431fbaa154475fd46aa6111be319 Mon Sep 17 00:00:00 2001 From: Sergei Shitikov Date: Fri, 6 Sep 2024 19:08:37 +0200 Subject: [PATCH 12/27] - Move most of the stuff for backwards compatibility support into one class - Added random port logic - Updated wait wtrategies - API alignments to make it similar to other official implementations - ... --- README.md | 14 +- phpunit.xml.dist | 1 - src/Container/Container.php | 149 ++++++++++- src/Container/GenericContainer.php | 237 +++++------------- src/Container/MariaDBContainer.php | 41 +-- src/Container/MySQLContainer.php | 41 +-- src/Container/OpenSearchContainer.php | 47 +--- src/Container/PostgresContainer.php | 49 +--- src/Container/RedisContainer.php | 25 +- src/Container/StartedGenericContainer.php | 152 +++++++++++ src/Container/StartedTestContainer.php | 43 ++++ src/Container/StoppedGenericContainer.php | 17 ++ src/Container/StoppedTestContainer.php | 10 + src/Container/TestContainer.php | 34 +++ .../DockerContainerClient.php} | 4 +- src/Modules/MariaDBContainer.php | 43 ++++ src/Modules/MySQLContainer.php | 43 ++++ src/Modules/OpenSearchContainer.php | 48 ++++ src/Modules/PostgresContainer.php | 51 ++++ src/Modules/RedisContainer.php | 27 ++ .../PortGenerator/FixedPortGenerator.php | 25 ++ src/Utils/PortGenerator/PortGenerator.php | 10 + .../PortGenerator/RandomPortGenerator.php | 30 +++ .../RandomUniquePortGenerator.php | 26 ++ src/Wait/BaseWait.php | 20 -- src/Wait/BaseWaitStrategy.php | 17 ++ ...tainerRunning.php => WaitForContainer.php} | 8 +- src/Wait/WaitForExec.php | 26 +- src/Wait/WaitForHealthCheck.php | 22 +- src/Wait/WaitForHttp.php | 2 +- src/Wait/WaitForLog.php | 15 +- src/Wait/WaitForTcpPortOpen.php | 2 +- src/Wait/WaitInterface.php | 10 - src/Wait/WaitStrategy.php | 12 + tests/Integration/ContainerTestCase.php | 6 +- tests/Integration/MariaDBContainerTest.php | 8 +- tests/Integration/MySQLContainerTest.php | 8 +- tests/Integration/OldTests/ContainerTest.php | 139 ++++++++++ .../Integration/OldTests/WaitStrategyTest.php | 151 +++++++++++ tests/Integration/OpenSearchContainerTest.php | 8 +- tests/Integration/PostgreSQLContainerTest.php | 8 +- tests/Integration/RedisContainerTest.php | 6 +- tests/Integration/WaitStrategyTest.php | 157 ------------ 43 files changed, 1191 insertions(+), 601 deletions(-) create mode 100644 src/Container/StartedGenericContainer.php create mode 100644 src/Container/StartedTestContainer.php create mode 100644 src/Container/StoppedGenericContainer.php create mode 100644 src/Container/StoppedTestContainer.php create mode 100644 src/Container/TestContainer.php rename src/{ContainerRuntime/ContainerRuntimeClient.php => ContainerClient/DockerContainerClient.php} (93%) create mode 100644 src/Modules/MariaDBContainer.php create mode 100644 src/Modules/MySQLContainer.php create mode 100644 src/Modules/OpenSearchContainer.php create mode 100644 src/Modules/PostgresContainer.php create mode 100644 src/Modules/RedisContainer.php create mode 100644 src/Utils/PortGenerator/FixedPortGenerator.php create mode 100644 src/Utils/PortGenerator/PortGenerator.php create mode 100644 src/Utils/PortGenerator/RandomPortGenerator.php create mode 100644 src/Utils/PortGenerator/RandomUniquePortGenerator.php delete mode 100644 src/Wait/BaseWait.php create mode 100644 src/Wait/BaseWaitStrategy.php rename src/Wait/{WaitForContainerRunning.php => WaitForContainer.php} (76%) delete mode 100644 src/Wait/WaitInterface.php create mode 100644 src/Wait/WaitStrategy.php create mode 100644 tests/Integration/OldTests/ContainerTest.php create mode 100644 tests/Integration/OldTests/WaitStrategyTest.php delete mode 100644 tests/Integration/WaitStrategyTest.php diff --git a/README.md b/README.md index 3e2c2e1..22cf3e0 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ composer req --dev testcontainers/testcontainers use Testcontainers\Container\GenericContainer; -$container = GenericContainer::make('nginx:alpine'); +$container = new GenericContainer::make('nginx:alpine'); // set an environment variable $container->withEnvironment('name', 'var'); @@ -58,7 +58,7 @@ $container->withWait(new WaitForHealthCheck()); ```php withMySQLDatabase('foo'); @@ -80,7 +80,7 @@ $pdo = new \PDO( ```php withMariaDBDatabase('foo'); @@ -102,7 +102,7 @@ $pdo = new \PDO( ```php withPostgresDatabase('database'); @@ -123,7 +123,7 @@ $pdo = new \PDO( ```php -use Testcontainers\Container\RedisContainer; +use Testcontainers\Modules\RedisContainer; $container = RedisContainer::make('6.0'); @@ -139,7 +139,7 @@ $redis->connect($container->getAddress()); ```php -use Testcontainers\Container\OpenSearchContainer; +use Testcontainers\Modules\OpenSearchContainer; $container = OpenSearchContainer::make('2'); $container->disableSecurityPlugin(); @@ -166,7 +166,7 @@ use Doctrine\Bundle\DoctrineBundle\ConnectionFactory; use Doctrine\Common\EventManager; use Doctrine\DBAL\Configuration; use Doctrine\DBAL\Tools\DsnParser; -use Testcontainers\Container\PostgresContainer; +use Testcontainers\Modules\PostgresContainer; class TestConnectionFactory extends ConnectionFactory { diff --git a/phpunit.xml.dist b/phpunit.xml.dist index fd48e82..9993376 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -14,7 +14,6 @@ tests - tests/Integration/WaitStrategyTest.php diff --git a/src/Container/Container.php b/src/Container/Container.php index 0134203..29a3d71 100644 --- a/src/Container/Container.php +++ b/src/Container/Container.php @@ -6,9 +6,154 @@ namespace Testcontainers\Container; /** * Added for backward compatibility. - * Just a wrapper for GenericContainer. * @deprecated Use GenericContainer instead. + * TODO: Remove in next major release. */ -class Container extends GenericContainer +final class Container extends GenericContainer { + protected ?StartedTestContainer $startedContainer = null; + + protected ?StoppedTestContainer $stoppedContainer = null; + public static function make(string $image): self + { + return new self($image); + } + + /** + * @deprecated Use `withPrivilegedMode` instead + */ + public function withPrivileged(bool $privileged = true): self + { + return $this->withPrivilegedMode($privileged); + } + + /** + * @deprecated Use `withExposedPorts` instead + */ + public function withPort(string $localPort, string $containerPort): self + { + return $this->withExposedPorts($containerPort); + } + + /** + * @deprecated there will be no replacement + */ + public function withImage(string $image): self + { + $this->image = $image; + + return $this; + } + + /** + * @deprecated Use `start` instead + */ + public function run(): self + { + $this->startedContainer = $this->start(); + + return $this; + } + + /** + * @param array $commandAsArray + * @deprecated Use 'exec' from StartedTestContainer instead + */ + public function execute(array $commandAsArray): string + { + if($this->startedContainer === null) { + throw new \RuntimeException('Container is not started'); + } + + return $this->startedContainer->exec($commandAsArray); + } + + /** + * @deprecated Use 'logs' from StartedTestContainer instead + */ + public function logs(): string + { + if($this->startedContainer === null) { + throw new \RuntimeException('Container is not started'); + } + + return $this->startedContainer->logs(); + } + + /** + * @deprecated Use 'getHost' from StartedTestContainer instead + */ + public function getAddress(): string + { + if($this->startedContainer === null) { + throw new \RuntimeException('Container is not started'); + } + + return $this->startedContainer->getHost(); + } + + /** + * @deprecated Use 'getFirstMappedPort' from StartedTestContainer instead + */ + public function getPort(): int + { + if($this->startedContainer === null) { + throw new \RuntimeException('Container is not started'); + } + + return $this->startedContainer->getFirstMappedPort(); + } + + /** + * @deprecated Use 'stop' from StartedTestContainer instead + */ + public function kill(): self + { + $this->dockerClient->containerKill($this->id); + + return $this; + } + + /** + * @deprecated Use `stop` from StartedTestContainer instead + */ + public function stop(): self + { + if($this->startedContainer === null) { + throw new \RuntimeException('Container is not started'); + } + + $this->stoppedContainer = $this->startedContainer->stop(); + + return $this; + } + + /** + * @deprecated Use 'restart' method from StartedTestContainer instead + */ + public function restart(): self + { + if($this->startedContainer === null) { + throw new \RuntimeException('Container is not started'); + } + + $restartedTestContainer = $this->startedContainer->restart(); + $this->startedContainer = $restartedTestContainer; + + return $this; + } + + /** + * @deprecated Use 'stop' method from StartedTestContainer instead + */ + public function remove(): self + { + if($this->startedContainer === null) { + throw new \RuntimeException('Container is not started'); + } + + $this->startedContainer->stop(); + + return $this; + } } diff --git a/src/Container/GenericContainer.php b/src/Container/GenericContainer.php index 04e26b2..6517091 100644 --- a/src/Container/GenericContainer.php +++ b/src/Container/GenericContainer.php @@ -4,39 +4,28 @@ declare(strict_types=1); namespace Testcontainers\Container; -use Docker\API\Client; use Docker\API\Exception\ContainerCreateNotFoundException; use Docker\API\Model\ContainersCreatePostBody; -use Docker\API\Model\ContainersIdExecPostBody; use Docker\API\Model\HealthConfig; use Docker\API\Model\HostConfig; use Docker\API\Model\Mount; use Docker\API\Model\PortBinding; use Docker\Docker; -use Psr\Http\Message\ResponseInterface; -use Testcontainers\ContainerRuntime\ContainerRuntimeClient; -use Testcontainers\Wait\WaitForContainerRunning; -use Testcontainers\Wait\WaitInterface; +use InvalidArgumentException; +use Testcontainers\ContainerClient\DockerContainerClient; +use Testcontainers\Utils\PortGenerator\RandomUniquePortGenerator; +use Testcontainers\Wait\WaitForContainer; +use Testcontainers\Wait\WaitStrategy; -/** - * @phpstan-type ContainerInspectSingleNetwork array - * @phpstan-type ContainerInspectMultipleNetworks array}}> - * @phpstan-type ContainerInspect ContainerInspectSingleNetwork|ContainerInspectMultipleNetworks - * @phpstan-type DockerNetwork array{CreatedAt: string, Driver: string, ID: string, IPv6: string, Internal: string, Labels: string, Name: string, Scope: string} - */ -class GenericContainer +class GenericContainer implements TestContainer { protected Docker $dockerClient; - protected ContainersCreatePostBody $containerConfig; - protected string $image; - protected string $containerName; - protected string $id; - /** @var array */ + /** @var list */ protected array $command = []; protected ?string $entryPoint = null; @@ -48,7 +37,7 @@ class GenericContainer */ protected array $env = []; - protected WaitInterface $wait; + protected WaitStrategy $waitStrategy; protected bool $isPrivileged = false; protected ?string $networkName = null; @@ -64,16 +53,7 @@ class GenericContainer public function __construct(string $image) { $this->image = $image; - $this->dockerClient = ContainerRuntimeClient::getDockerClient(); - } - - /** - * @deprecated Use constructor instead - * Left for backward compatibility - */ - public static function make(string $image): self - { - return new GenericContainer($image); + $this->dockerClient = DockerContainerClient::getDockerClient(); } public function getId(): string @@ -81,58 +61,56 @@ class GenericContainer return $this->id; } - public function withCommand(array $command): self + /** + * @param list $command + */ + public function withCommand(array $command): static { $this->command = $command; + return $this; } - public function exec(array $command): string - { - $execConfig = (new ContainersIdExecPostBody()) - ->setCmd($command) - ->setAttachStdout(true) - ->setAttachStderr(true); - - // Create and start the exec command - $exec = $this->dockerClient->containerExec($this->id, $execConfig); - $contents = $this->dockerClient - ->execStart($exec->getId(), null, Client::FETCH_RESPONSE) - ?->getBody() - ->getContents() ?? ''; - - return preg_replace('/[\x00-\x1F\x7F]/u', '', $contents); - } - - public function withEntryPoint(string $entryPoint): self + public function withEntryPoint(string $entryPoint): static { $this->entryPoint = $entryPoint; return $this; } - public function withEnvironment(string $name, string $value): self + /** + * To support temporarily backwards compatibility, the method supports two formats: + * 1. A single key-value pair (deprecated): $object->withEnvironment('key', 'value'); + * 2. An array of key-value pairs: $object->withEnvironment(['key1' => 'value1', 'key2' => 'value2']); + * + * @param string | array $env An array of environment variables or the name of a single variable. + * @param string|null $value The value of the environment variable if a single variable is passed. + * @return static Returns itself for chaining purposes. + */ + public function withEnvironment(string | array $env, ?string $value = null): static { - $this->env[$name] = $value; + if (is_array($env)) { + foreach ($env as $key => $val) { + $this->env[$key] = $val; + } + } else { + if ($value === null) { + throw new InvalidArgumentException("Value cannot be null when setting a single environment variable."); + } + $this->env[$env] = $value; + } return $this; } - public function withImage(string $image): self + public function withWait(WaitStrategy $waitStrategy): static { - $this->image = $image; + $this->waitStrategy = $waitStrategy; return $this; } - public function withWait(WaitInterface $wait): self - { - $this->wait = $wait; - - return $this; - } - - public function withHealthCheckCommand(string $command, int $healthCheckIntervalInMS = 1000): self + public function withHealthCheckCommand(string $command, int $healthCheckIntervalInMS = 1000): static { $this->healthConfig = new HealthConfig([ 'Test' => ['CMD', $command], @@ -142,33 +120,22 @@ class GenericContainer return $this; } - public function withMount(string $localPath, string $containerPath): self + public function withMount(string $localPath, string $containerPath): static { $this->mounts[] = new Mount(['type' => 'bind', 'source' => $localPath, 'target' => $containerPath]); return $this; } - /** - * @deprecated Use `withExposedPorts` instead - */ - public function withPort(string $localPort, string $containerPort): self - { - return $this->withExposedPorts($containerPort); - } - - /** - * @psalm-param string|int|array $port - */ /** * Add ports to be exposed by the Docker container. * This method accepts multiple inputs: single port, multiple ports, or ports with specific protocols * to attempt to align with other language implementations. * * @psalm-param int|string|array $ports One or more ports to expose. - * @return self Fluent interface for chaining. + * @return static Fluent interface for chaining. */ - public function withExposedPorts(...$ports): self + public function withExposedPorts(...$ports): static { foreach ($ports as $port) { if (is_array($port)) { @@ -207,46 +174,51 @@ class GenericContainer return $port; } - public function withPrivileged(bool $privileged = true): self + public function withPrivilegedMode(bool $privileged = true): static { $this->isPrivileged = $privileged; return $this; } - public function withNetwork(string $networkName): self + //TODO: not yet implemented + public function withNetwork(string $networkName): static { $this->networkName = $networkName; return $this; } - public function stop(): self - { - $this->dockerClient->containerStop($this->id); - - return $this; - } - - public function start(): self + //TODO: needs refactoring + public function start(): StartedGenericContainer { try { $containerCreatePostBody = new ContainersCreatePostBody(); - //setup only if we need to expose ports + //handle withExposedPorts if(!empty($this->exposedPorts)) { + $portGenerator = new RandomUniquePortGenerator(); $portMap = new \ArrayObject(); foreach ($this->exposedPorts as $port) { $portBinding = new PortBinding(); - $portBinding->setHostPort(explode('/', $port)[0]); + $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 = []; @@ -267,96 +239,13 @@ class GenericContainer $this->dockerClient->containerStart($this->id); - if(!isset($this->wait)) { - $this->withWait(new WaitForContainerRunning()); + if(!isset($this->waitStrategy)) { + $this->withWait(new WaitForContainer()); } - $this->wait->wait($this->id); + $startedContainer = new StartedGenericContainer($this->id); + $this->waitStrategy->wait($startedContainer); - return $this; - } - - public function restart(): self - { - $this->dockerClient->containerRestart($this->id); - - return $this; - } - - public function remove(): self - { - $this->dockerClient->containerStop($this->id); - $this->dockerClient->containerDelete($this->id); - - return $this; - } - - public function kill(): self - { - $this->dockerClient->containerKill($this->id); - - return $this; - } - - /** - * @deprecated Use `start` instead - * Left for backward compatibility - */ - public function run(): self - { - return $this->start(); - } - - /** - * @param array $commandAsArray - */ - public function execute(array $commandAsArray): ResponseInterface - { - $command = new ContainersIdExecPostBody(); - $command->setCmd($commandAsArray); - return $this->dockerClient->containerExec($this->id, $command); - } - - public function logs(): string - { - return $this->dockerClient->containerLogs($this->id)?->getBody()?->getContents() ?? ''; - } - - public function getAddress(): string - { - $inspection = $this->inspect(); - return $inspection['gateway']; - // foreach ($containerNetworks as $network) { - // var_dump($network->getNetworkID(), $this->id, $network->getIPAddress()); - // if($network->getNetworkID() === $this->id) { - // $containerAddress = $network->getIpAddress(); - // break; - // } - // } - // return $containerAddress; - } - - /** - * @return array{gateway: string, ports: array} - */ - public function inspect(): array - { - $response = $this->dockerClient->containerInspect($this->id); - $settings = $response->getNetworkSettings(); - //var_dump($settings); - - $ports = []; - foreach ($settings->getPorts() as $port => $value) { - if ($value === null) { - continue; - } - - $ports[$port] = (int) $value[0]->getHostPort(); - } - - return [ - 'gateway' => $settings->getGateway(), - 'ports' => $ports, - ]; + return $startedContainer; } } diff --git a/src/Container/MariaDBContainer.php b/src/Container/MariaDBContainer.php index 33a4cc2..1171ca1 100644 --- a/src/Container/MariaDBContainer.php +++ b/src/Container/MariaDBContainer.php @@ -4,39 +4,12 @@ declare(strict_types=1); namespace Testcontainers\Container; -use Testcontainers\Wait\WaitForLog; - -class MariaDBContainer extends GenericContainer +/** + * Left for namespace backward compatibility + * @deprecated Use \Testcontainers\Modules\MariaDBContainer instead. + * TODO: Remove in next major release. + */ +class MariaDBContainer extends \Testcontainers\Modules\MariaDBContainer { - public function __construct(string $version = 'latest', string $mysqlRootPassword = 'root') - { - parent::__construct('mariadb:' . $version); - $this->withExposedPorts(3306); - $this->withWait(new WaitForLog('ready for connections')); - $this->withEnvironment('MARIADB_ROOT_PASSWORD', $mysqlRootPassword); - } - /** - * @deprecated Use constructor instead - * Left for backward compatibility - */ - public static function make(string $version = 'latest', string $mysqlRootPassword = 'root'): self - { - return new self($version, $mysqlRootPassword); - } - - public function withMariaDBUser(string $username, string $password): self - { - $this->withEnvironment('MARIADB_USER', $username); - $this->withEnvironment('MARIADB_PASSWORD', $password); - - return $this; - } - - public function withMariaDBDatabase(string $database): self - { - $this->withEnvironment('MARIADB_DATABASE', $database); - - return $this; - } -} +} \ No newline at end of file diff --git a/src/Container/MySQLContainer.php b/src/Container/MySQLContainer.php index e4bb4d9..9aab957 100644 --- a/src/Container/MySQLContainer.php +++ b/src/Container/MySQLContainer.php @@ -4,39 +4,12 @@ declare(strict_types=1); namespace Testcontainers\Container; -use Testcontainers\Wait\WaitForLog; - -class MySQLContainer extends GenericContainer +/** + * Left for namespace backward compatibility + * @deprecated Use \Testcontainers\Modules\MySQLContainer instead. + * TODO: Remove in next major release. + */ +class MySQLContainer extends \Testcontainers\Modules\MySQLContainer { - public function __construct(string $version = 'latest', string $mysqlRootPassword = 'root') - { - parent::__construct('mysql:' . $version); - $this->withExposedPorts(3306); - $this->withEnvironment('MYSQL_ROOT_PASSWORD', $mysqlRootPassword); - $this->withWait(new WaitForLog('ready for connections')); - } - /** - * @deprecated Use constructor instead - * Left for backward compatibility - */ - public static function make(string $version = 'latest', string $mysqlRootPassword = 'root'): self - { - return new self($version, $mysqlRootPassword); - } - - public function withMySQLUser(string $username, string $password): self - { - $this->withEnvironment('MYSQL_USER', $username); - $this->withEnvironment('MYSQL_PASSWORD', $password); - - return $this; - } - - public function withMySQLDatabase(string $database): self - { - $this->withEnvironment('MYSQL_DATABASE', $database); - - return $this; - } -} +} \ No newline at end of file diff --git a/src/Container/OpenSearchContainer.php b/src/Container/OpenSearchContainer.php index a6d2b84..90e1f17 100644 --- a/src/Container/OpenSearchContainer.php +++ b/src/Container/OpenSearchContainer.php @@ -4,45 +4,12 @@ declare(strict_types=1); namespace Testcontainers\Container; -use Testcontainers\Wait\WaitForHttp; -use Testcontainers\Wait\WaitForLog; - -class OpenSearchContainer extends GenericContainer +/** + * Left for namespace backward compatibility + * @deprecated Use \Testcontainers\Modules\OpenSearchContainer instead. + * TODO: Remove in next major release. + */ +class OpenSearchContainer extends \Testcontainers\Modules\OpenSearchContainer { - public function __construct(string $version = 'latest') - { - parent::__construct('opensearchproject/opensearch:' . $version); - $this->withExposedPorts(9200); - $this->withEnvironment('discovery.type', 'single-node'); - $this->withEnvironment('OPENSEARCH_INITIAL_ADMIN_PASSWORD', 'c3o_ZPHo!'); - $this->withWait(new WaitForLog( - '/\]\s+started\?\[/', - true, - 30000 - )); - } - /** - * @deprecated Use constructor instead - * Left for backward compatibility - */ - public static function make(string $version = 'latest'): self - { - return new self($version); - } - - public function withDisabledSecurityPlugin(): self - { - $this->withEnvironment('plugins.security.disabled', 'true'); - - return $this; - } - - /** - * @deprecated Use withDisabledSecurityPlugin instead - */ - public function disableSecurityPlugin(): self - { - return $this->withDisabledSecurityPlugin(); - } -} +} \ No newline at end of file diff --git a/src/Container/PostgresContainer.php b/src/Container/PostgresContainer.php index 705f3b6..6e6e3e1 100644 --- a/src/Container/PostgresContainer.php +++ b/src/Container/PostgresContainer.php @@ -4,47 +4,12 @@ declare(strict_types=1); namespace Testcontainers\Container; -use Testcontainers\Wait\WaitForExec; - -class PostgresContainer extends GenericContainer +/** + * Left for namespace backward compatibility + * @deprecated Use \Testcontainers\Modules\PostgresContainer instead. + * TODO: Remove in next major release. + */ +class PostgresContainer extends \Testcontainers\Modules\PostgresContainer { - public function __construct( - string $version = 'latest', - public readonly string $username = 'test', - public readonly string $password = 'test', - public readonly string $database = 'test' - ) { - parent::__construct('postgres:' . $version); - $this->withExposedPorts(5432); - $this->withEnvironment('POSTGRES_USER', $this->username); - $this->withEnvironment('POSTGRES_PASSWORD', $this->password); - $this->withEnvironment('POSTGRES_DB', $this->database); - $this->withWait(new WaitForExec(["pg_isready", "-h", "127.0.0.1", "-U", $this->username])); - } - /** - * @deprecated Use constructor instead - * Left for backward compatibility - */ - public static function make(string $version = 'latest', string $dbPassword = 'root'): self - { - return new self( - version: $version, - password: $dbPassword - ); - } - - public function withPostgresUser(string $username): self - { - $this->withEnvironment('POSTGRES_USER', $username); - - return $this; - } - - public function withPostgresDatabase(string $database): self - { - $this->withEnvironment('POSTGRES_DB', $database); - - return $this; - } -} +} \ No newline at end of file diff --git a/src/Container/RedisContainer.php b/src/Container/RedisContainer.php index d568dd8..aa2f0da 100644 --- a/src/Container/RedisContainer.php +++ b/src/Container/RedisContainer.php @@ -4,23 +4,12 @@ declare(strict_types=1); namespace Testcontainers\Container; -use Testcontainers\Wait\WaitForLog; - -class RedisContainer extends GenericContainer +/** + * Left for namespace backward compatibility + * @deprecated Use \Testcontainers\Modules\RedisContainer instead. + * TODO: Remove in next major release. + */ +class RedisContainer extends \Testcontainers\Modules\RedisContainer { - public function __construct(string $version = 'latest') - { - parent::__construct('redis:' . $version); - $this->withExposedPorts(6379); - $this->withWait(new WaitForLog('Ready to accept connections')); - } - /** - * @deprecated Use constructor instead - * Left for backward compatibility - */ - public static function make(string $version = 'latest'): self - { - return new self($version); - } -} +} \ No newline at end of file diff --git a/src/Container/StartedGenericContainer.php b/src/Container/StartedGenericContainer.php new file mode 100644 index 0000000..5760eab --- /dev/null +++ b/src/Container/StartedGenericContainer.php @@ -0,0 +1,152 @@ +dockerClient = DockerContainerClient::getDockerClient(); + } + + + public function getId(): string + { + return $this->id; + } + + public function getLastExecId(): ?string + { + return $this->lastExecId; + } + + public function getClient(): Docker + { + return $this->dockerClient; + } + + /** + * @param list $command + */ + public function exec(array $command): string + { + $execConfig = (new ContainersIdExecPostBody()) + ->setCmd($command) + ->setAttachStdout(true) + ->setAttachStderr(true); + + // Create and start the exec command + /** @var IdResponse | null $exec */ + $exec = $this->dockerClient->containerExec($this->id, $execConfig); + + if($exec === null || $exec->getId() === null) { + throw new \RuntimeException('Failed to create exec command'); + } + + $this->lastExecId = $exec->getId(); + + $contents = $this->dockerClient + ->execStart($this->lastExecId, null, Client::FETCH_RESPONSE) + ?->getBody() + ->getContents() ?? ''; + + return preg_replace('/[\x00-\x1F\x7F]/u', '', $contents) ?? ''; + } + + public function stop(): StoppedTestContainer + { + $this->dockerClient->containerStop($this->id); + $this->dockerClient->containerDelete($this->id); + + return new StoppedGenericContainer($this->id); + } + + public function restart(): self + { + $this->dockerClient->containerRestart($this->id); + + return $this; + } + + public function logs(): string + { + $output = $this->dockerClient + ->containerLogs( + $this->id, + ['stdout' => true, 'stderr' => true], + DockerRuntimeClient::FETCH_RESPONSE + ) + ?->getBody() + ->getContents() ?? ''; + + return preg_replace('/[\x00-\x1F\x7F]/u', '', mb_convert_encoding($output, 'UTF-8', 'UTF-8')) ?? ''; + } + + //TODO: replace with the proper implementation + public function getHost(): string + { + return '127.0.0.1'; + } + + //TODO: not ready yet + public function getMappedPort(int $port): int + { + return $this->inspect()->ports[$port]; + } + + //TODO: not ready yet + public function getFirstMappedPort(): int + { + $containerInspectResponse = $this->dockerClient->containerInspect($this->id); + $settings = $containerInspectResponse->getNetworkSettings(); + + $ports = (array)$settings->getPorts(); + $port = array_key_first($ports); + + return (int) $ports[$port][0]->getHostPort(); + } + + public function getName(): string + { + // TODO: Implement getName() method. + return ''; + } + + public function getLabels(): array + { + // TODO: Implement getLabels() method. + return []; + } + + + public function getNetworkNames(): array + { + // TODO: Implement getNetworkNames() method. + return []; + } + + public function getNetworkId(string $networkName): string + { + // TODO: Implement getNetworkId() method. + return ''; + } + + public function getIpAddress(string $networkName): string + { + // TODO: Implement getIpAddress() method. + return ''; + } +} diff --git a/src/Container/StartedTestContainer.php b/src/Container/StartedTestContainer.php new file mode 100644 index 0000000..8eefcdf --- /dev/null +++ b/src/Container/StartedTestContainer.php @@ -0,0 +1,43 @@ + $command + */ + public function exec(array $command): string; + + public function logs(): string; +} diff --git a/src/Container/StoppedGenericContainer.php b/src/Container/StoppedGenericContainer.php new file mode 100644 index 0000000..f226c8c --- /dev/null +++ b/src/Container/StoppedGenericContainer.php @@ -0,0 +1,17 @@ +id; + } +} diff --git a/src/Container/StoppedTestContainer.php b/src/Container/StoppedTestContainer.php new file mode 100644 index 0000000..423c230 --- /dev/null +++ b/src/Container/StoppedTestContainer.php @@ -0,0 +1,10 @@ +|string $env + */ + public function withEnvironment(array | string $env, ?string $value): static; + + /** + * @param array $command + */ + public function withCommand(array $command): static; + + public function withEntrypoint(string $entryPoint): static; + + /** @param int|string|array $ports One or more ports to expose. */ + public function withExposedPorts(...$ports): static; + + public function withWait(WaitStrategy $waitStrategy): static; + + public function withNetwork(string $networkName): static; + + public function withPrivilegedMode(): static; +} diff --git a/src/ContainerRuntime/ContainerRuntimeClient.php b/src/ContainerClient/DockerContainerClient.php similarity index 93% rename from src/ContainerRuntime/ContainerRuntimeClient.php rename to src/ContainerClient/DockerContainerClient.php index 5728217..84bf645 100644 --- a/src/ContainerRuntime/ContainerRuntimeClient.php +++ b/src/ContainerClient/DockerContainerClient.php @@ -1,10 +1,10 @@ withExposedPorts(3306); + $this->withWait(new WaitForLog('ready for connections')); + $this->withEnvironment('MARIADB_ROOT_PASSWORD', $mysqlRootPassword); + } + + /** + * @deprecated Use constructor instead + * Left for backward compatibility + */ + public static function make(string $version = 'latest', string $mysqlRootPassword = 'root'): self + { + return new self($version, $mysqlRootPassword); + } + + public function withMariaDBUser(string $username, string $password): self + { + $this->withEnvironment('MARIADB_USER', $username); + $this->withEnvironment('MARIADB_PASSWORD', $password); + + return $this; + } + + public function withMariaDBDatabase(string $database): self + { + $this->withEnvironment('MARIADB_DATABASE', $database); + + return $this; + } +} diff --git a/src/Modules/MySQLContainer.php b/src/Modules/MySQLContainer.php new file mode 100644 index 0000000..fcef5bb --- /dev/null +++ b/src/Modules/MySQLContainer.php @@ -0,0 +1,43 @@ +withExposedPorts(3306); + $this->withEnvironment('MYSQL_ROOT_PASSWORD', $mysqlRootPassword); + $this->withWait(new WaitForLog('ready for connections')); + } + + /** + * @deprecated Use constructor instead + * Left for backward compatibility + */ + public static function make(string $version = 'latest', string $mysqlRootPassword = 'root'): self + { + return new self($version, $mysqlRootPassword); + } + + public function withMySQLUser(string $username, string $password): self + { + $this->withEnvironment('MYSQL_USER', $username); + $this->withEnvironment('MYSQL_PASSWORD', $password); + + return $this; + } + + public function withMySQLDatabase(string $database): self + { + $this->withEnvironment('MYSQL_DATABASE', $database); + + return $this; + } +} diff --git a/src/Modules/OpenSearchContainer.php b/src/Modules/OpenSearchContainer.php new file mode 100644 index 0000000..24d3f7d --- /dev/null +++ b/src/Modules/OpenSearchContainer.php @@ -0,0 +1,48 @@ +withExposedPorts(9200); + $this->withEnvironment('discovery.type', 'single-node'); + $this->withEnvironment('OPENSEARCH_INITIAL_ADMIN_PASSWORD', 'c3o_ZPHo!'); + $this->withWait(new WaitForLog( + '/\]\s+started\?\[/', + true, + 30000 + )); + } + + /** + * @deprecated Use constructor instead + * Left for backward compatibility + */ + public static function make(string $version = 'latest'): self + { + return new self($version); + } + + public function withDisabledSecurityPlugin(): self + { + $this->withEnvironment('plugins.security.disabled', 'true'); + + return $this; + } + + /** + * @deprecated Use withDisabledSecurityPlugin instead + */ + public function disableSecurityPlugin(): self + { + return $this->withDisabledSecurityPlugin(); + } +} diff --git a/src/Modules/PostgresContainer.php b/src/Modules/PostgresContainer.php new file mode 100644 index 0000000..8c30903 --- /dev/null +++ b/src/Modules/PostgresContainer.php @@ -0,0 +1,51 @@ +withExposedPorts(5432); + $this->withEnvironment('POSTGRES_USER', $this->username); + $this->withEnvironment('POSTGRES_PASSWORD', $this->password); + $this->withEnvironment('POSTGRES_DB', $this->database); + $this->withWait(new WaitForExec(["pg_isready", "-h", "127.0.0.1", "-U", $this->username])); + } + + /** + * @deprecated Use constructor instead + * Left for backward compatibility + */ + public static function make(string $version = 'latest', string $dbPassword = 'root'): self + { + return new self( + version: $version, + password: $dbPassword + ); + } + + public function withPostgresUser(string $username): self + { + $this->withEnvironment('POSTGRES_USER', $username); + + return $this; + } + + public function withPostgresDatabase(string $database): self + { + $this->withEnvironment('POSTGRES_DB', $database); + + return $this; + } +} diff --git a/src/Modules/RedisContainer.php b/src/Modules/RedisContainer.php new file mode 100644 index 0000000..a31dde9 --- /dev/null +++ b/src/Modules/RedisContainer.php @@ -0,0 +1,27 @@ +withExposedPorts(6379); + $this->withWait(new WaitForLog('Ready to accept connections')); + } + + /** + * @deprecated Use constructor instead + * Left for backward compatibility + */ + public static function make(string $version = 'latest'): self + { + return new self($version); + } +} diff --git a/src/Utils/PortGenerator/FixedPortGenerator.php b/src/Utils/PortGenerator/FixedPortGenerator.php new file mode 100644 index 0000000..9afe0b7 --- /dev/null +++ b/src/Utils/PortGenerator/FixedPortGenerator.php @@ -0,0 +1,25 @@ +ports[$this->portIndex])) { + throw new \RuntimeException('No more ports available in the fixed list.'); + } + + return $this->ports[$this->portIndex++]; + } +} diff --git a/src/Utils/PortGenerator/PortGenerator.php b/src/Utils/PortGenerator/PortGenerator.php new file mode 100644 index 0000000..df7da1a --- /dev/null +++ b/src/Utils/PortGenerator/PortGenerator.php @@ -0,0 +1,10 @@ +getRandomPort($this->randomBetweenInclusive(10000, 65535)); + } + + private function randomBetweenInclusive(int $min, int $max): int + { + return random_int($min, $max); + } + + private function getRandomPort(int $port): int + { + $connection = @fsockopen("localhost", $port); + + if (is_resource($connection)) { + fclose($connection); + throw new \RuntimeException("Port $port is already in use."); + } + + return $port; + } +} \ No newline at end of file diff --git a/src/Utils/PortGenerator/RandomUniquePortGenerator.php b/src/Utils/PortGenerator/RandomUniquePortGenerator.php new file mode 100644 index 0000000..f405e25 --- /dev/null +++ b/src/Utils/PortGenerator/RandomUniquePortGenerator.php @@ -0,0 +1,26 @@ +portGenerator->generatePort(); + } while (in_array($port, self::$assignedPorts)); + + self::$assignedPorts[] = $port; + + return $port; + } +} diff --git a/src/Wait/BaseWait.php b/src/Wait/BaseWait.php deleted file mode 100644 index b9b607a..0000000 --- a/src/Wait/BaseWait.php +++ /dev/null @@ -1,20 +0,0 @@ -dockerClient = ContainerRuntimeClient::getDockerClient(); - } - - abstract public function wait(string $id): void; -} diff --git a/src/Wait/BaseWaitStrategy.php b/src/Wait/BaseWaitStrategy.php new file mode 100644 index 0000000..432aedf --- /dev/null +++ b/src/Wait/BaseWaitStrategy.php @@ -0,0 +1,17 @@ +getId(); $startTime = microtime(true) * 1000; while (true) { @@ -25,7 +27,7 @@ class WaitForContainerRunning extends BaseWait } /** @var ContainersIdJsonGetResponse200 | null $containerInspect */ - $containerInspect = $this->dockerClient->containerInspect($id); + $containerInspect = $container->getClient()->containerInspect($id); $containerStatus = $containerInspect?->getState()?->getStatus(); if ($containerStatus === 'running') { diff --git a/src/Wait/WaitForExec.php b/src/Wait/WaitForExec.php index 6d8aea9..044471c 100644 --- a/src/Wait/WaitForExec.php +++ b/src/Wait/WaitForExec.php @@ -5,14 +5,15 @@ declare(strict_types=1); namespace Testcontainers\Wait; use Closure; -use Docker\API\Client; use Docker\API\Model\ContainersIdExecPostBody; +use Docker\API\Model\ExecIdJsonGetResponse200; +use Testcontainers\Container\StartedTestContainer; use Testcontainers\Exception\ContainerWaitingTimeoutException; /** * Uses $timout and $pollInterval in milliseconds to set the parameters for waiting. */ -class WaitForExec extends BaseWait +class WaitForExec extends BaseWaitStrategy { protected ContainersIdExecPostBody $execConfig; @@ -28,32 +29,23 @@ class WaitForExec extends BaseWait parent::__construct($timeout, $pollInterval); } - public function wait(string $id): void + public function wait(StartedTestContainer $container): void { - $this->execConfig = (new ContainersIdExecPostBody()) - ->setCmd($this->command) - ->setAttachStdout(true) - ->setAttachStderr(true); - $startTime = microtime(true) * 1000; while (true) { $elapsedTime = (microtime(true) * 1000) - $startTime; if ($elapsedTime > $this->timeout) { - throw new ContainerWaitingTimeoutException($id); + throw new ContainerWaitingTimeoutException($container->getId()); } - // Create and start the exec command - $exec = $this->dockerClient->containerExec($id, $this->execConfig); - $contents = $this->dockerClient - ->execStart($exec->getId(), null, Client::FETCH_RESPONSE) - ?->getBody() - ->getContents() ?? ''; + $contents = $container->exec($this->command); // Inspect the exec to check the exit code - $execInspect = $this->dockerClient->execInspect($exec->getId()); - $exitCode = $execInspect->getExitCode(); + /** @var ExecIdJsonGetResponse200 | null $execInspect */ + $execInspect = $container->getClient()->execInspect($container->getLastExecId() ?? ''); + $exitCode = $execInspect?->getExitCode(); // If a custom check function is provided, use it to validate the command output if ($this->checkFunction !== null) { diff --git a/src/Wait/WaitForHealthCheck.php b/src/Wait/WaitForHealthCheck.php index 995b99d..4a0c9c8 100644 --- a/src/Wait/WaitForHealthCheck.php +++ b/src/Wait/WaitForHealthCheck.php @@ -5,25 +5,18 @@ declare(strict_types=1); namespace Testcontainers\Wait; use Docker\Docker; -use Docker\DockerClientFactory; use Http\Client\Socket\Exception\TimeoutException; -use Testcontainers\ContainerRuntime\ContainerRuntimeClient; +use Testcontainers\Container\StartedTestContainer; use Testcontainers\Exception\ContainerNotReadyException; -class WaitForHealthCheck implements WaitInterface +class WaitForHealthCheck extends BaseWaitStrategy { - protected Docker $dockerClient; - protected int $timeout; - protected int $pollInterval; - - public function __construct(int $timeout = 5000, int $pollInterval = 1000) + public function __construct(protected int $timeout = 5000, protected int $pollInterval = 1000) { - $this->dockerClient = ContainerRuntimeClient::getDockerClient(); - $this->timeout = $timeout; - $this->pollInterval = $pollInterval; + parent::__construct($timeout, $pollInterval); } - public function wait(string $id): void + public function wait(StartedTestContainer $container): void { $startTime = microtime(true) * 1000; @@ -34,10 +27,11 @@ class WaitForHealthCheck implements WaitInterface throw new TimeoutException(sprintf("Health check not healthy after %d ms", $this->timeout)); } - $containerInspect = $this->dockerClient->containerInspect($id, [], Docker::FETCH_RESPONSE); + /** @var \Psr\Http\Message\ResponseInterface | null $containerInspect */ + $containerInspect = $container->getClient()->containerInspect($container->getId(), [], Docker::FETCH_RESPONSE); //$containerStatus = $containerInspect?->getArrayCopy() ?? null; var_dump($containerInspect->getBody()->getContents()); - $containerStatus=''; + $containerStatus = ''; if ($containerStatus === 'healthy') { return; } diff --git a/src/Wait/WaitForHttp.php b/src/Wait/WaitForHttp.php index f8927e9..906dc8b 100644 --- a/src/Wait/WaitForHttp.php +++ b/src/Wait/WaitForHttp.php @@ -7,7 +7,7 @@ namespace Testcontainers\Wait; use Docker\Docker; use Testcontainers\Exception\ContainerNotReadyException; -class WaitForHttp implements WaitInterface +class WaitForHttp implements WaitStrategy { public const METHOD_GET = 'GET'; public const METHOD_POST = 'POST'; diff --git a/src/Wait/WaitForLog.php b/src/Wait/WaitForLog.php index c8ba18c..a7ff614 100644 --- a/src/Wait/WaitForLog.php +++ b/src/Wait/WaitForLog.php @@ -4,13 +4,13 @@ declare(strict_types=1); namespace Testcontainers\Wait; -use Docker\API\Runtime\Client\Client; +use Testcontainers\Container\StartedTestContainer; use Testcontainers\Exception\ContainerWaitingTimeoutException; /** * Uses $timout and $pollInterval in milliseconds to set the parameters for waiting. */ -class WaitForLog extends BaseWait +class WaitForLog extends BaseWaitStrategy { public function __construct( protected string $message, @@ -21,7 +21,7 @@ class WaitForLog extends BaseWait parent::__construct($timeout, $pollInterval); } - public function wait(string $id): void + public function wait(StartedTestContainer $container): void { $startTime = microtime(true) * 1000; @@ -29,15 +29,10 @@ class WaitForLog extends BaseWait $elapsedTime = (microtime(true) * 1000) - $startTime; if ($elapsedTime > $this->timeout) { - throw new ContainerWaitingTimeoutException($id); + throw new ContainerWaitingTimeoutException($container->getId()); } - $output = $this->dockerClient - ->containerLogs($id, ['stdout' => true, 'stderr' => true], Client::FETCH_RESPONSE) - ?->getBody() - ->getContents() ?? ''; - - $output = preg_replace('/[\x00-\x1F\x7F]/u', '', mb_convert_encoding($output, 'UTF-8', 'UTF-8')) ?? ''; + $output = $container->logs(); if ($this->enableRegex) { if (preg_match($this->message, $output)) { diff --git a/src/Wait/WaitForTcpPortOpen.php b/src/Wait/WaitForTcpPortOpen.php index 6fea945..e6c3700 100644 --- a/src/Wait/WaitForTcpPortOpen.php +++ b/src/Wait/WaitForTcpPortOpen.php @@ -9,7 +9,7 @@ use JsonException; use RuntimeException; use Testcontainers\Exception\ContainerNotReadyException; -final class WaitForTcpPortOpen implements WaitInterface +final class WaitForTcpPortOpen implements WaitStrategy { private Docker $dockerClient; diff --git a/src/Wait/WaitInterface.php b/src/Wait/WaitInterface.php deleted file mode 100644 index 4e75ce5..0000000 --- a/src/Wait/WaitInterface.php +++ /dev/null @@ -1,10 +0,0 @@ -remove(); + self::$container->stop(); } } diff --git a/tests/Integration/MariaDBContainerTest.php b/tests/Integration/MariaDBContainerTest.php index f1da8c8..b2ad631 100644 --- a/tests/Integration/MariaDBContainerTest.php +++ b/tests/Integration/MariaDBContainerTest.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace Testcontainers\Tests\Integration; -use Testcontainers\Container\MariaDBContainer; +use Testcontainers\Modules\MariaDBContainer; class MariaDBContainerTest extends ContainerTestCase { @@ -19,7 +19,11 @@ class MariaDBContainerTest extends ContainerTestCase public function testMariaDBContainer(): void { $pdo = new \PDO( - sprintf('mysql:host=%s;port=3306', self::$container->getAddress()), + sprintf( + 'mysql:host=%s;port=%d', + self::$container->getHost(), + self::$container->getFirstMappedPort() + ), 'bar', 'baz', ); diff --git a/tests/Integration/MySQLContainerTest.php b/tests/Integration/MySQLContainerTest.php index 51c98e3..c88f911 100644 --- a/tests/Integration/MySQLContainerTest.php +++ b/tests/Integration/MySQLContainerTest.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace Testcontainers\Tests\Integration; -use Testcontainers\Container\MySQLContainer; +use Testcontainers\Modules\MySQLContainer; class MySQLContainerTest extends ContainerTestCase { @@ -19,7 +19,11 @@ class MySQLContainerTest extends ContainerTestCase public function testMySQLContainer(): void { $pdo = new \PDO( - sprintf('mysql:host=%s;port=3306', '127.0.0.1'), + sprintf( + 'mysql:host=%s;port=%d', + self::$container->getHost(), + self::$container->getFirstMappedPort() + ), 'bar', 'baz', ); diff --git a/tests/Integration/OldTests/ContainerTest.php b/tests/Integration/OldTests/ContainerTest.php new file mode 100644 index 0000000..f13f163 --- /dev/null +++ b/tests/Integration/OldTests/ContainerTest.php @@ -0,0 +1,139 @@ +withMySQLDatabase('foo'); + $container->withMySQLUser('bar', 'baz'); + + $container->run(); + + $pdo = new \PDO( + sprintf('mysql:host=%s;port=3306', $container->getAddress()), + 'bar', + 'baz', + ); + + $query = $pdo->query('SHOW databases'); + + $this->assertInstanceOf(\PDOStatement::class, $query); + + $databases = $query->fetchAll(\PDO::FETCH_COLUMN); + + $this->assertContains('foo', $databases); + + $container->stop(); + } + + public function testMariaDB(): void + { + $container = MariaDBContainer::make(); + $container->withMariaDBDatabase('foo'); + $container->withMariaDBUser('bar', 'baz'); + + $container->run(); + + $pdo = new \PDO( + sprintf('mysql:host=%s;port=3306', $container->getAddress()), + 'bar', + 'baz', + ); + + $query = $pdo->query('SHOW databases'); + + $this->assertInstanceOf(\PDOStatement::class, $query); + + $databases = $query->fetchAll(\PDO::FETCH_COLUMN); + + $this->assertContains('foo', $databases); + + $container->stop(); + } + + public function testRedis(): void + { + $container = RedisContainer::make(); + + $container->run(); + + $redis = new Client([ + 'scheme' => 'tcp', + 'host' => $container->getAddress(), + 'port' => 6379, + ]); + + $redis->ping(); + + $this->assertTrue($redis->isConnected()); + + $container->stop(); + } + + /** + * @throws \JsonException + */ + public function testOpenSearch(): void + { + $container = OpenSearchContainer::make(); + $container->disableSecurityPlugin(); + + $container->run(); + + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, sprintf('http://%s:%d', $container->getAddress(), 9200)); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + + $response = (string) curl_exec($ch); + + $this->assertNotEmpty($response); + + /** @var array{cluster_name: string} $data */ + $data = json_decode($response, true, JSON_THROW_ON_ERROR, JSON_THROW_ON_ERROR); + + $this->assertArrayHasKey('cluster_name', $data); + + $this->assertEquals('docker-cluster', $data['cluster_name']); + } + + public function testPostgreSQLContainer(): void + { + $container = PostgresContainer::make('latest', 'test') + ->withPostgresUser('test') + ->withPostgresDatabase('foo') + ->run(); + + + $pdo = new \PDO( + sprintf('pgsql:host=%s;port=5432;dbname=foo', $container->getAddress()), + 'test', + 'test', + ); + + $query = $pdo->query('SELECT datname FROM pg_database'); + + $this->assertInstanceOf(\PDOStatement::class, $query); + + $databases = $query->fetchAll(\PDO::FETCH_COLUMN); + + $this->assertContains('foo', $databases); + + $container->stop(); + } +} diff --git a/tests/Integration/OldTests/WaitStrategyTest.php b/tests/Integration/OldTests/WaitStrategyTest.php new file mode 100644 index 0000000..fab22a2 --- /dev/null +++ b/tests/Integration/OldTests/WaitStrategyTest.php @@ -0,0 +1,151 @@ +withEnvironment('MYSQL_ROOT_PASSWORD', 'root') + ->withWait( + new WaitForExec([ + 'mysqladmin', 'ping', + '-h', '127.0.0.1', + ]) + ); + + $container->run(); + + $pdo = new \PDO( + sprintf('mysql:host=%s;port=3306', $container->getAddress()), + 'root', + 'root' + ); + + $query = $pdo->query('select version()'); + + $this->assertInstanceOf(\PDOStatement::class, $query); + + $version = $query->fetchColumn(); + + $this->assertNotEmpty($version); + } + + // public function testWaitForLog(): void + // { + // $container = GenericContainer::make('redis:6.2.5') + // ->withWait(new WaitForLog('Ready to accept connections')); + // + // $container->run(); + // + // $redis = new Client([ + // 'scheme' => 'tcp', + // 'host' => $container->getAddress(), + // 'port' => 6379, + // ]); + // + // $redis->set('foo', 'bar'); + // + // $this->assertEquals('bar', $redis->get('foo')); + // + // $container->stop(); + // + // $this->expectException(ConnectionException::class); + // + // $redis->get('foo'); + // + // $container->remove(); + // } + // + // public function testWaitForHTTP(): void + // { + // $container = GenericContainer::make('nginx:alpine') + // ->withWait(WaitForHttp::make(80)); + // + // $container->run(); + // + // $ch = curl_init(); + // curl_setopt($ch, CURLOPT_URL, sprintf('http://%s:%d', $container->getAddress(), 80)); + // curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + // + // $response = (string) curl_exec($ch); + // + // curl_close($ch); + // + // $this->assertNotEmpty($response); + // } + // + // /** + // * @dataProvider provideWaitForTcpPortOpen + // */ + // public function testWaitForTcpPortOpen(bool $wait): void + // { + // $container = GenericContainer::make('nginx:alpine'); + // + // if ($wait) { + // $container->withWait(WaitForTcpPortOpen::make(80)); + // } + // + // $container->run(); + // + // if ($wait) { + // static::assertIsResource(fsockopen($container->getAddress(), 80), 'Failed to connect to container'); + // return; + // } + // + // $containerId = $container->getId(); + // + // $this->expectExceptionObject(new ContainerNotReadyException($containerId)); + // + // (new WaitForTcpPortOpen(8080))->wait($containerId); + // } + // + // /** + // * @return array> + // */ + // public function provideWaitForTcpPortOpen(): array + // { + // return [ + // 'Can connect to container' => [true], + // 'Cannot connect to container' => [false], + // ]; + // } + // + // public function testWaitForHealthCheck(): void + // { + // $container = GenericContainer::make('nginx') + // ->withHealthCheckCommand('curl --fail http://localhost') + // ->withWait(new WaitForHealthCheck()); + // + // $container->run(); + // + // $ch = curl_init(); + // + // curl_setopt($ch, CURLOPT_URL, sprintf('http://%s:%d', $container->getAddress(), 80)); + // curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + // + // $response = curl_exec($ch); + // + // $this->assertNotEmpty($response); + // $this->assertIsString($response); + // + // $this->assertStringContainsString('Welcome to nginx!', $response); + // } +} diff --git a/tests/Integration/OpenSearchContainerTest.php b/tests/Integration/OpenSearchContainerTest.php index 8e8db0d..19c34aa 100644 --- a/tests/Integration/OpenSearchContainerTest.php +++ b/tests/Integration/OpenSearchContainerTest.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace Testcontainers\Tests\Integration; -use Testcontainers\Container\OpenSearchContainer; +use Testcontainers\Modules\OpenSearchContainer; class OpenSearchContainerTest extends ContainerTestCase { @@ -21,7 +21,11 @@ class OpenSearchContainerTest extends ContainerTestCase public function testOpenSearch(): void { $ch = curl_init(); - curl_setopt($ch, CURLOPT_URL, sprintf('http://%s:%d', '127.0.0.1', 9200)); + curl_setopt($ch, CURLOPT_URL, sprintf( + 'http://%s:%d', + self::$container->getHost(), + self::$container->getFirstMappedPort() + )); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); $response = (string) curl_exec($ch); diff --git a/tests/Integration/PostgreSQLContainerTest.php b/tests/Integration/PostgreSQLContainerTest.php index 2b6bd70..e1f81c5 100644 --- a/tests/Integration/PostgreSQLContainerTest.php +++ b/tests/Integration/PostgreSQLContainerTest.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace Testcontainers\Tests\Integration; -use Testcontainers\Container\PostgresContainer; +use Testcontainers\Modules\PostgresContainer; class PostgreSQLContainerTest extends ContainerTestCase { @@ -19,7 +19,11 @@ class PostgreSQLContainerTest extends ContainerTestCase public function testPostgreSQLContainer(): void { $pdo = new \PDO( - 'pgsql:host=127.0.0.1;port=5432;dbname=foo', + sprintf( + 'pgsql:host=%s;port=%d;dbname=foo', + self::$container->getHost(), + self::$container->getFirstMappedPort() + ), 'bar', 'test', ); diff --git a/tests/Integration/RedisContainerTest.php b/tests/Integration/RedisContainerTest.php index 08dec78..0379dce 100644 --- a/tests/Integration/RedisContainerTest.php +++ b/tests/Integration/RedisContainerTest.php @@ -5,7 +5,7 @@ declare(strict_types=1); namespace Testcontainers\Tests\Integration; use Predis\Client; -use Testcontainers\Container\RedisContainer; +use Testcontainers\Modules\RedisContainer; class RedisContainerTest extends ContainerTestCase { @@ -18,8 +18,8 @@ class RedisContainerTest extends ContainerTestCase public function testRedisContainer(): void { $redisClient = new Client([ - 'host' => 'localhost', - 'port' => 6379, + 'host' => self::$container->getHost(), + 'port' => self::$container->getFirstMappedPort(), ]); $redisClient->ping(); diff --git a/tests/Integration/WaitStrategyTest.php b/tests/Integration/WaitStrategyTest.php deleted file mode 100644 index 7bdbd3d..0000000 --- a/tests/Integration/WaitStrategyTest.php +++ /dev/null @@ -1,157 +0,0 @@ -withEnvironment('MYSQL_ROOT_PASSWORD', 'root') - ->withWait(new WaitForExec(['mysqladmin', 'ping', '-h', '127.0.0.1'], function (Process $process) use (&$called) { - $called = true; - })); - - $container->run(); - - $this->assertTrue($called, 'Wait function was not called'); - unset($called); - - $pdo = new \PDO( - sprintf('mysql:host=%s;port=3306', $container->getAddress()), - 'root', - 'root' - ); - - $query = $pdo->query('select version()'); - - $this->assertInstanceOf(\PDOStatement::class, $query); - - $version = $query->fetchColumn(); - - $this->assertNotEmpty($version); - } - - public function testWaitForLog(): void - { - $container = GenericContainer::make('redis:6.2.5') - ->withWait(new WaitForLog('Ready to accept connections')); - - $container->run(); - - $redis = new Client([ - 'scheme' => 'tcp', - 'host' => $container->getAddress(), - 'port' => 6379, - ]); - - $redis->set('foo', 'bar'); - - $this->assertEquals('bar', $redis->get('foo')); - - $container->stop(); - - $this->expectException(ConnectionException::class); - - $redis->get('foo'); - - $container->remove(); - } - - public function testWaitForHTTP(): void - { - $container = GenericContainer::make('nginx:alpine') - ->withWait(WaitForHttp::make(80)); - - $container->run(); - - $ch = curl_init(); - curl_setopt($ch, CURLOPT_URL, sprintf('http://%s:%d', $container->getAddress(), 80)); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - - $response = (string) curl_exec($ch); - - curl_close($ch); - - $this->assertNotEmpty($response); - } - - /** - * @dataProvider provideWaitForTcpPortOpen - */ - public function testWaitForTcpPortOpen(bool $wait): void - { - $container = GenericContainer::make('nginx:alpine'); - - if ($wait) { - $container->withWait(WaitForTcpPortOpen::make(80)); - } - - $container->run(); - - if ($wait) { - static::assertIsResource(fsockopen($container->getAddress(), 80), 'Failed to connect to container'); - return; - } - - $containerId = $container->getId(); - - $this->expectExceptionObject(new ContainerNotReadyException($containerId)); - - (new WaitForTcpPortOpen(8080))->wait($containerId); - } - - /** - * @return array> - */ - public function provideWaitForTcpPortOpen(): array - { - return [ - 'Can connect to container' => [true], - 'Cannot connect to container' => [false], - ]; - } - - public function testWaitForHealthCheck(): void - { - $container = GenericContainer::make('nginx') - ->withHealthCheckCommand('curl --fail http://localhost') - ->withWait(new WaitForHealthCheck()); - - $container->run(); - - $ch = curl_init(); - - curl_setopt($ch, CURLOPT_URL, sprintf('http://%s:%d', $container->getAddress(), 80)); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - - $response = curl_exec($ch); - - $this->assertNotEmpty($response); - $this->assertIsString($response); - - $this->assertStringContainsString('Welcome to nginx!', $response); - } -} From a10484e7f5bc2667bdb6061b3e200f6e15663d3c Mon Sep 17 00:00:00 2001 From: Sergei Shitikov Date: Fri, 6 Sep 2024 20:02:48 +0200 Subject: [PATCH 13/27] Update deps and README + some cleanup --- README.md | 91 +++++--- composer.json | 2 +- src/Container/Container.php | 2 +- src/Container/MariaDBContainer.php | 34 ++- src/Container/MySQLContainer.php | 34 ++- src/Container/OpenSearchContainer.php | 41 +++- src/Container/PostgresContainer.php | 44 +++- src/Container/RedisContainer.php | 20 +- src/Modules/PostgresContainer.php | 19 +- src/Wait/WaitForHealthCheck.php | 1 - tests/Integration/OldTests/ContainerTest.php | 16 +- .../Integration/OldTests/WaitStrategyTest.php | 210 +++++++++--------- 12 files changed, 348 insertions(+), 166 deletions(-) diff --git a/README.md b/README.md index 22cf3e0..c5fad87 100644 --- a/README.md +++ b/README.md @@ -19,10 +19,13 @@ composer req --dev testcontainers/testcontainers use Testcontainers\Container\GenericContainer; -$container = new GenericContainer::make('nginx:alpine'); +$container = new GenericContainer('nginx:alpine'); // set an environment variable -$container->withEnvironment('name', 'var'); +$container->withEnvironment([ + 'key1' => 'val1', + 'key2' => 'val2' +]); // enable health check for an container $container->withHealthCheckCommand('curl --fail localhost'); @@ -35,10 +38,18 @@ Normally you have to wait until the Container is ready. so for this you can defi ```php +use Testcontainers\Container\GenericContainer; +use Testcontainers\Wait\WaitForExec; +use Testcontainers\Wait\WaitForLog; +use Testcontainers\Wait\WaitForHttp; +use Testcontainers\Wait\WaitForHealthCheck; + +$container = new GenericContainer('nginx:alpine'); + // Run mysqladmin ping until the command returns exit code 0 $container->withWait(new WaitForExec(['mysqladmin', 'ping', '-h', '127.0.0.1'])); -$container->withWait(new WaitForExec(['mysqladmin', 'ping', '-h', '127.0.0.1']), function(Process $process) { +$container->withWait(new WaitForExec(['mysqladmin', 'ping', '-h', '127.0.0.1']), function($exitCode, $contents) { // throw exception if process result is bad }); @@ -60,14 +71,17 @@ $container->withWait(new WaitForHealthCheck()); use Testcontainers\Modules\MySQLContainer; -$container = MySQLContainer::make('8.0'); -$container->withMySQLDatabase('foo'); -$container->withMySQLUser('bar', 'baz'); - -$container->run(); +$container = (new MySQLContainer('8.0')) + ->withMySQLDatabase('foo') + ->withMySQLUser('bar', 'baz') + ->start(); $pdo = new \PDO( - sprintf('mysql:host=%s;port=3306', $container->getAddress()), + sprintf( + 'mysql:host=%s;port=%d', + $container->getHost(), + $container->getFirstMappedPort() + ), 'bar', 'baz', ); @@ -82,14 +96,17 @@ $pdo = new \PDO( use Testcontainers\Modules\MariaDBContainer; -$container = MariaDBContainer::make('8.0'); -$container->withMariaDBDatabase('foo'); -$container->withMariaDBUser('bar', 'baz'); - -$container->run(); +$container = $container = (new MariaDBContainer()) + ->withMariaDBDatabase('foo') + ->withMariaDBUser('bar', 'baz') + ->start(); $pdo = new \PDO( - sprintf('mysql:host=%s;port=3306', $container->getAddress()), + sprintf( + 'mysql:host=%s;port=%d', + $container->getHost(), + $container->getFirstMappedPort() + ), 'bar', 'baz', ); @@ -104,16 +121,19 @@ $pdo = new \PDO( use Testcontainers\Modules\PostgresContainer; -$container = PostgresContainer::make('15.0', 'password'); -$container->withPostgresDatabase('database'); -$container->withPostgresUser('username'); - -$container->run(); +$container = (new PostgresContainer()) + ->withPostgresUser('bar') + ->withPostgresDatabase('foo') + ->start(); $pdo = new \PDO( - sprintf('pgsql:host=%s;port=5432;dbname=database', $container->getAddress()), - 'username', - 'password', + sprintf( + 'pgsql:host=%s;port=%d;dbname=foo', + self::$container->getHost(), + self::$container->getFirstMappedPort() + ), + 'bar', + 'test', ); // Do something with pdo @@ -125,12 +145,11 @@ $pdo = new \PDO( use Testcontainers\Modules\RedisContainer; -$container = RedisContainer::make('6.0'); - -$container->run(); +$container = (new RedisContainer()) + ->start(); $redis = new \Redis(); -$redis->connect($container->getAddress()); +$redis->connect($container->getHost(), $container->getFirstMappedPort()); // Do something with redis ``` @@ -141,10 +160,9 @@ $redis->connect($container->getAddress()); use Testcontainers\Modules\OpenSearchContainer; -$container = OpenSearchContainer::make('2'); -$container->disableSecurityPlugin(); - -$container->run(); +$container = (new OpenSearchContainer()) + ->withDisabledSecurityPlugin() + ->start(); // Do something with opensearch ``` @@ -175,11 +193,12 @@ class TestConnectionFactory extends ConnectionFactory public function __construct(array $typesConfig, ?DsnParser $dsnParser = null) { if (!$this::$testDsn) { - $psql = PostgresContainer::make('14.0', 'password'); - $psql->withPostgresDatabase('database'); - $psql->withPostgresUser('user'); - $psql->run(); - $this::$testDsn = sprintf('postgresql://user:password@%s:5432/database?serverVersion=14&charset=utf8', $psql->getAddress()); + $psql = (new PostgresContainer()) + ->withPostgresUser('user') + ->withPostgresPassword('password') + ->withPostgresDatabase('database') + ->start(); + $this::$testDsn = sprintf('postgresql://user:password@%s:%d/database?serverVersion=14&charset=utf8', $psql->getAddress(), $psql->getFirstMappedPort()); } parent::__construct($typesConfig, $dsnParser); } diff --git a/composer.json b/composer.json index c0a3fb1..76636bb 100644 --- a/composer.json +++ b/composer.json @@ -14,12 +14,12 @@ } ], "require": { + "ext-curl": "*", "php": ">= 8.1", "beluga-php/docker-php": "^1.45", "symfony/http-client": "^7.1" }, "require-dev": { - "ext-curl": "*", "ext-pdo": "*", "phpunit/phpunit": "^9.5", "brianium/paratest": "^6.6", diff --git a/src/Container/Container.php b/src/Container/Container.php index 29a3d71..2074a23 100644 --- a/src/Container/Container.php +++ b/src/Container/Container.php @@ -9,7 +9,7 @@ namespace Testcontainers\Container; * @deprecated Use GenericContainer instead. * TODO: Remove in next major release. */ -final class Container extends GenericContainer +class Container extends GenericContainer { protected ?StartedTestContainer $startedContainer = null; diff --git a/src/Container/MariaDBContainer.php b/src/Container/MariaDBContainer.php index 1171ca1..79fcbb9 100644 --- a/src/Container/MariaDBContainer.php +++ b/src/Container/MariaDBContainer.php @@ -4,12 +4,44 @@ declare(strict_types=1); namespace Testcontainers\Container; +use Testcontainers\Wait\WaitForLog; + /** * Left for namespace backward compatibility * @deprecated Use \Testcontainers\Modules\MariaDBContainer instead. * TODO: Remove in next major release. */ -class MariaDBContainer extends \Testcontainers\Modules\MariaDBContainer +class MariaDBContainer extends Container { + public function __construct(string $version = 'latest', string $mysqlRootPassword = 'root') + { + parent::__construct('mariadb:' . $version); + $this->withExposedPorts(3306); + $this->withWait(new WaitForLog('ready for connections')); + $this->withEnvironment('MARIADB_ROOT_PASSWORD', $mysqlRootPassword); + } + /** + * @deprecated Use constructor instead + * Left for backward compatibility + */ + public static function make(string $version = 'latest', string $mysqlRootPassword = 'root'): self + { + return new self($version, $mysqlRootPassword); + } + + public function withMariaDBUser(string $username, string $password): self + { + $this->withEnvironment('MARIADB_USER', $username); + $this->withEnvironment('MARIADB_PASSWORD', $password); + + return $this; + } + + public function withMariaDBDatabase(string $database): self + { + $this->withEnvironment('MARIADB_DATABASE', $database); + + return $this; + } } \ No newline at end of file diff --git a/src/Container/MySQLContainer.php b/src/Container/MySQLContainer.php index 9aab957..b2a3bfa 100644 --- a/src/Container/MySQLContainer.php +++ b/src/Container/MySQLContainer.php @@ -4,12 +4,44 @@ declare(strict_types=1); namespace Testcontainers\Container; +use Testcontainers\Wait\WaitForLog; + /** * Left for namespace backward compatibility * @deprecated Use \Testcontainers\Modules\MySQLContainer instead. * TODO: Remove in next major release. */ -class MySQLContainer extends \Testcontainers\Modules\MySQLContainer +class MySQLContainer extends Container { + public function __construct(string $version = 'latest', string $mysqlRootPassword = 'root') + { + parent::__construct('mysql:' . $version); + $this->withExposedPorts(3306); + $this->withEnvironment('MYSQL_ROOT_PASSWORD', $mysqlRootPassword); + $this->withWait(new WaitForLog('ready for connections')); + } + /** + * @deprecated Use constructor instead + * Left for backward compatibility + */ + public static function make(string $version = 'latest', string $mysqlRootPassword = 'root'): self + { + return new self($version, $mysqlRootPassword); + } + + public function withMySQLUser(string $username, string $password): self + { + $this->withEnvironment('MYSQL_USER', $username); + $this->withEnvironment('MYSQL_PASSWORD', $password); + + return $this; + } + + public function withMySQLDatabase(string $database): self + { + $this->withEnvironment('MYSQL_DATABASE', $database); + + return $this; + } } \ No newline at end of file diff --git a/src/Container/OpenSearchContainer.php b/src/Container/OpenSearchContainer.php index 90e1f17..f8c43a8 100644 --- a/src/Container/OpenSearchContainer.php +++ b/src/Container/OpenSearchContainer.php @@ -4,12 +4,49 @@ declare(strict_types=1); namespace Testcontainers\Container; +use Testcontainers\Wait\WaitForLog; + /** * Left for namespace backward compatibility * @deprecated Use \Testcontainers\Modules\OpenSearchContainer instead. * TODO: Remove in next major release. */ -class OpenSearchContainer extends \Testcontainers\Modules\OpenSearchContainer +class OpenSearchContainer extends Container { + public function __construct(string $version = 'latest') + { + parent::__construct('opensearchproject/opensearch:' . $version); + $this->withExposedPorts(9200); + $this->withEnvironment('discovery.type', 'single-node'); + $this->withEnvironment('OPENSEARCH_INITIAL_ADMIN_PASSWORD', 'c3o_ZPHo!'); + $this->withWait(new WaitForLog( + '/\]\s+started\?\[/', + true, + 30000 + )); + } -} \ No newline at end of file + /** + * @deprecated Use constructor instead + * Left for backward compatibility + */ + public static function make(string $version = 'latest'): self + { + return new self($version); + } + + public function withDisabledSecurityPlugin(): self + { + $this->withEnvironment('plugins.security.disabled', 'true'); + + return $this; + } + + /** + * @deprecated Use withDisabledSecurityPlugin instead + */ + public function disableSecurityPlugin(): self + { + return $this->withDisabledSecurityPlugin(); + } +} diff --git a/src/Container/PostgresContainer.php b/src/Container/PostgresContainer.php index 6e6e3e1..599c475 100644 --- a/src/Container/PostgresContainer.php +++ b/src/Container/PostgresContainer.php @@ -4,12 +4,52 @@ declare(strict_types=1); namespace Testcontainers\Container; +use Testcontainers\Wait\WaitForExec; + /** * Left for namespace backward compatibility * @deprecated Use \Testcontainers\Modules\PostgresContainer instead. * TODO: Remove in next major release. */ -class PostgresContainer extends \Testcontainers\Modules\PostgresContainer +class PostgresContainer extends Container { + public function __construct( + string $version = 'latest', + public readonly string $username = 'test', + public readonly string $password = 'test', + public readonly string $database = 'test' + ) { + parent::__construct('postgres:' . $version); + $this->withExposedPorts(5432); + $this->withEnvironment('POSTGRES_USER', $this->username); + $this->withEnvironment('POSTGRES_PASSWORD', $this->password); + $this->withEnvironment('POSTGRES_DB', $this->database); + $this->withWait(new WaitForExec(["pg_isready", "-h", "127.0.0.1", "-U", $this->username])); + } -} \ No newline at end of file + /** + * @deprecated Use constructor instead + * Left for backward compatibility + */ + public static function make(string $version = 'latest', string $dbPassword = 'root'): self + { + return new self( + version: $version, + password: $dbPassword + ); + } + + public function withPostgresUser(string $username): self + { + $this->withEnvironment('POSTGRES_USER', $username); + + return $this; + } + + public function withPostgresDatabase(string $database): self + { + $this->withEnvironment('POSTGRES_DB', $database); + + return $this; + } +} diff --git a/src/Container/RedisContainer.php b/src/Container/RedisContainer.php index aa2f0da..52625f1 100644 --- a/src/Container/RedisContainer.php +++ b/src/Container/RedisContainer.php @@ -4,12 +4,28 @@ declare(strict_types=1); namespace Testcontainers\Container; +use Testcontainers\Wait\WaitForLog; + /** * Left for namespace backward compatibility * @deprecated Use \Testcontainers\Modules\RedisContainer instead. * TODO: Remove in next major release. */ -class RedisContainer extends \Testcontainers\Modules\RedisContainer +class RedisContainer extends Container { + public function __construct(string $version = 'latest') + { + parent::__construct('redis:' . $version); + $this->withExposedPorts(6379); + $this->withWait(new WaitForLog('Ready to accept connections')); + } -} \ No newline at end of file + /** + * @deprecated Use constructor instead + * Left for backward compatibility + */ + public static function make(string $version = 'latest'): self + { + return new self($version); + } +} diff --git a/src/Modules/PostgresContainer.php b/src/Modules/PostgresContainer.php index 8c30903..764663b 100644 --- a/src/Modules/PostgresContainer.php +++ b/src/Modules/PostgresContainer.php @@ -23,18 +23,6 @@ class PostgresContainer extends GenericContainer $this->withWait(new WaitForExec(["pg_isready", "-h", "127.0.0.1", "-U", $this->username])); } - /** - * @deprecated Use constructor instead - * Left for backward compatibility - */ - public static function make(string $version = 'latest', string $dbPassword = 'root'): self - { - return new self( - version: $version, - password: $dbPassword - ); - } - public function withPostgresUser(string $username): self { $this->withEnvironment('POSTGRES_USER', $username); @@ -42,6 +30,13 @@ class PostgresContainer extends GenericContainer return $this; } + public function withPostgresPassword(string $password): self + { + $this->withEnvironment('POSTGRES_PASSWORD', $password); + + return $this; + } + public function withPostgresDatabase(string $database): self { $this->withEnvironment('POSTGRES_DB', $database); diff --git a/src/Wait/WaitForHealthCheck.php b/src/Wait/WaitForHealthCheck.php index 4a0c9c8..74a13f2 100644 --- a/src/Wait/WaitForHealthCheck.php +++ b/src/Wait/WaitForHealthCheck.php @@ -30,7 +30,6 @@ class WaitForHealthCheck extends BaseWaitStrategy /** @var \Psr\Http\Message\ResponseInterface | null $containerInspect */ $containerInspect = $container->getClient()->containerInspect($container->getId(), [], Docker::FETCH_RESPONSE); //$containerStatus = $containerInspect?->getArrayCopy() ?? null; - var_dump($containerInspect->getBody()->getContents()); $containerStatus = ''; if ($containerStatus === 'healthy') { return; diff --git a/tests/Integration/OldTests/ContainerTest.php b/tests/Integration/OldTests/ContainerTest.php index f13f163..d7147b5 100644 --- a/tests/Integration/OldTests/ContainerTest.php +++ b/tests/Integration/OldTests/ContainerTest.php @@ -6,17 +6,23 @@ namespace Testcontainers\Tests\Integration\OldTests; use PHPUnit\Framework\TestCase; use Predis\Client; -use Testcontainers\Modules\MariaDBContainer; -use Testcontainers\Modules\MySQLContainer; -use Testcontainers\Modules\OpenSearchContainer; -use Testcontainers\Modules\PostgresContainer; -use Testcontainers\Modules\RedisContainer; +use Testcontainers\Container\MariaDBContainer; +use Testcontainers\Container\MySQLContainer; +use Testcontainers\Container\OpenSearchContainer; +use Testcontainers\Container\PostgresContainer; +use Testcontainers\Container\RedisContainer; /** * Old test classes kept to check backward compatibility */ class ContainerTest extends TestCase { + //TODO: remove after check + protected function setUp(): void + { + $this->markTestIncomplete(); + } + public function testMySQL(): void { $container = MySQLContainer::make(); diff --git a/tests/Integration/OldTests/WaitStrategyTest.php b/tests/Integration/OldTests/WaitStrategyTest.php index fab22a2..060ca5b 100644 --- a/tests/Integration/OldTests/WaitStrategyTest.php +++ b/tests/Integration/OldTests/WaitStrategyTest.php @@ -7,7 +7,7 @@ namespace Testcontainers\Tests\Integration\OldTests; use PHPUnit\Framework\TestCase; use Predis\Client; use Predis\Connection\ConnectionException; -use Testcontainers\Container\GenericContainer; +use Testcontainers\Container\Container; use Testcontainers\Exception\ContainerNotReadyException; use Testcontainers\Wait\WaitForExec; use Testcontainers\Wait\WaitForHealthCheck; @@ -20,9 +20,15 @@ use Testcontainers\Wait\WaitForTcpPortOpen; */ class WaitStrategyTest extends TestCase { + //TODO: remove after check + protected function setUp(): void + { + $this->markTestIncomplete(); + } + public function testWaitForExec(): void { - $container = GenericContainer::make('mysql') + $container = Container::make('mysql') ->withEnvironment('MYSQL_ROOT_PASSWORD', 'root') ->withWait( new WaitForExec([ @@ -48,104 +54,104 @@ class WaitStrategyTest extends TestCase $this->assertNotEmpty($version); } - // public function testWaitForLog(): void - // { - // $container = GenericContainer::make('redis:6.2.5') - // ->withWait(new WaitForLog('Ready to accept connections')); - // - // $container->run(); - // - // $redis = new Client([ - // 'scheme' => 'tcp', - // 'host' => $container->getAddress(), - // 'port' => 6379, - // ]); - // - // $redis->set('foo', 'bar'); - // - // $this->assertEquals('bar', $redis->get('foo')); - // - // $container->stop(); - // - // $this->expectException(ConnectionException::class); - // - // $redis->get('foo'); - // - // $container->remove(); - // } - // - // public function testWaitForHTTP(): void - // { - // $container = GenericContainer::make('nginx:alpine') - // ->withWait(WaitForHttp::make(80)); - // - // $container->run(); - // - // $ch = curl_init(); - // curl_setopt($ch, CURLOPT_URL, sprintf('http://%s:%d', $container->getAddress(), 80)); - // curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - // - // $response = (string) curl_exec($ch); - // - // curl_close($ch); - // - // $this->assertNotEmpty($response); - // } - // - // /** - // * @dataProvider provideWaitForTcpPortOpen - // */ - // public function testWaitForTcpPortOpen(bool $wait): void - // { - // $container = GenericContainer::make('nginx:alpine'); - // - // if ($wait) { - // $container->withWait(WaitForTcpPortOpen::make(80)); - // } - // - // $container->run(); - // - // if ($wait) { - // static::assertIsResource(fsockopen($container->getAddress(), 80), 'Failed to connect to container'); - // return; - // } - // - // $containerId = $container->getId(); - // - // $this->expectExceptionObject(new ContainerNotReadyException($containerId)); - // - // (new WaitForTcpPortOpen(8080))->wait($containerId); - // } - // - // /** - // * @return array> - // */ - // public function provideWaitForTcpPortOpen(): array - // { - // return [ - // 'Can connect to container' => [true], - // 'Cannot connect to container' => [false], - // ]; - // } - // - // public function testWaitForHealthCheck(): void - // { - // $container = GenericContainer::make('nginx') - // ->withHealthCheckCommand('curl --fail http://localhost') - // ->withWait(new WaitForHealthCheck()); - // - // $container->run(); - // - // $ch = curl_init(); - // - // curl_setopt($ch, CURLOPT_URL, sprintf('http://%s:%d', $container->getAddress(), 80)); - // curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - // - // $response = curl_exec($ch); - // - // $this->assertNotEmpty($response); - // $this->assertIsString($response); - // - // $this->assertStringContainsString('Welcome to nginx!', $response); - // } + public function testWaitForLog(): void + { + $container = Container::make('redis:6.2.5') + ->withWait(new WaitForLog('Ready to accept connections')); + + $container->run(); + + $redis = new Client([ + 'scheme' => 'tcp', + 'host' => $container->getAddress(), + 'port' => 6379, + ]); + + $redis->set('foo', 'bar'); + + $this->assertEquals('bar', $redis->get('foo')); + + $container->stop(); + + $this->expectException(ConnectionException::class); + + $redis->get('foo'); + + $container->remove(); + } + + public function testWaitForHTTP(): void + { + $container = Container::make('nginx:alpine') + ->withWait(WaitForHttp::make(80)); + + $container->run(); + + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, sprintf('http://%s:%d', $container->getAddress(), 80)); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + + $response = (string) curl_exec($ch); + + curl_close($ch); + + $this->assertNotEmpty($response); + } + + /** + * @dataProvider provideWaitForTcpPortOpen + */ + public function testWaitForTcpPortOpen(bool $wait): void + { + $container = Container::make('nginx:alpine'); + + if ($wait) { + $container->withWait(WaitForTcpPortOpen::make(80)); + } + + $container->run(); + + if ($wait) { + static::assertIsResource(fsockopen($container->getAddress(), 80), 'Failed to connect to container'); + return; + } + + $containerId = $container->getId(); + + $this->expectExceptionObject(new ContainerNotReadyException($containerId)); + + (new WaitForTcpPortOpen(8080))->wait($containerId); + } + + /** + * @return array> + */ + public function provideWaitForTcpPortOpen(): array + { + return [ + 'Can connect to container' => [true], + 'Cannot connect to container' => [false], + ]; + } + + public function testWaitForHealthCheck(): void + { + $container = Container::make('nginx') + ->withHealthCheckCommand('curl --fail http://localhost') + ->withWait(new WaitForHealthCheck()); + + $container->run(); + + $ch = curl_init(); + + curl_setopt($ch, CURLOPT_URL, sprintf('http://%s:%d', $container->getAddress(), 80)); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + + $response = curl_exec($ch); + + $this->assertNotEmpty($response); + $this->assertIsString($response); + + $this->assertStringContainsString('Welcome to nginx!', $response); + } } From 656f38f05649add33c1ece9a7c5b027a31d05dad Mon Sep 17 00:00:00 2001 From: Sergei Shitikov Date: Fri, 6 Sep 2024 20:15:10 +0200 Subject: [PATCH 14/27] cleanup --- src/Container/Container.php | 15 ++++++++------- src/Container/MariaDBContainer.php | 4 ---- src/Container/MySQLContainer.php | 6 +----- src/Container/OpenSearchContainer.php | 14 +------------- src/Container/PostgresContainer.php | 4 ---- src/Container/RedisContainer.php | 4 ---- src/Modules/MariaDBContainer.php | 9 --------- src/Modules/MySQLContainer.php | 9 --------- src/Modules/OpenSearchContainer.php | 8 -------- src/Modules/RedisContainer.php | 9 --------- 10 files changed, 10 insertions(+), 72 deletions(-) diff --git a/src/Container/Container.php b/src/Container/Container.php index 2074a23..ce482c5 100644 --- a/src/Container/Container.php +++ b/src/Container/Container.php @@ -14,6 +14,7 @@ class Container extends GenericContainer protected ?StartedTestContainer $startedContainer = null; protected ?StoppedTestContainer $stoppedContainer = null; + public static function make(string $image): self { return new self($image); @@ -61,7 +62,7 @@ class Container extends GenericContainer */ public function execute(array $commandAsArray): string { - if($this->startedContainer === null) { + if ($this->startedContainer === null) { throw new \RuntimeException('Container is not started'); } @@ -73,7 +74,7 @@ class Container extends GenericContainer */ public function logs(): string { - if($this->startedContainer === null) { + if ($this->startedContainer === null) { throw new \RuntimeException('Container is not started'); } @@ -85,7 +86,7 @@ class Container extends GenericContainer */ public function getAddress(): string { - if($this->startedContainer === null) { + if ($this->startedContainer === null) { throw new \RuntimeException('Container is not started'); } @@ -97,7 +98,7 @@ class Container extends GenericContainer */ public function getPort(): int { - if($this->startedContainer === null) { + if ($this->startedContainer === null) { throw new \RuntimeException('Container is not started'); } @@ -119,7 +120,7 @@ class Container extends GenericContainer */ public function stop(): self { - if($this->startedContainer === null) { + if ($this->startedContainer === null) { throw new \RuntimeException('Container is not started'); } @@ -133,7 +134,7 @@ class Container extends GenericContainer */ public function restart(): self { - if($this->startedContainer === null) { + if ($this->startedContainer === null) { throw new \RuntimeException('Container is not started'); } @@ -148,7 +149,7 @@ class Container extends GenericContainer */ public function remove(): self { - if($this->startedContainer === null) { + if ($this->startedContainer === null) { throw new \RuntimeException('Container is not started'); } diff --git a/src/Container/MariaDBContainer.php b/src/Container/MariaDBContainer.php index 79fcbb9..72f0aac 100644 --- a/src/Container/MariaDBContainer.php +++ b/src/Container/MariaDBContainer.php @@ -21,10 +21,6 @@ class MariaDBContainer extends Container $this->withEnvironment('MARIADB_ROOT_PASSWORD', $mysqlRootPassword); } - /** - * @deprecated Use constructor instead - * Left for backward compatibility - */ public static function make(string $version = 'latest', string $mysqlRootPassword = 'root'): self { return new self($version, $mysqlRootPassword); diff --git a/src/Container/MySQLContainer.php b/src/Container/MySQLContainer.php index b2a3bfa..232c912 100644 --- a/src/Container/MySQLContainer.php +++ b/src/Container/MySQLContainer.php @@ -21,10 +21,6 @@ class MySQLContainer extends Container $this->withWait(new WaitForLog('ready for connections')); } - /** - * @deprecated Use constructor instead - * Left for backward compatibility - */ public static function make(string $version = 'latest', string $mysqlRootPassword = 'root'): self { return new self($version, $mysqlRootPassword); @@ -44,4 +40,4 @@ class MySQLContainer extends Container return $this; } -} \ No newline at end of file +} diff --git a/src/Container/OpenSearchContainer.php b/src/Container/OpenSearchContainer.php index f8c43a8..c3ba7cc 100644 --- a/src/Container/OpenSearchContainer.php +++ b/src/Container/OpenSearchContainer.php @@ -26,27 +26,15 @@ class OpenSearchContainer extends Container )); } - /** - * @deprecated Use constructor instead - * Left for backward compatibility - */ public static function make(string $version = 'latest'): self { return new self($version); } - public function withDisabledSecurityPlugin(): self + public function disableSecurityPlugin(): self { $this->withEnvironment('plugins.security.disabled', 'true'); return $this; } - - /** - * @deprecated Use withDisabledSecurityPlugin instead - */ - public function disableSecurityPlugin(): self - { - return $this->withDisabledSecurityPlugin(); - } } diff --git a/src/Container/PostgresContainer.php b/src/Container/PostgresContainer.php index 599c475..5bb2e0a 100644 --- a/src/Container/PostgresContainer.php +++ b/src/Container/PostgresContainer.php @@ -27,10 +27,6 @@ class PostgresContainer extends Container $this->withWait(new WaitForExec(["pg_isready", "-h", "127.0.0.1", "-U", $this->username])); } - /** - * @deprecated Use constructor instead - * Left for backward compatibility - */ public static function make(string $version = 'latest', string $dbPassword = 'root'): self { return new self( diff --git a/src/Container/RedisContainer.php b/src/Container/RedisContainer.php index 52625f1..1ec3f24 100644 --- a/src/Container/RedisContainer.php +++ b/src/Container/RedisContainer.php @@ -20,10 +20,6 @@ class RedisContainer extends Container $this->withWait(new WaitForLog('Ready to accept connections')); } - /** - * @deprecated Use constructor instead - * Left for backward compatibility - */ public static function make(string $version = 'latest'): self { return new self($version); diff --git a/src/Modules/MariaDBContainer.php b/src/Modules/MariaDBContainer.php index 94f66c6..07ea973 100644 --- a/src/Modules/MariaDBContainer.php +++ b/src/Modules/MariaDBContainer.php @@ -17,15 +17,6 @@ class MariaDBContainer extends GenericContainer $this->withEnvironment('MARIADB_ROOT_PASSWORD', $mysqlRootPassword); } - /** - * @deprecated Use constructor instead - * Left for backward compatibility - */ - public static function make(string $version = 'latest', string $mysqlRootPassword = 'root'): self - { - return new self($version, $mysqlRootPassword); - } - public function withMariaDBUser(string $username, string $password): self { $this->withEnvironment('MARIADB_USER', $username); diff --git a/src/Modules/MySQLContainer.php b/src/Modules/MySQLContainer.php index fcef5bb..b5daaea 100644 --- a/src/Modules/MySQLContainer.php +++ b/src/Modules/MySQLContainer.php @@ -17,15 +17,6 @@ class MySQLContainer extends GenericContainer $this->withWait(new WaitForLog('ready for connections')); } - /** - * @deprecated Use constructor instead - * Left for backward compatibility - */ - public static function make(string $version = 'latest', string $mysqlRootPassword = 'root'): self - { - return new self($version, $mysqlRootPassword); - } - public function withMySQLUser(string $username, string $password): self { $this->withEnvironment('MYSQL_USER', $username); diff --git a/src/Modules/OpenSearchContainer.php b/src/Modules/OpenSearchContainer.php index 24d3f7d..b4e1f5e 100644 --- a/src/Modules/OpenSearchContainer.php +++ b/src/Modules/OpenSearchContainer.php @@ -37,12 +37,4 @@ class OpenSearchContainer extends GenericContainer return $this; } - - /** - * @deprecated Use withDisabledSecurityPlugin instead - */ - public function disableSecurityPlugin(): self - { - return $this->withDisabledSecurityPlugin(); - } } diff --git a/src/Modules/RedisContainer.php b/src/Modules/RedisContainer.php index a31dde9..e40f068 100644 --- a/src/Modules/RedisContainer.php +++ b/src/Modules/RedisContainer.php @@ -15,13 +15,4 @@ class RedisContainer extends GenericContainer $this->withExposedPorts(6379); $this->withWait(new WaitForLog('Ready to accept connections')); } - - /** - * @deprecated Use constructor instead - * Left for backward compatibility - */ - public static function make(string $version = 'latest'): self - { - return new self($version); - } } From abe8159bcb0a443345fc28381881263d1395ff30 Mon Sep 17 00:00:00 2001 From: Sergei Shitikov Date: Fri, 6 Sep 2024 21:30:46 +0200 Subject: [PATCH 15/27] cleanup --- src/Container/GenericContainer.php | 8 ++++---- src/Container/StartedGenericContainer.php | 4 ++++ src/Modules/OpenSearchContainer.php | 9 --------- 3 files changed, 8 insertions(+), 13 deletions(-) diff --git a/src/Container/GenericContainer.php b/src/Container/GenericContainer.php index 6517091..ed5a39a 100644 --- a/src/Container/GenericContainer.php +++ b/src/Container/GenericContainer.php @@ -195,7 +195,7 @@ class GenericContainer implements TestContainer try { $containerCreatePostBody = new ContainersCreatePostBody(); //handle withExposedPorts - if(!empty($this->exposedPorts)) { + if (!empty($this->exposedPorts)) { $portGenerator = new RandomUniquePortGenerator(); $portMap = new \ArrayObject(); @@ -209,13 +209,13 @@ class GenericContainer implements TestContainer $hostConfig = new HostConfig(); $hostConfig->setPortBindings($portMap); //handle withPrivilegedMode - if($this->isPrivileged) { + if ($this->isPrivileged) { $hostConfig->setPrivileged($this->isPrivileged); } $containerCreatePostBody->setHostConfig($hostConfig); } //handle withPrivilegedMode - if($this->isPrivileged) { + if ($this->isPrivileged) { $hostConfig = new HostConfig(); $hostConfig->setPrivileged($this->isPrivileged); } @@ -239,7 +239,7 @@ class GenericContainer implements TestContainer $this->dockerClient->containerStart($this->id); - if(!isset($this->waitStrategy)) { + if (!isset($this->waitStrategy)) { $this->withWait(new WaitForContainer()); } diff --git a/src/Container/StartedGenericContainer.php b/src/Container/StartedGenericContainer.php index 5760eab..ee509f4 100644 --- a/src/Container/StartedGenericContainer.php +++ b/src/Container/StartedGenericContainer.php @@ -110,12 +110,16 @@ class StartedGenericContainer implements StartedTestContainer //TODO: not ready yet public function getFirstMappedPort(): int { + var_dump($this->dockerClient);die(123); + /** @var \Docker\API\Model\ContainersIdJsonGetResponse200 | null $containerInspectResponse */ $containerInspectResponse = $this->dockerClient->containerInspect($this->id); $settings = $containerInspectResponse->getNetworkSettings(); $ports = (array)$settings->getPorts(); $port = array_key_first($ports); + var_dump($ports, $port, (int)$ports[$port][0]->getHostPort()); + return (int) $ports[$port][0]->getHostPort(); } diff --git a/src/Modules/OpenSearchContainer.php b/src/Modules/OpenSearchContainer.php index b4e1f5e..ddbb445 100644 --- a/src/Modules/OpenSearchContainer.php +++ b/src/Modules/OpenSearchContainer.php @@ -22,15 +22,6 @@ class OpenSearchContainer extends GenericContainer )); } - /** - * @deprecated Use constructor instead - * Left for backward compatibility - */ - public static function make(string $version = 'latest'): self - { - return new self($version); - } - public function withDisabledSecurityPlugin(): self { $this->withEnvironment('plugins.security.disabled', 'true'); From f7e97d4932628811df34e97e2b94b64ddc8195d3 Mon Sep 17 00:00:00 2001 From: Sergei Shitikov Date: Fri, 6 Sep 2024 22:19:52 +0200 Subject: [PATCH 16/27] cleanup --- src/Container/GenericContainer.php | 2 ++ src/Container/StartedGenericContainer.php | 6 ++---- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Container/GenericContainer.php b/src/Container/GenericContainer.php index ed5a39a..9a0a266 100644 --- a/src/Container/GenericContainer.php +++ b/src/Container/GenericContainer.php @@ -11,6 +11,7 @@ use Docker\API\Model\HostConfig; use Docker\API\Model\Mount; use Docker\API\Model\PortBinding; use Docker\Docker; +use Docker\Stream\CreateImageStream; use InvalidArgumentException; use Testcontainers\ContainerClient\DockerContainerClient; use Testcontainers\Utils\PortGenerator\RandomUniquePortGenerator; @@ -230,6 +231,7 @@ class GenericContainer implements TestContainer $containerCreateResponse = $this->dockerClient->containerCreate($containerCreatePostBody); $this->id = $containerCreateResponse?->getId() ?? ''; } catch (ContainerCreateNotFoundException) { + /** @var CreateImageStream $imageCreateResponse */ $this->dockerClient->imageCreate(null, [ 'fromImage' => explode(':', $this->image)[0], 'tag' => explode(':', $this->image)[1] ?? 'latest', diff --git a/src/Container/StartedGenericContainer.php b/src/Container/StartedGenericContainer.php index ee509f4..60d478c 100644 --- a/src/Container/StartedGenericContainer.php +++ b/src/Container/StartedGenericContainer.php @@ -6,6 +6,7 @@ namespace Testcontainers\Container; use Docker\API\Client; use Docker\API\Model\ContainersIdExecPostBody; +use Docker\API\Model\ContainersIdJsonGetResponse200; use Docker\API\Model\IdResponse; use Docker\API\Runtime\Client\Client as DockerRuntimeClient; use Docker\Docker; @@ -110,16 +111,13 @@ class StartedGenericContainer implements StartedTestContainer //TODO: not ready yet public function getFirstMappedPort(): int { - var_dump($this->dockerClient);die(123); - /** @var \Docker\API\Model\ContainersIdJsonGetResponse200 | null $containerInspectResponse */ + /** @var ContainersIdJsonGetResponse200 | null $containerInspectResponse */ $containerInspectResponse = $this->dockerClient->containerInspect($this->id); $settings = $containerInspectResponse->getNetworkSettings(); $ports = (array)$settings->getPorts(); $port = array_key_first($ports); - var_dump($ports, $port, (int)$ports[$port][0]->getHostPort()); - return (int) $ports[$port][0]->getHostPort(); } From aa2c1eb900fa57173e82199836d0d21a0d1cab54 Mon Sep 17 00:00:00 2001 From: Sergei Shitikov Date: Sun, 8 Sep 2024 15:46:34 +0200 Subject: [PATCH 17/27] remove symfony/http-client dependency + some composer adjustments --- composer.json | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/composer.json b/composer.json index 76636bb..5474d25 100644 --- a/composer.json +++ b/composer.json @@ -16,11 +16,12 @@ "require": { "ext-curl": "*", "php": ">= 8.1", - "beluga-php/docker-php": "^1.45", - "symfony/http-client": "^7.1" + "beluga-php/docker-php": "^1.45" }, "require-dev": { "ext-pdo": "*", + "ext-pdo_mysql": "*", + "ext-pdo_pgsql": "*", "phpunit/phpunit": "^9.5", "brianium/paratest": "^6.6", "friendsofphp/php-cs-fixer": "^3.12", @@ -48,7 +49,7 @@ "config": { "allow-plugins": { "phpstan/extension-installer": true, - "php-http/discovery": true + "php-http/discovery": false } } } From 357887542f6d91373ca8979f67b2458c1a5b5a92 Mon Sep 17 00:00:00 2001 From: Sergei Shitikov Date: Sun, 8 Sep 2024 19:04:15 +0200 Subject: [PATCH 18/27] fixes and improvements --- src/Container/GenericContainer.php | 4 +- src/Container/MariaDBContainer.php | 10 +- src/Container/MySQLContainer.php | 8 +- src/Container/StartedGenericContainer.php | 35 +++- src/ContainerClient/DockerContainerClient.php | 5 +- src/Modules/MariaDBContainer.php | 8 +- src/Modules/MySQLContainer.php | 8 +- .../PortGenerator/RandomPortGenerator.php | 2 +- src/Wait/BaseWaitStrategy.php | 1 - src/Wait/WaitForExec.php | 3 - src/Wait/WaitForHealthCheck.php | 1 + src/Wait/WaitForHttp.php | 3 +- src/Wait/WaitForTcpPortOpen.php | 1 + tests/Integration/OldTests/ContainerTest.php | 1 + .../Integration/OldTests/WaitStrategyTest.php | 154 +++++++++--------- 15 files changed, 140 insertions(+), 104 deletions(-) diff --git a/src/Container/GenericContainer.php b/src/Container/GenericContainer.php index 9a0a266..ca0ba6f 100644 --- a/src/Container/GenericContainer.php +++ b/src/Container/GenericContainer.php @@ -232,10 +232,12 @@ class GenericContainer implements TestContainer $this->id = $containerCreateResponse?->getId() ?? ''; } catch (ContainerCreateNotFoundException) { /** @var CreateImageStream $imageCreateResponse */ - $this->dockerClient->imageCreate(null, [ + $imageCreateResponse = $this->dockerClient->imageCreate(null, [ 'fromImage' => explode(':', $this->image)[0], 'tag' => explode(':', $this->image)[1] ?? 'latest', ]); + $imageCreateResponse->wait(); + return $this->start(); } diff --git a/src/Container/MariaDBContainer.php b/src/Container/MariaDBContainer.php index 72f0aac..b806783 100644 --- a/src/Container/MariaDBContainer.php +++ b/src/Container/MariaDBContainer.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace Testcontainers\Container; -use Testcontainers\Wait\WaitForLog; +use Testcontainers\Wait\WaitForExec; /** * Left for namespace backward compatibility @@ -17,8 +17,12 @@ class MariaDBContainer extends Container { parent::__construct('mariadb:' . $version); $this->withExposedPorts(3306); - $this->withWait(new WaitForLog('ready for connections')); $this->withEnvironment('MARIADB_ROOT_PASSWORD', $mysqlRootPassword); + $this->withWait(new WaitForExec([ + "mariadb-admin", + "ping", + "-h", "127.0.0.1", + ])); } public static function make(string $version = 'latest', string $mysqlRootPassword = 'root'): self @@ -40,4 +44,4 @@ class MariaDBContainer extends Container return $this; } -} \ No newline at end of file +} diff --git a/src/Container/MySQLContainer.php b/src/Container/MySQLContainer.php index 232c912..a6e7efb 100644 --- a/src/Container/MySQLContainer.php +++ b/src/Container/MySQLContainer.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace Testcontainers\Container; -use Testcontainers\Wait\WaitForLog; +use Testcontainers\Wait\WaitForExec; /** * Left for namespace backward compatibility @@ -18,7 +18,11 @@ class MySQLContainer extends Container parent::__construct('mysql:' . $version); $this->withExposedPorts(3306); $this->withEnvironment('MYSQL_ROOT_PASSWORD', $mysqlRootPassword); - $this->withWait(new WaitForLog('ready for connections')); + $this->withWait(new WaitForExec([ + "mysqladmin", + "ping", + "-h", "127.0.0.1", + ])); } public static function make(string $version = 'latest', string $mysqlRootPassword = 'root'): self diff --git a/src/Container/StartedGenericContainer.php b/src/Container/StartedGenericContainer.php index 60d478c..ec81e71 100644 --- a/src/Container/StartedGenericContainer.php +++ b/src/Container/StartedGenericContainer.php @@ -6,10 +6,10 @@ namespace Testcontainers\Container; use Docker\API\Client; use Docker\API\Model\ContainersIdExecPostBody; -use Docker\API\Model\ContainersIdJsonGetResponse200; use Docker\API\Model\IdResponse; use Docker\API\Runtime\Client\Client as DockerRuntimeClient; use Docker\Docker; +use Psr\Http\Message\ResponseInterface; use Testcontainers\ContainerClient\DockerContainerClient; class StartedGenericContainer implements StartedTestContainer @@ -53,7 +53,7 @@ class StartedGenericContainer implements StartedTestContainer /** @var IdResponse | null $exec */ $exec = $this->dockerClient->containerExec($this->id, $execConfig); - if($exec === null || $exec->getId() === null) { + if ($exec === null || $exec->getId() === null) { throw new \RuntimeException('Failed to create exec command'); } @@ -108,17 +108,36 @@ class StartedGenericContainer implements StartedTestContainer return $this->inspect()->ports[$port]; } - //TODO: not ready yet + /** + * @throws \JsonException + */ public function getFirstMappedPort(): int { - /** @var ContainersIdJsonGetResponse200 | null $containerInspectResponse */ - $containerInspectResponse = $this->dockerClient->containerInspect($this->id); - $settings = $containerInspectResponse->getNetworkSettings(); + //For some reason, containerInspect can crash when using FETCH_OBJECT option (e.g. with OpenSearch) + //should be checked within beluga-php/docker-php client library + /** @var ResponseInterface | null $containerInspectResponse */ + $containerInspectResponse = $this->dockerClient->containerInspect($this->id, [], Docker::FETCH_RESPONSE); + if ($containerInspectResponse === null) { + throw new \RuntimeException('Failed to inspect container'); + } + + $containerInspectResponseAsArray = json_decode( + $containerInspectResponse->getBody()->getContents(), + true, + 512, + JSON_THROW_ON_ERROR + ); + + /** @var array>> $ports */ + $ports = $containerInspectResponseAsArray['NetworkSettings']['Ports'] ?? []; + + if ($ports === []) { + throw new \RuntimeException('Failed to get ports from container'); + } - $ports = (array)$settings->getPorts(); $port = array_key_first($ports); - return (int) $ports[$port][0]->getHostPort(); + return (int) $ports[$port][0]['HostPort']; } public function getName(): string diff --git a/src/ContainerClient/DockerContainerClient.php b/src/ContainerClient/DockerContainerClient.php index 84bf645..8001992 100644 --- a/src/ContainerClient/DockerContainerClient.php +++ b/src/ContainerClient/DockerContainerClient.php @@ -1,5 +1,7 @@ withExposedPorts(3306); - $this->withWait(new WaitForLog('ready for connections')); $this->withEnvironment('MARIADB_ROOT_PASSWORD', $mysqlRootPassword); + $this->withWait(new WaitForExec([ + "mariadb-admin", + "ping", + "-h", "127.0.0.1", + ])); } public function withMariaDBUser(string $username, string $password): self diff --git a/src/Modules/MySQLContainer.php b/src/Modules/MySQLContainer.php index b5daaea..00c6a90 100644 --- a/src/Modules/MySQLContainer.php +++ b/src/Modules/MySQLContainer.php @@ -5,7 +5,7 @@ declare(strict_types=1); namespace Testcontainers\Modules; use Testcontainers\Container\GenericContainer; -use Testcontainers\Wait\WaitForLog; +use Testcontainers\Wait\WaitForExec; class MySQLContainer extends GenericContainer { @@ -14,7 +14,11 @@ class MySQLContainer extends GenericContainer parent::__construct('mysql:' . $version); $this->withExposedPorts(3306); $this->withEnvironment('MYSQL_ROOT_PASSWORD', $mysqlRootPassword); - $this->withWait(new WaitForLog('ready for connections')); + $this->withWait(new WaitForExec([ + "mysqladmin", + "ping", + "-h", "127.0.0.1", + ])); } public function withMySQLUser(string $username, string $password): self diff --git a/src/Utils/PortGenerator/RandomPortGenerator.php b/src/Utils/PortGenerator/RandomPortGenerator.php index 8d3bbbc..1610a0a 100644 --- a/src/Utils/PortGenerator/RandomPortGenerator.php +++ b/src/Utils/PortGenerator/RandomPortGenerator.php @@ -27,4 +27,4 @@ class RandomPortGenerator implements PortGenerator return $port; } -} \ No newline at end of file +} diff --git a/src/Wait/BaseWaitStrategy.php b/src/Wait/BaseWaitStrategy.php index 432aedf..80ff748 100644 --- a/src/Wait/BaseWaitStrategy.php +++ b/src/Wait/BaseWaitStrategy.php @@ -8,7 +8,6 @@ use Testcontainers\Container\StartedTestContainer; abstract class BaseWaitStrategy implements WaitStrategy { - public function __construct(protected int $timeout = 10000, protected int $pollInterval = 500) { } diff --git a/src/Wait/WaitForExec.php b/src/Wait/WaitForExec.php index 044471c..ce7af35 100644 --- a/src/Wait/WaitForExec.php +++ b/src/Wait/WaitForExec.php @@ -5,7 +5,6 @@ declare(strict_types=1); namespace Testcontainers\Wait; use Closure; -use Docker\API\Model\ContainersIdExecPostBody; use Docker\API\Model\ExecIdJsonGetResponse200; use Testcontainers\Container\StartedTestContainer; use Testcontainers\Exception\ContainerWaitingTimeoutException; @@ -15,8 +14,6 @@ use Testcontainers\Exception\ContainerWaitingTimeoutException; */ class WaitForExec extends BaseWaitStrategy { - protected ContainersIdExecPostBody $execConfig; - /** * @param array $command */ diff --git a/src/Wait/WaitForHealthCheck.php b/src/Wait/WaitForHealthCheck.php index 74a13f2..9bef414 100644 --- a/src/Wait/WaitForHealthCheck.php +++ b/src/Wait/WaitForHealthCheck.php @@ -9,6 +9,7 @@ use Http\Client\Socket\Exception\TimeoutException; use Testcontainers\Container\StartedTestContainer; use Testcontainers\Exception\ContainerNotReadyException; +//TODO: not ready yet class WaitForHealthCheck extends BaseWaitStrategy { public function __construct(protected int $timeout = 5000, protected int $pollInterval = 1000) diff --git a/src/Wait/WaitForHttp.php b/src/Wait/WaitForHttp.php index 906dc8b..87a840b 100644 --- a/src/Wait/WaitForHttp.php +++ b/src/Wait/WaitForHttp.php @@ -7,6 +7,7 @@ namespace Testcontainers\Wait; use Docker\Docker; use Testcontainers\Exception\ContainerNotReadyException; +//TODO: not ready yet class WaitForHttp implements WaitStrategy { public const METHOD_GET = 'GET'; @@ -61,7 +62,7 @@ class WaitForHttp implements WaitStrategy $containerNetworks = $this->dockerClient->containerInspect($id)->getNetworkSettings()->getNetworks(); $containerAddress = null; foreach ($containerNetworks as $network) { - if($network->getNetworkID() === $id) { + if ($network->getNetworkID() === $id) { $containerAddress = $network->getIpAddress(); break; } diff --git a/src/Wait/WaitForTcpPortOpen.php b/src/Wait/WaitForTcpPortOpen.php index e6c3700..9545a88 100644 --- a/src/Wait/WaitForTcpPortOpen.php +++ b/src/Wait/WaitForTcpPortOpen.php @@ -9,6 +9,7 @@ use JsonException; use RuntimeException; use Testcontainers\Exception\ContainerNotReadyException; +//TODO: not ready yet final class WaitForTcpPortOpen implements WaitStrategy { private Docker $dockerClient; diff --git a/tests/Integration/OldTests/ContainerTest.php b/tests/Integration/OldTests/ContainerTest.php index d7147b5..ed65412 100644 --- a/tests/Integration/OldTests/ContainerTest.php +++ b/tests/Integration/OldTests/ContainerTest.php @@ -18,6 +18,7 @@ use Testcontainers\Container\RedisContainer; class ContainerTest extends TestCase { //TODO: remove after check + //To make it work, fixed port should be first implemented protected function setUp(): void { $this->markTestIncomplete(); diff --git a/tests/Integration/OldTests/WaitStrategyTest.php b/tests/Integration/OldTests/WaitStrategyTest.php index 060ca5b..9c164e9 100644 --- a/tests/Integration/OldTests/WaitStrategyTest.php +++ b/tests/Integration/OldTests/WaitStrategyTest.php @@ -54,104 +54,104 @@ class WaitStrategyTest extends TestCase $this->assertNotEmpty($version); } - public function testWaitForLog(): void - { - $container = Container::make('redis:6.2.5') - ->withWait(new WaitForLog('Ready to accept connections')); + public function testWaitForLog(): void + { + $container = Container::make('redis:6.2.5') + ->withWait(new WaitForLog('Ready to accept connections')); - $container->run(); + $container->run(); - $redis = new Client([ - 'scheme' => 'tcp', - 'host' => $container->getAddress(), - 'port' => 6379, - ]); + $redis = new Client([ + 'scheme' => 'tcp', + 'host' => $container->getAddress(), + 'port' => 6379, + ]); - $redis->set('foo', 'bar'); + $redis->set('foo', 'bar'); - $this->assertEquals('bar', $redis->get('foo')); + $this->assertEquals('bar', $redis->get('foo')); - $container->stop(); + $container->stop(); - $this->expectException(ConnectionException::class); + $this->expectException(ConnectionException::class); - $redis->get('foo'); + $redis->get('foo'); - $container->remove(); + $container->remove(); + } + + public function testWaitForHTTP(): void + { + $container = Container::make('nginx:alpine') + ->withWait(WaitForHttp::make(80)); + + $container->run(); + + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, sprintf('http://%s:%d', $container->getAddress(), 80)); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + + $response = (string) curl_exec($ch); + + curl_close($ch); + + $this->assertNotEmpty($response); + } + + /** + * @dataProvider provideWaitForTcpPortOpen + */ + public function testWaitForTcpPortOpen(bool $wait): void + { + $container = Container::make('nginx:alpine'); + + if ($wait) { + $container->withWait(WaitForTcpPortOpen::make(80)); } - public function testWaitForHTTP(): void - { - $container = Container::make('nginx:alpine') - ->withWait(WaitForHttp::make(80)); + $container->run(); - $container->run(); - - $ch = curl_init(); - curl_setopt($ch, CURLOPT_URL, sprintf('http://%s:%d', $container->getAddress(), 80)); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - - $response = (string) curl_exec($ch); - - curl_close($ch); - - $this->assertNotEmpty($response); + if ($wait) { + static::assertIsResource(fsockopen($container->getAddress(), 80), 'Failed to connect to container'); + return; } - /** - * @dataProvider provideWaitForTcpPortOpen - */ - public function testWaitForTcpPortOpen(bool $wait): void - { - $container = Container::make('nginx:alpine'); + $containerId = $container->getId(); - if ($wait) { - $container->withWait(WaitForTcpPortOpen::make(80)); - } + $this->expectExceptionObject(new ContainerNotReadyException($containerId)); - $container->run(); + (new WaitForTcpPortOpen(8080))->wait($containerId); + } - if ($wait) { - static::assertIsResource(fsockopen($container->getAddress(), 80), 'Failed to connect to container'); - return; - } + /** + * @return array> + */ + public function provideWaitForTcpPortOpen(): array + { + return [ + 'Can connect to container' => [true], + 'Cannot connect to container' => [false], + ]; + } - $containerId = $container->getId(); + public function testWaitForHealthCheck(): void + { + $container = Container::make('nginx') + ->withHealthCheckCommand('curl --fail http://localhost') + ->withWait(new WaitForHealthCheck()); - $this->expectExceptionObject(new ContainerNotReadyException($containerId)); + $container->run(); - (new WaitForTcpPortOpen(8080))->wait($containerId); - } + $ch = curl_init(); - /** - * @return array> - */ - public function provideWaitForTcpPortOpen(): array - { - return [ - 'Can connect to container' => [true], - 'Cannot connect to container' => [false], - ]; - } + curl_setopt($ch, CURLOPT_URL, sprintf('http://%s:%d', $container->getAddress(), 80)); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - public function testWaitForHealthCheck(): void - { - $container = Container::make('nginx') - ->withHealthCheckCommand('curl --fail http://localhost') - ->withWait(new WaitForHealthCheck()); + $response = curl_exec($ch); - $container->run(); + $this->assertNotEmpty($response); + $this->assertIsString($response); - $ch = curl_init(); - - curl_setopt($ch, CURLOPT_URL, sprintf('http://%s:%d', $container->getAddress(), 80)); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - - $response = curl_exec($ch); - - $this->assertNotEmpty($response); - $this->assertIsString($response); - - $this->assertStringContainsString('Welcome to nginx!', $response); - } + $this->assertStringContainsString('Welcome to nginx!', $response); + } } From 1f9035f790357450a64dcb876debb9b0f3ab30c8 Mon Sep 17 00:00:00 2001 From: Sergei Shitikov Date: Fri, 27 Sep 2024 17:18:48 +0200 Subject: [PATCH 19/27] solve conflicts --- src/Container/Container.php | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/Container/Container.php b/src/Container/Container.php index ce482c5..b3f7696 100644 --- a/src/Container/Container.php +++ b/src/Container/Container.php @@ -20,6 +20,25 @@ class Container extends GenericContainer return new self($image); } + /** + * @deprecated Use `withCommand` instead + * @param array $cmd + */ + public function withCmd(array $cmd): self + { + return $this->withCommand($cmd); + } + + /** + * @deprecated Use `withEntrypoint` instead + * TODO: this is just dummy method for compatibility, + * the implementation with Docker Engine API should be discussed + */ + public function withHostname(string $hostname): self + { + return $this; + } + /** * @deprecated Use `withPrivilegedMode` instead */ From 2802dea75e854fac92836d5b53461dc613e9f4de Mon Sep 17 00:00:00 2001 From: Sergei Shitikov Date: Sun, 29 Sep 2024 11:11:37 +0200 Subject: [PATCH 20/27] refactor start() method of the GenericContainer --- src/Container/GenericContainer.php | 168 +++++++++++++++++------------ src/Container/InternetProtocol.php | 24 +++++ src/Utils/PortNormalizer.php | 32 ++++++ 3 files changed, 156 insertions(+), 68 deletions(-) create mode 100644 src/Container/InternetProtocol.php create mode 100644 src/Utils/PortNormalizer.php diff --git a/src/Container/GenericContainer.php b/src/Container/GenericContainer.php index ca0ba6f..6edcf2c 100644 --- a/src/Container/GenericContainer.php +++ b/src/Container/GenericContainer.php @@ -5,16 +5,20 @@ declare(strict_types=1); 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\RandomUniquePortGenerator; +use Testcontainers\Utils\PortNormalizer; use Testcontainers\Wait\WaitForContainer; use Testcontainers\Wait\WaitStrategy; @@ -144,37 +148,13 @@ class GenericContainer implements TestContainer $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,54 +170,16 @@ class GenericContainer implements TestContainer return $this; } - //TODO: needs refactoring public function start(): StartedGenericContainer { + $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 the image is not found, pull it and try again + $this->pullImage(); return $this->start(); } @@ -252,4 +194,94 @@ class GenericContainer implements TestContainer 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 \ArrayObject> + */ + protected function createPortBindings(): \ArrayObject + { + $portGenerator = new RandomUniquePortGenerator(); + $portBindings = new \ArrayObject(); + + foreach ($this->exposedPorts as $port) { + $portBinding = new PortBinding(); + $portBinding->setHostPort((string)$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/Utils/PortNormalizer.php b/src/Utils/PortNormalizer.php new file mode 100644 index 0000000..d9cded5 --- /dev/null +++ b/src/Utils/PortNormalizer.php @@ -0,0 +1,32 @@ +toDockerNotation()}"; + } + + // Check if the port specification already includes a protocol + if (is_string($port) && !str_contains($port, '/')) { + return "{$port}/{$internetProtocol->toDockerNotation()}"; + } + + return $port; + } +} From 336f459d97cf78a5fb4a24af878d0ff15b697c8a Mon Sep 17 00:00:00 2001 From: Sergei Shitikov Date: Sun, 29 Sep 2024 11:14:22 +0200 Subject: [PATCH 21/27] Initialize default wait strategy in Constructor --- src/Container/GenericContainer.php | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/Container/GenericContainer.php b/src/Container/GenericContainer.php index 6edcf2c..8bb17d7 100644 --- a/src/Container/GenericContainer.php +++ b/src/Container/GenericContainer.php @@ -59,6 +59,7 @@ class GenericContainer implements TestContainer { $this->image = $image; $this->dockerClient = DockerContainerClient::getDockerClient(); + $this->waitStrategy = new WaitForContainer(); } public function getId(): string @@ -185,10 +186,6 @@ class GenericContainer implements TestContainer $this->dockerClient->containerStart($this->id); - if (!isset($this->waitStrategy)) { - $this->withWait(new WaitForContainer()); - } - $startedContainer = new StartedGenericContainer($this->id); $this->waitStrategy->wait($startedContainer); From 2f1c8750e0eb8330b998cfc8a1561e2f59460a99 Mon Sep 17 00:00:00 2001 From: Sergei Shitikov Date: Sun, 29 Sep 2024 13:28:47 +0200 Subject: [PATCH 22/27] Improve retry logic on start(), improved portBindings types --- src/Container/GenericContainer.php | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/Container/GenericContainer.php b/src/Container/GenericContainer.php index 8bb17d7..7413c56 100644 --- a/src/Container/GenericContainer.php +++ b/src/Container/GenericContainer.php @@ -47,6 +47,9 @@ class GenericContainer implements TestContainer protected bool $isPrivileged = false; protected ?string $networkName = null; + protected int $startAttempts = 0; + protected const MAX_START_ATTEMPTS = 2; + /** * @var array */ @@ -173,13 +176,18 @@ class GenericContainer implements TestContainer public function start(): StartedGenericContainer { + $this->startAttempts++; $containerConfig = $this->createContainerConfig(); try { /** @var ContainerCreateResponse|null $containerCreateResponse */ $containerCreateResponse = $this->dockerClient->containerCreate($containerConfig); $this->id = $containerCreateResponse?->getId() ?? ''; } catch (ContainerCreateNotFoundException) { + 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(); } @@ -254,12 +262,12 @@ class GenericContainer implements TestContainer } /** - * @return \ArrayObject> + * @return array> */ - protected function createPortBindings(): \ArrayObject + protected function createPortBindings(): array { $portGenerator = new RandomUniquePortGenerator(); - $portBindings = new \ArrayObject(); + $portBindings = []; foreach ($this->exposedPorts as $port) { $portBinding = new PortBinding(); From a58ff460faeaacd18124be1168618d0b5b926d41 Mon Sep 17 00:00:00 2001 From: Sergei Shitikov Date: Sun, 29 Sep 2024 14:46:54 +0200 Subject: [PATCH 23/27] refactored WaitForHealthCheck strategy and added more granular exceptions --- src/Exception/ContainerException.php | 21 ++++++ src/Exception/ContainerNotReadyException.php | 6 +- src/Exception/ContainerStateException.php | 14 ++++ .../ContainerWaitingTimeoutException.php | 14 +--- src/Exception/HealthCheckFailedException.php | 14 ++++ .../HealthCheckNotConfiguredException.php | 14 ++++ .../UnknownHealthStatusException.php | 14 ++++ src/Wait/WaitForHealthCheck.php | 73 +++++++++++++------ 8 files changed, 131 insertions(+), 39 deletions(-) create mode 100644 src/Exception/ContainerException.php create mode 100644 src/Exception/ContainerStateException.php create mode 100644 src/Exception/HealthCheckFailedException.php create mode 100644 src/Exception/HealthCheckNotConfiguredException.php create mode 100644 src/Exception/UnknownHealthStatusException.php 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 @@ declare(strict_types=1); 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); + parent::__construct($message, $containerId, $previous); } - - public function getContainerId(): string - { - return $this->containerId; - } -} +} \ No newline at end of file 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 @@ + $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(); + + 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()); } - if ($containerStatus === 'unhealthy') { - throw new ContainerNotReadyException(sprintf("Health check failed: %s", $containerStatus)); - } - - usleep($this->pollInterval * 1000); // Sleep for the polling interval + usleep($this->pollInterval * 1000); } } } From 353977c7bb19d8b38476cf83ccbdd5b169111c87 Mon Sep 17 00:00:00 2001 From: Sergei Shitikov Date: Sun, 29 Sep 2024 18:10:01 +0200 Subject: [PATCH 24/27] use FixedPortGenerator for legacy implementations --- src/Container/GenericContainer.php | 14 ++++++++++++-- src/Container/MariaDBContainer.php | 2 ++ src/Container/MySQLContainer.php | 2 ++ src/Container/OpenSearchContainer.php | 2 ++ src/Container/PostgresContainer.php | 2 ++ src/Container/RedisContainer.php | 2 ++ tests/Integration/OldTests/ContainerTest.php | 7 ------- 7 files changed, 22 insertions(+), 9 deletions(-) diff --git a/src/Container/GenericContainer.php b/src/Container/GenericContainer.php index 7413c56..4706afc 100644 --- a/src/Container/GenericContainer.php +++ b/src/Container/GenericContainer.php @@ -17,6 +17,7 @@ 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; @@ -44,6 +45,8 @@ class GenericContainer implements TestContainer protected WaitStrategy $waitStrategy; + protected PortGenerator $portGenerator; + protected bool $isPrivileged = false; protected ?string $networkName = null; @@ -63,6 +66,7 @@ class GenericContainer implements TestContainer $this->image = $image; $this->dockerClient = DockerContainerClient::getDockerClient(); $this->waitStrategy = new WaitForContainer(); + $this->portGenerator = new RandomUniquePortGenerator(); } public function getId(): string @@ -174,6 +178,13 @@ class GenericContainer implements TestContainer return $this; } + public function withPortGenerator(PortGenerator $portGenerator): static + { + $this->portGenerator = $portGenerator; + + return $this; + } + public function start(): StartedGenericContainer { $this->startAttempts++; @@ -266,12 +277,11 @@ class GenericContainer implements TestContainer */ protected function createPortBindings(): array { - $portGenerator = new RandomUniquePortGenerator(); $portBindings = []; foreach ($this->exposedPorts as $port) { $portBinding = new PortBinding(); - $portBinding->setHostPort((string)$portGenerator->generatePort()); + $portBinding->setHostPort((string)$this->portGenerator->generatePort()); $portBinding->setHostIp('0.0.0.0'); $portBindings[$port] = [$portBinding]; } 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 @@ declare(strict_types=1); 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 @@ declare(strict_types=1); 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 @@ declare(strict_types=1); 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 @@ declare(strict_types=1); namespace Testcontainers\Container; +use Testcontainers\Utils\PortGenerator\FixedPortGenerator; use Testcontainers\Wait\WaitForExec; /** @@ -20,6 +21,7 @@ class PostgresContainer extends Container 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 @@ declare(strict_types=1); 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/tests/Integration/OldTests/ContainerTest.php b/tests/Integration/OldTests/ContainerTest.php index ed65412..20b86a8 100644 --- a/tests/Integration/OldTests/ContainerTest.php +++ b/tests/Integration/OldTests/ContainerTest.php @@ -17,13 +17,6 @@ use Testcontainers\Container\RedisContainer; */ 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(); From 90edcd48f4cce15f5f01b1dfd7a2902018320a28 Mon Sep 17 00:00:00 2001 From: Sergei Shitikov Date: Sun, 29 Sep 2024 18:14:00 +0200 Subject: [PATCH 25/27] add forgotten stop() container for opensearch test --- tests/Integration/OldTests/ContainerTest.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/Integration/OldTests/ContainerTest.php b/tests/Integration/OldTests/ContainerTest.php index 20b86a8..0f36535 100644 --- a/tests/Integration/OldTests/ContainerTest.php +++ b/tests/Integration/OldTests/ContainerTest.php @@ -110,6 +110,8 @@ class ContainerTest extends TestCase $this->assertArrayHasKey('cluster_name', $data); $this->assertEquals('docker-cluster', $data['cluster_name']); + + $container->stop(); } public function testPostgreSQLContainer(): void From 49031990a2b599265ccdc4206a0d39595c013f07 Mon Sep 17 00:00:00 2001 From: Sergei Shitikov Date: Sun, 29 Sep 2024 18:48:46 +0200 Subject: [PATCH 26/27] Fixes in WaitForHealthCheck params and legacy tests --- src/Container/Container.php | 3 +++ src/Container/GenericContainer.php | 19 +++++++++++++------ .../Integration/OldTests/WaitStrategyTest.php | 12 +++++++++--- 3 files changed, 25 insertions(+), 9 deletions(-) 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 @@ declare(strict_types=1); namespace Testcontainers\Container; +use Testcontainers\Utils\PortGenerator\FixedPortGenerator; + /** * Added for backward compatibility. * @deprecated Use GenericContainer instead. @@ -52,6 +54,7 @@ class Container extends GenericContainer */ 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 4706afc..326291b 100644 --- a/src/Container/GenericContainer.php +++ b/src/Container/GenericContainer.php @@ -123,12 +123,19 @@ class GenericContainer implements TestContainer 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; } 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 PHPUnit\Framework\TestCase; 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 @@ class WaitStrategyTest extends TestCase public function testWaitForExec(): void { - $container = Container::make('mysql') + $container = MySQLContainer::make() ->withEnvironment('MYSQL_ROOT_PASSWORD', 'root') ->withWait( new WaitForExec([ @@ -52,11 +53,13 @@ class WaitStrategyTest extends TestCase $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 @@ class WaitStrategyTest extends TestCase { $container = Container::make('nginx') ->withHealthCheckCommand('curl --fail http://localhost') + ->withPort('80', '80') ->withWait(new WaitForHealthCheck()); $container->run(); @@ -153,5 +157,7 @@ class WaitStrategyTest extends TestCase $this->assertIsString($response); $this->assertStringContainsString('Welcome to nginx!', $response); + + $container->stop(); } } From e21cea14fc2e89d481db075d672a3e53d39af9f4 Mon Sep 17 00:00:00 2001 From: Sergei Shitikov Date: Sun, 29 Sep 2024 18:50:39 +0200 Subject: [PATCH 27/27] cs fix --- src/Exception/ContainerWaitingTimeoutException.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Exception/ContainerWaitingTimeoutException.php b/src/Exception/ContainerWaitingTimeoutException.php index 62c5803..c7e38bd 100644 --- a/src/Exception/ContainerWaitingTimeoutException.php +++ b/src/Exception/ContainerWaitingTimeoutException.php @@ -11,4 +11,4 @@ class ContainerWaitingTimeoutException extends ContainerNotReadyException $message ??= sprintf('Timeout reached while waiting for container %s', $containerId); parent::__construct($message, $containerId, $previous); } -} \ No newline at end of file +}