From c82e974ab9ab7a52de5781f33bef72b6f448bc22 Mon Sep 17 00:00:00 2001 From: Sergei Shitikov Date: Thu, 22 Aug 2024 16:41:51 +0200 Subject: [PATCH 01/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] - 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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 +} From 1cbfe638b99c4e419d9e3507d039b846b781d67a Mon Sep 17 00:00:00 2001 From: Sergei Shitikov Date: Thu, 24 Oct 2024 19:36:48 +0200 Subject: [PATCH 28/54] implement WaitForHttp and WaitForHostPort strategies --- src/Wait/BaseWaitStrategy.php | 12 ++ src/Wait/WaitForHostPort.php | 51 ++++++ src/Wait/WaitForHttp.php | 155 ++++++++++++------ src/Wait/WaitForTcpPortOpen.php | 43 ++--- .../Integration/OldTests/WaitStrategyTest.php | 48 ++---- 5 files changed, 195 insertions(+), 114 deletions(-) create mode 100644 src/Wait/WaitForHostPort.php diff --git a/src/Wait/BaseWaitStrategy.php b/src/Wait/BaseWaitStrategy.php index 80ff748..df4da26 100644 --- a/src/Wait/BaseWaitStrategy.php +++ b/src/Wait/BaseWaitStrategy.php @@ -13,4 +13,16 @@ abstract class BaseWaitStrategy implements WaitStrategy } abstract public function wait(StartedTestContainer $container): void; + + public function withTimeout(int $timeout): static + { + $this->timeout = $timeout; + return $this; + } + + public function withPollInterval(int $pollInterval): static + { + $this->pollInterval = $pollInterval; + return $this; + } } diff --git a/src/Wait/WaitForHostPort.php b/src/Wait/WaitForHostPort.php new file mode 100644 index 0000000..d9f7f86 --- /dev/null +++ b/src/Wait/WaitForHostPort.php @@ -0,0 +1,51 @@ +getHost(); + + while (true) { + $elapsedTime = (microtime(true) * 1000) - $startTime; + + if ($elapsedTime > $this->timeout) { + throw new ContainerWaitingTimeoutException($container->getId()); + } + + if ($this->isPortOpen($containerAddress, $this->port)) { + return; // Port is open, container is ready + } + + usleep($this->pollInterval * 1000); // Wait for the next polling interval + } + } + + private function isPortOpen(string $ipAddress, int $port): bool + { + $connection = @fsockopen($ipAddress, $port, $errno, $errstr, 2); + + if ($connection !== false) { + fclose($connection); + return true; + } + + return false; + } +} diff --git a/src/Wait/WaitForHttp.php b/src/Wait/WaitForHttp.php index 87a840b..dac2b0c 100644 --- a/src/Wait/WaitForHttp.php +++ b/src/Wait/WaitForHttp.php @@ -4,83 +4,146 @@ declare(strict_types=1); namespace Testcontainers\Wait; -use Docker\Docker; -use Testcontainers\Exception\ContainerNotReadyException; +use Testcontainers\Container\HttpMethod; +use Testcontainers\Container\StartedTestContainer; +use Testcontainers\Exception\ContainerWaitingTimeoutException; -//TODO: not ready yet -class WaitForHttp implements WaitStrategy +class WaitForHttp extends BaseWaitStrategy { - public const METHOD_GET = 'GET'; - public const METHOD_POST = 'POST'; - public const METHOD_PUT = 'PUT'; - public const METHOD_DELETE = 'DELETE'; - public const METHOD_HEAD = 'HEAD'; - public const METHOD_OPTIONS = 'OPTIONS'; + protected HttpMethod $method = HttpMethod::GET; + protected string $path = '/'; - private string $method = 'GET'; - private string $path = '/'; - private int $statusCode = 200; - private Docker $dockerClient; + protected string $protocol = 'http'; - public function __construct(private int $port) - { - $this->dockerClient = Docker::create(); - } + protected int $expectedStatusCode = 200; - public static function make(int $port): self - { - return new WaitForHttp($port); + protected bool $allowInsecure = false; + + /** + * @var array + */ + protected array $headers = []; + + /** + * @var int Timeout in milliseconds for reading the response + */ + protected int $readTimeout = 1000; + + public function __construct( + protected int $port, + int $timeout = 10000, + int $pollInterval = 500 + ) { + parent::__construct($timeout, $pollInterval); } /** - * @param WaitForHttp::METHOD_* $method + * @deprecated Use constructor instead + * Kept for backward compatibility + * Should be removed in next major version */ - public function withMethod(string $method): self + public static function make(int $port): self { - $this->method = $method; + return new self($port); + } + public function withMethod(HttpMethod | string $method): self + { + if (is_string($method)) { + $method = HttpMethod::fromString($method); + } + $this->method = $method; return $this; } public function withPath(string $path): self { $this->path = $path; - return $this; } - public function withStatusCode(int $statusCode): self + public function withExpectedStatusCode(int $statusCode): self { - $this->statusCode = $statusCode; - + $this->expectedStatusCode = $statusCode; return $this; } - public function wait(string $id): void + public function usingHttps(): self { - $containerNetworks = $this->dockerClient->containerInspect($id)->getNetworkSettings()->getNetworks(); - $containerAddress = null; - foreach ($containerNetworks as $network) { - if ($network->getNetworkID() === $id) { - $containerAddress = $network->getIpAddress(); - break; + $this->protocol = 'https'; + return $this; + } + + public function allowInsecure(): self + { + $this->allowInsecure = true; + return $this; + } + + public function withReadTimeout(int $timeout): self + { + $this->readTimeout = $timeout; + return $this; + } + + /** + * @param array $headers + */ + public function withHeaders(array $headers): self + { + $this->headers = $headers; + return $this; + } + + public function wait(StartedTestContainer $container): void + { + $startTime = microtime(true) * 1000; + + while (true) { + $elapsedTime = (microtime(true) * 1000) - $startTime; + + if ($elapsedTime > $this->timeout) { + throw new ContainerWaitingTimeoutException($container->getId()); } + + $containerAddress = $container->getHost(); + + $url = sprintf('%s://%s:%d%s', $this->protocol, $containerAddress, $this->port, $this->path); + $responseCode = $this->makeHttpRequest($url); + + if ($responseCode === $this->expectedStatusCode) { + return; // Container is ready + } + + usleep($this->pollInterval * 1000); + } + } + + private function makeHttpRequest(string $url): int + { + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, $url); + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $this->method->value); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_HEADER, true); + curl_setopt($ch, CURLOPT_NOBODY, true); // No need for response body, just headers + curl_setopt($ch, CURLOPT_TIMEOUT_MS, $this->readTimeout); + + // Allow insecure connections if requested + if ($this->allowInsecure) { + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); } - $ch = curl_init(); - curl_setopt($ch, CURLOPT_URL, sprintf('http://%s:%d%s', $containerAddress, $this->port, $this->path)); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $this->method); - curl_setopt($ch, CURLOPT_HEADER, true); - curl_setopt($ch, CURLOPT_NOBODY, true); + // Add custom headers + if (!empty($this->headers)) { + curl_setopt($ch, CURLOPT_HTTPHEADER, array_map(static fn ($k, $v) => "$k: $v", array_keys($this->headers), $this->headers)); + } curl_exec($ch); - - if (curl_getinfo($ch, CURLINFO_HTTP_CODE) !== $this->statusCode) { - throw new ContainerNotReadyException($id, new \RuntimeException('HTTP status code does not match')); - } - + $responseCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); + + return $responseCode; } } diff --git a/src/Wait/WaitForTcpPortOpen.php b/src/Wait/WaitForTcpPortOpen.php index 9545a88..a65cebe 100644 --- a/src/Wait/WaitForTcpPortOpen.php +++ b/src/Wait/WaitForTcpPortOpen.php @@ -4,42 +4,23 @@ declare(strict_types=1); namespace Testcontainers\Wait; -use Docker\Docker; -use JsonException; -use RuntimeException; -use Testcontainers\Exception\ContainerNotReadyException; - -//TODO: not ready yet -final class WaitForTcpPortOpen implements WaitStrategy +/** + * @deprecated Use WaitForHostPort instead + * Kept for backward compatibility + * Should be removed in next major version + */ +final class WaitForTcpPortOpen extends WaitForHostPort { - private Docker $dockerClient; - - public function __construct(private readonly int $port, private readonly ?string $network = null) + /** + * @phpstan-ignore-next-line + */ + public function __construct(int $port, string $network = null) { - $this->dockerClient = Docker::create(); + parent::__construct($port); } public static function make(int $port, ?string $network = null): self { - return new self($port, $network); - } - - /** - * @throws JsonException - */ - public function wait(string $id): void - { - $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')); - } + return new self($port); } } diff --git a/tests/Integration/OldTests/WaitStrategyTest.php b/tests/Integration/OldTests/WaitStrategyTest.php index 52a6d47..25ff3ec 100644 --- a/tests/Integration/OldTests/WaitStrategyTest.php +++ b/tests/Integration/OldTests/WaitStrategyTest.php @@ -21,12 +21,6 @@ use Testcontainers\Wait\WaitForTcpPortOpen; */ class WaitStrategyTest extends TestCase { - //TODO: remove after check - protected function setUp(): void - { - $this->markTestIncomplete(); - } - public function testWaitForExec(): void { $container = MySQLContainer::make() @@ -86,12 +80,13 @@ class WaitStrategyTest extends TestCase public function testWaitForHTTP(): void { $container = Container::make('nginx:alpine') - ->withWait(WaitForHttp::make(80)); + ->withWait(WaitForHttp::make(3000)) + ->withPort('3000', '80'); $container->run(); $ch = curl_init(); - curl_setopt($ch, CURLOPT_URL, sprintf('http://%s:%d', $container->getAddress(), 80)); + curl_setopt($ch, CURLOPT_URL, sprintf('http://%s:%d', $container->getAddress(), $container->getPort())); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); $response = (string) curl_exec($ch); @@ -99,42 +94,21 @@ class WaitStrategyTest extends TestCase curl_close($ch); $this->assertNotEmpty($response); + + $container->stop(); } - /** - * @dataProvider provideWaitForTcpPortOpen - */ - public function testWaitForTcpPortOpen(bool $wait): void + public function testWaitForTcpPortOpen(): void { - $container = Container::make('nginx:alpine'); - - if ($wait) { - $container->withWait(WaitForTcpPortOpen::make(80)); - } + $container = Container::make('nginx:alpine') + ->withWait(WaitForTcpPortOpen::make(80)) + ->withPort('80', '80'); $container->run(); - if ($wait) { - static::assertIsResource(fsockopen($container->getAddress(), 80), 'Failed to connect to container'); - return; - } + static::assertIsResource(fsockopen($container->getAddress(), 80), 'Failed to connect to container'); - $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], - ]; + $container->stop(); } public function testWaitForHealthCheck(): void From 5b42abf081c4aa34336490532a14691fe90d3cc6 Mon Sep 17 00:00:00 2001 From: Sergei Shitikov Date: Thu, 24 Oct 2024 19:55:06 +0200 Subject: [PATCH 29/54] Added missing HttpMethod enum --- src/Container/HttpMethod.php | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 src/Container/HttpMethod.php diff --git a/src/Container/HttpMethod.php b/src/Container/HttpMethod.php new file mode 100644 index 0000000..628f5d4 --- /dev/null +++ b/src/Container/HttpMethod.php @@ -0,0 +1,28 @@ + self::GET, + 'POST' => self::POST, + 'PUT' => self::PUT, + 'DELETE' => self::DELETE, + 'HEAD' => self::HEAD, + 'OPTIONS' => self::OPTIONS, + default => throw new \InvalidArgumentException("Invalid HTTP method: $method"), + }; + } +} From 662acfefa102edb63cbe9e2cf3b754b63edc7584 Mon Sep 17 00:00:00 2001 From: Sergei Shitikov Date: Sun, 27 Oct 2024 21:32:47 +0100 Subject: [PATCH 30/54] Update src/Container/HttpMethod.php improve fromString Co-authored-by: Jacob Dreesen --- src/Container/HttpMethod.php | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/src/Container/HttpMethod.php b/src/Container/HttpMethod.php index 628f5d4..4950c16 100644 --- a/src/Container/HttpMethod.php +++ b/src/Container/HttpMethod.php @@ -15,14 +15,6 @@ enum HttpMethod: string public static function fromString(string $method): self { - return match (strtoupper($method)) { - 'GET' => self::GET, - 'POST' => self::POST, - 'PUT' => self::PUT, - 'DELETE' => self::DELETE, - 'HEAD' => self::HEAD, - 'OPTIONS' => self::OPTIONS, - default => throw new \InvalidArgumentException("Invalid HTTP method: $method"), - }; + return self::tryFrom(strtoupper($method)) ?? throw new \InvalidArgumentException("Invalid HTTP method: $method"); } } From 7d5f52b6d52f8ac21c20ebb1b45b53cf419fd6a7 Mon Sep 17 00:00:00 2001 From: Sergei Shitikov Date: Sun, 27 Oct 2024 21:57:24 +0100 Subject: [PATCH 31/54] restore the doc-block for withMethod() in WaitForHttp --- src/Wait/WaitForHttp.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Wait/WaitForHttp.php b/src/Wait/WaitForHttp.php index dac2b0c..3ae06c2 100644 --- a/src/Wait/WaitForHttp.php +++ b/src/Wait/WaitForHttp.php @@ -48,6 +48,9 @@ class WaitForHttp extends BaseWaitStrategy return new self($port); } + /** + * @param HttpMethod|value-of $method + */ public function withMethod(HttpMethod | string $method): self { if (is_string($method)) { From a1d29e4dac94f4d161cb66f95ccbb48f2429cb02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20W=C3=BCnsche?= Date: Wed, 13 Nov 2024 10:33:46 +0100 Subject: [PATCH 32/54] Implements missed methods in StartedGenericContainer.php --- src/Container/StartedGenericContainer.php | 106 +++++++++++++--------- src/Modules/MySQLContainer.php | 2 + 2 files changed, 67 insertions(+), 41 deletions(-) diff --git a/src/Container/StartedGenericContainer.php b/src/Container/StartedGenericContainer.php index ec81e71..5604eb2 100644 --- a/src/Container/StartedGenericContainer.php +++ b/src/Container/StartedGenericContainer.php @@ -10,7 +10,9 @@ use Docker\API\Model\IdResponse; use Docker\API\Runtime\Client\Client as DockerRuntimeClient; use Docker\Docker; use Psr\Http\Message\ResponseInterface; +use RuntimeException; use Testcontainers\ContainerClient\DockerContainerClient; +use Throwable; class StartedGenericContainer implements StartedTestContainer { @@ -54,7 +56,7 @@ class StartedGenericContainer implements StartedTestContainer $exec = $this->dockerClient->containerExec($this->id, $execConfig); if ($exec === null || $exec->getId() === null) { - throw new \RuntimeException('Failed to create exec command'); + throw new RuntimeException('Failed to create exec command'); } $this->lastExecId = $exec->getId(); @@ -96,45 +98,24 @@ class StartedGenericContainer implements StartedTestContainer 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'; + return $this->inspect()['NetworkSettings']['Gateway'] ?? '127.0.0.1'; } - //TODO: not ready yet public function getMappedPort(int $port): int { - return $this->inspect()->ports[$port]; + $ports = $this->ports(); + if (isset($ports["{$port}/tcp"][0]['HostPort'])) { + return (int) $ports["{$port}/tcp"][0]['HostPort']; + } + + throw new RuntimeException("Failed to get mapped port $port for container"); } - /** - * @throws \JsonException - */ public function getFirstMappedPort(): int { - //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 = $this->ports(); $port = array_key_first($ports); return (int) $ports[$port][0]['HostPort']; @@ -142,32 +123,75 @@ class StartedGenericContainer implements StartedTestContainer public function getName(): string { - // TODO: Implement getName() method. - return ''; + return trim($this->inspect()['Name'], '/ '); } + /** + * @return string[] + */ public function getLabels(): array { - // TODO: Implement getLabels() method. - return []; + return $this->inspect()['Config']['Labels'] ?? []; } - + /** + * @return string[] + */ public function getNetworkNames(): array { - // TODO: Implement getNetworkNames() method. - return []; + $networks = $this->inspect()['NetworkSettings']['Networks'] ?? []; + return array_keys($networks); } public function getNetworkId(string $networkName): string { - // TODO: Implement getNetworkId() method. - return ''; + $networks = $this->inspect()['NetworkSettings']['Networks']; + if (isset($networks[$networkName])) { + return $networks[$networkName]['NetworkID']; + } + throw new RuntimeException("Network with name {$networkName} not exists"); } public function getIpAddress(string $networkName): string { - // TODO: Implement getIpAddress() method. - return ''; + $networks = $this->inspect()['NetworkSettings']['Networks']; + if (isset($networks[$networkName])) { + return $networks[$networkName]['IPAddress']; + } + throw new RuntimeException("Network with name {$networkName} not exists"); + } + + private function inspect(): array + { + //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'); + } + + try { + return json_decode( + $containerInspectResponse->getBody()->getContents(), + true, + 512, + JSON_THROW_ON_ERROR + ); + } catch (Throwable $exception) { + throw new RuntimeException('Failed to inspect container', 0, $exception); + } + } + + private function ports(): array + { + /** @var array>> $ports */ + $ports = $this->inspect()['NetworkSettings']['Ports'] ?? []; + + if ($ports === []) { + throw new RuntimeException('Failed to get ports from container'); + } + + return $ports; } } diff --git a/src/Modules/MySQLContainer.php b/src/Modules/MySQLContainer.php index 00c6a90..82b673b 100644 --- a/src/Modules/MySQLContainer.php +++ b/src/Modules/MySQLContainer.php @@ -17,6 +17,8 @@ class MySQLContainer extends GenericContainer $this->withWait(new WaitForExec([ "mysqladmin", "ping", + "-u", "root", + "-p{$mysqlRootPassword}", "-h", "127.0.0.1", ])); } From e663bbfbcc533cee9afff2c8913afff4824d4026 Mon Sep 17 00:00:00 2001 From: Sergei Shitikov Date: Sat, 28 Dec 2024 13:26:52 +0100 Subject: [PATCH 33/54] add withHostname, withName, withLabels support --- src/Container/Container.php | 10 ------- src/Container/GenericContainer.php | 48 ++++++++++++++++++++++++++++-- 2 files changed, 46 insertions(+), 12 deletions(-) diff --git a/src/Container/Container.php b/src/Container/Container.php index 2dd943f..ade3661 100644 --- a/src/Container/Container.php +++ b/src/Container/Container.php @@ -31,16 +31,6 @@ class Container extends GenericContainer 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 */ diff --git a/src/Container/GenericContainer.php b/src/Container/GenericContainer.php index 326291b..efbea98 100644 --- a/src/Container/GenericContainer.php +++ b/src/Container/GenericContainer.php @@ -29,6 +29,16 @@ class GenericContainer implements TestContainer protected string $image; + protected ?string $name = null; + + /** + * User-defined key/value metadata. + * @param array|null $labels + */ + protected ?array $labels = null; + + protected ?string $hostname = null; + protected string $id; /** @var list */ @@ -140,9 +150,20 @@ class GenericContainer implements TestContainer return $this; } + public function withHostname(string $hostname): static + { + $this->hostname = $hostname; + + return $this; + } + public function withMount(string $localPath, string $containerPath): static { - $this->mounts[] = new Mount(['type' => 'bind', 'source' => $localPath, 'target' => $containerPath]); + $this->mounts[] = new Mount([ + 'type' => 'bind', + 'source' => $localPath, + 'target' => $containerPath, + ]); return $this; } @@ -170,6 +191,23 @@ class GenericContainer implements TestContainer return $this; } + public function withName(string $name): static + { + $this->name = $name; + + return $this; + } + + /** + * @param array $labels + */ + public function withLabels(array $labels): static + { + $this->labels = $labels; + + return $this; + } + public function withPrivilegedMode(bool $privileged = true): static { $this->isPrivileged = $privileged; @@ -196,9 +234,13 @@ class GenericContainer implements TestContainer { $this->startAttempts++; $containerConfig = $this->createContainerConfig(); + $queryParameters = []; + if ($this->name !== null) { + $queryParameters['name'] = $this->name; + } try { /** @var ContainerCreateResponse|null $containerCreateResponse */ - $containerCreateResponse = $this->dockerClient->containerCreate($containerConfig); + $containerCreateResponse = $this->dockerClient->containerCreate($containerConfig, $queryParameters); $this->id = $containerCreateResponse?->getId() ?? ''; } catch (ContainerCreateNotFoundException) { if ($this->startAttempts >= self::MAX_START_ATTEMPTS) { @@ -223,6 +265,8 @@ class GenericContainer implements TestContainer $containerCreatePostBody = new ContainersCreatePostBody(); $containerCreatePostBody->setImage($this->image); $containerCreatePostBody->setCmd($this->command); + $containerCreatePostBody->setLabels($this->labels); + $containerCreatePostBody->setHostname($this->hostname); $envs = array_map(static fn ($key, $value) => "$key=$value", array_keys($this->env), $this->env); $containerCreatePostBody->setEnv($envs); From 774743a7cec40155494ed230414cb79eeffeb2ba Mon Sep 17 00:00:00 2001 From: Sergei Shitikov Date: Sat, 28 Dec 2024 18:57:23 +0100 Subject: [PATCH 34/54] update interfaces, small phpstan improvements --- src/Container/GenericContainer.php | 2 +- src/Container/StartedGenericContainer.php | 1 - src/Container/StartedTestContainer.php | 52 +++++++++++------------ src/Container/TestContainer.php | 38 +++++++++++++---- 4 files changed, 57 insertions(+), 36 deletions(-) diff --git a/src/Container/GenericContainer.php b/src/Container/GenericContainer.php index efbea98..ea85e7d 100644 --- a/src/Container/GenericContainer.php +++ b/src/Container/GenericContainer.php @@ -33,7 +33,7 @@ class GenericContainer implements TestContainer /** * User-defined key/value metadata. - * @param array|null $labels + * @var array|null $labels */ protected ?array $labels = null; diff --git a/src/Container/StartedGenericContainer.php b/src/Container/StartedGenericContainer.php index ec81e71..5e45977 100644 --- a/src/Container/StartedGenericContainer.php +++ b/src/Container/StartedGenericContainer.php @@ -23,7 +23,6 @@ class StartedGenericContainer implements StartedTestContainer $this->dockerClient = DockerContainerClient::getDockerClient(); } - public function getId(): string { return $this->id; diff --git a/src/Container/StartedTestContainer.php b/src/Container/StartedTestContainer.php index 8eefcdf..3a75b0b 100644 --- a/src/Container/StartedTestContainer.php +++ b/src/Container/StartedTestContainer.php @@ -8,36 +8,36 @@ use Docker\Docker; interface StartedTestContainer { - public function stop(): StoppedTestContainer; - - public function restart(): self; - - public function getClient(): Docker; - - public function getHost(): string; - - public function getFirstMappedPort(): int; - - public function getMappedPort(int $port): int; - - public function getName(): string; - - public function getLabels(): array; - - public function getId(): string; - - public function getLastExecId(): string | null; - - public function getNetworkNames(): array; - - public function getNetworkId(string $networkName): string; - - public function getIpAddress(string $networkName): string; - /** * @param list $command */ public function exec(array $command): string; + public function getClient(): Docker; + + public function getFirstMappedPort(): int; + + public function getHost(): string; + + public function getId(): string; + + public function getIpAddress(string $networkName): string; + + public function getLabels(): array; + public function logs(): string; + + public function getLastExecId(): string | null; + + public function getMappedPort(int $port): int; + + public function getName(): string; + + public function getNetworkId(string $networkName): string; + + public function getNetworkNames(): array; + + public function restart(): self; + + public function stop(): StoppedTestContainer; } diff --git a/src/Container/TestContainer.php b/src/Container/TestContainer.php index e5ec39c..2885e62 100644 --- a/src/Container/TestContainer.php +++ b/src/Container/TestContainer.php @@ -4,18 +4,13 @@ declare(strict_types=1); namespace Testcontainers\Container; +use Testcontainers\Utils\PortGenerator\PortGenerator; use Testcontainers\Wait\WaitStrategy; interface TestContainer { public function start(): StartedGenericContainer; - /** - * TODO: replace with array after deprecated implementation is removed - * @param array|string $env - */ - public function withEnvironment(array | string $env, ?string $value): static; - /** * @param array $command */ @@ -23,12 +18,39 @@ interface TestContainer public function withEntrypoint(string $entryPoint): static; + /** + * TODO: replace with array after deprecated implementation is removed + * @param array|string $env + */ + public function withEnvironment(array | string $env, ?string $value): 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 withHealthCheckCommand( + string $command, + int $intervalInMilliseconds, + int $timeoutInMilliseconds, + int $retries, + int $startPeriodInMilliseconds + ): static; + + public function withHostname(string $hostname): static; + + /** + * @param array $labels + */ + public function withLabels(array $labels): static; + + public function withMount(string $localPath, string $containerPath): static; + + public function withName(string $name): static; public function withNetwork(string $networkName): static; - public function withPrivilegedMode(): static; + public function withPortGenerator(PortGenerator $portGenerator): static; + + public function withPrivilegedMode(bool $privileged): static; + + public function withWait(WaitStrategy $waitStrategy): static; } From dfff7ef28296b451898d201675c1e4ff7df7c839 Mon Sep 17 00:00:00 2001 From: Sergei Shitikov Date: Wed, 1 Jan 2025 23:29:57 +0100 Subject: [PATCH 35/54] test improvements --- composer.json | 5 +- tests/Fixtures/Docker/test.txt | 1 + tests/Integration/ContainerTestCase.php | 5 +- tests/Integration/GenericContainerTest.php | 110 ++++++++++++++++-- tests/Integration/MariaDBContainerTest.php | 8 +- tests/Integration/MySQLContainerTest.php | 8 +- tests/Integration/OldTests/ContainerTest.php | 1 + .../Integration/OldTests/WaitStrategyTest.php | 1 + tests/Integration/OpenSearchContainerTest.php | 8 +- tests/Integration/PostgreSQLContainerTest.php | 8 +- tests/Integration/RedisContainerTest.php | 8 +- 11 files changed, 129 insertions(+), 34 deletions(-) create mode 100644 tests/Fixtures/Docker/test.txt diff --git a/composer.json b/composer.json index 5474d25..773c728 100644 --- a/composer.json +++ b/composer.json @@ -23,7 +23,7 @@ "ext-pdo_mysql": "*", "ext-pdo_pgsql": "*", "phpunit/phpunit": "^9.5", - "brianium/paratest": "^6.6", + "brianium/paratest": "^6.11", "friendsofphp/php-cs-fixer": "^3.12", "phpstan/phpstan": "^1.8", "phpstan/phpstan-phpunit": "^1.1", @@ -41,7 +41,8 @@ } }, "scripts": { - "integration": "paratest tests/ --bootstrap vendor/autoload.php -f", + "integration": "paratest tests/ --exclude-group=legacy --bootstrap vendor/autoload.php -f", + "integration:old": "phpunit tests/Integration/OldTests --bootstrap vendor/autoload.php", "cs": "php-cs-fixer fix --dry-run", "cs:fix": "php-cs-fixer fix", "phpstan": "phpstan analyse" diff --git a/tests/Fixtures/Docker/test.txt b/tests/Fixtures/Docker/test.txt new file mode 100644 index 0000000..95d09f2 --- /dev/null +++ b/tests/Fixtures/Docker/test.txt @@ -0,0 +1 @@ +hello world \ No newline at end of file diff --git a/tests/Integration/ContainerTestCase.php b/tests/Integration/ContainerTestCase.php index 0a30baf..bbe9d84 100644 --- a/tests/Integration/ContainerTestCase.php +++ b/tests/Integration/ContainerTestCase.php @@ -9,10 +9,11 @@ use Testcontainers\Container\StartedTestContainer; abstract class ContainerTestCase extends TestCase { - protected static StartedTestContainer $container; + protected StartedTestContainer $container; protected function tearDown(): void { - self::$container->stop(); + $this->container->stop(); + parent::tearDown(); } } diff --git a/tests/Integration/GenericContainerTest.php b/tests/Integration/GenericContainerTest.php index 4fc644f..7590db3 100644 --- a/tests/Integration/GenericContainerTest.php +++ b/tests/Integration/GenericContainerTest.php @@ -4,20 +4,110 @@ declare(strict_types=1); namespace Testcontainers\Tests\Integration; +use Docker\API\Model\ContainersIdJsonGetResponse200; +use PHPUnit\Framework\TestCase; use Testcontainers\Container\GenericContainer; +use Testcontainers\Utils\PortGenerator\FixedPortGenerator; +use Testcontainers\Wait\WaitForHostPort; -class GenericContainerTest extends ContainerTestCase +class GenericContainerTest extends TestCase { - public static function setUpBeforeClass(): void - { - self::$container = (new GenericContainer('alpine')) - ->withCommand(['tail', '-f', '/dev/null']) - ->start(); - } - public function testExec(): void { - $actual = self::$container->exec(['echo', 'testcontainers']); - self::assertSame('testcontainers', $actual); + $container = (new GenericContainer('alpine')) + ->withCommand(['tail', '-f', '/dev/null']) + ->start(); + $result = $container->exec(['echo', 'testcontainers']); + + self::assertSame('testcontainers', $result); + + $container->stop(); + } + + /** + * @throws \JsonException + */ + public function testShouldReturnFirstMappedPort(): void + { + $container = (new GenericContainer('nginx')) + ->withPortGenerator(new FixedPortGenerator([8080])) + ->withExposedPorts(80) + ->withWait(new WaitForHostPort(8080)) + ->start(); + $firstMappedPort = $container->getFirstMappedPort(); + + self::assertSame($firstMappedPort, 8080, 'First mapped port does not match 8080'); + + $container->stop(); + } + + public function testShouldCaptureStderrWhenCommandFails(): void + { + $container = (new GenericContainer('alpine')) + ->withCommand(['tail', '-f', '/dev/null']) + ->start(); + $result = $container->exec(['ls', '/nonexistent/path']); + + self::assertStringContainsString('No such file or directory', $result, 'Expected stderr in the output'); + + $container->stop(); + } + + public function testShouldSetEnvironmentVariables(): void + { + $container = (new GenericContainer('alpine')) + ->withCommand(['tail', '-f', '/dev/null']) + ->withEnvironment(['TEST_ENV' => 'testValue']) + ->start(); + $output = $container->exec(['env']); + + self::assertStringContainsString('TEST_ENV=testValue', $output); + + $container->stop(); + } + + public function testShouldSetEntrypoint(): void + { + $container = (new GenericContainer('cristianrgreco/testcontainer:1.1.14')) + ->withEntrypoint('node') + ->withCommand(['index.js']) + ->withExposedPorts(8080) + ->start(); + + /** @var ContainersIdJsonGetResponse200|null $inspectResult */ + $inspectResult = $container->getClient()->containerInspect($container->getId()); + $entrypoint = $inspectResult?->getConfig()?->getEntrypoint() ?? []; + + self::assertContains('node', $entrypoint); + + $container->stop(); + } + + public function testShouldSetMount(): void + { + $localPath = __DIR__ . '/../Fixtures/Docker'; + $containerPath = '/mnt/test-data'; + + $container = (new GenericContainer('alpine')) + ->withMount($localPath, $containerPath) + ->withCommand(['tail', '-f', '/dev/null']) + ->start(); + + $result = $container->exec(["cat", $containerPath.'/test.txt']); + self::assertSame('hello world', $result); + } + + public function testShouldSetPrivilegedMode(): void + { + $container = (new GenericContainer('alpine')) + ->withPrivilegedMode() + ->withCommand(['tail', '-f', '/dev/null']) + ->start(); + + /** @var ContainersIdJsonGetResponse200|null $inspectResult */ + $inspectResult = $container->getClient()->containerInspect($container->getId()); + $privileged = $inspectResult?->getHostConfig()?->getPrivileged(); + + self::assertTrue($privileged); } } diff --git a/tests/Integration/MariaDBContainerTest.php b/tests/Integration/MariaDBContainerTest.php index b2ad631..f88b359 100644 --- a/tests/Integration/MariaDBContainerTest.php +++ b/tests/Integration/MariaDBContainerTest.php @@ -8,9 +8,9 @@ use Testcontainers\Modules\MariaDBContainer; class MariaDBContainerTest extends ContainerTestCase { - public static function setUpBeforeClass(): void + public function setUp(): void { - self::$container = (new MariaDBContainer()) + $this->container = (new MariaDBContainer()) ->withMariaDBDatabase('foo') ->withMariaDBUser('bar', 'baz') ->start(); @@ -21,8 +21,8 @@ class MariaDBContainerTest extends ContainerTestCase $pdo = new \PDO( sprintf( 'mysql:host=%s;port=%d', - self::$container->getHost(), - self::$container->getFirstMappedPort() + $this->container->getHost(), + $this->container->getFirstMappedPort() ), 'bar', 'baz', diff --git a/tests/Integration/MySQLContainerTest.php b/tests/Integration/MySQLContainerTest.php index c88f911..8d3f867 100644 --- a/tests/Integration/MySQLContainerTest.php +++ b/tests/Integration/MySQLContainerTest.php @@ -8,9 +8,9 @@ use Testcontainers\Modules\MySQLContainer; class MySQLContainerTest extends ContainerTestCase { - public static function setUpBeforeClass(): void + public function setUp(): void { - self::$container = (new MySQLContainer()) + $this->container = (new MySQLContainer()) ->withMySQLDatabase('foo') ->withMySQLUser('bar', 'baz') ->start(); @@ -21,8 +21,8 @@ class MySQLContainerTest extends ContainerTestCase $pdo = new \PDO( sprintf( 'mysql:host=%s;port=%d', - self::$container->getHost(), - self::$container->getFirstMappedPort() + $this->container->getHost(), + $this->container->getFirstMappedPort() ), 'bar', 'baz', diff --git a/tests/Integration/OldTests/ContainerTest.php b/tests/Integration/OldTests/ContainerTest.php index 0f36535..74c16d1 100644 --- a/tests/Integration/OldTests/ContainerTest.php +++ b/tests/Integration/OldTests/ContainerTest.php @@ -13,6 +13,7 @@ use Testcontainers\Container\PostgresContainer; use Testcontainers\Container\RedisContainer; /** + * @group legacy * Old test classes kept to check backward compatibility */ class ContainerTest extends TestCase diff --git a/tests/Integration/OldTests/WaitStrategyTest.php b/tests/Integration/OldTests/WaitStrategyTest.php index 25ff3ec..a7c63cb 100644 --- a/tests/Integration/OldTests/WaitStrategyTest.php +++ b/tests/Integration/OldTests/WaitStrategyTest.php @@ -17,6 +17,7 @@ use Testcontainers\Wait\WaitForLog; use Testcontainers\Wait\WaitForTcpPortOpen; /** + * @group legacy * Old test classes kept to check backward compatibility */ class WaitStrategyTest extends TestCase diff --git a/tests/Integration/OpenSearchContainerTest.php b/tests/Integration/OpenSearchContainerTest.php index 19c34aa..b1134a5 100644 --- a/tests/Integration/OpenSearchContainerTest.php +++ b/tests/Integration/OpenSearchContainerTest.php @@ -8,9 +8,9 @@ use Testcontainers\Modules\OpenSearchContainer; class OpenSearchContainerTest extends ContainerTestCase { - public static function setUpBeforeClass(): void + public function setUp(): void { - self::$container = (new OpenSearchContainer()) + $this->container = (new OpenSearchContainer()) ->withDisabledSecurityPlugin() ->start(); } @@ -23,8 +23,8 @@ class OpenSearchContainerTest extends ContainerTestCase $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, sprintf( 'http://%s:%d', - self::$container->getHost(), - self::$container->getFirstMappedPort() + $this->container->getHost(), + $this->container->getFirstMappedPort() )); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); diff --git a/tests/Integration/PostgreSQLContainerTest.php b/tests/Integration/PostgreSQLContainerTest.php index e1f81c5..31e23cf 100644 --- a/tests/Integration/PostgreSQLContainerTest.php +++ b/tests/Integration/PostgreSQLContainerTest.php @@ -8,9 +8,9 @@ use Testcontainers\Modules\PostgresContainer; class PostgreSQLContainerTest extends ContainerTestCase { - public static function setUpBeforeClass(): void + public function setUp(): void { - self::$container = (new PostgresContainer()) + $this->container = (new PostgresContainer()) ->withPostgresUser('bar') ->withPostgresDatabase('foo') ->start(); @@ -21,8 +21,8 @@ class PostgreSQLContainerTest extends ContainerTestCase $pdo = new \PDO( sprintf( 'pgsql:host=%s;port=%d;dbname=foo', - self::$container->getHost(), - self::$container->getFirstMappedPort() + $this->container->getHost(), + $this->container->getFirstMappedPort() ), 'bar', 'test', diff --git a/tests/Integration/RedisContainerTest.php b/tests/Integration/RedisContainerTest.php index 0379dce..3e207d4 100644 --- a/tests/Integration/RedisContainerTest.php +++ b/tests/Integration/RedisContainerTest.php @@ -9,17 +9,17 @@ use Testcontainers\Modules\RedisContainer; class RedisContainerTest extends ContainerTestCase { - public static function setUpBeforeClass(): void + public function setUp(): void { - self::$container = (new RedisContainer()) + $this->container = (new RedisContainer()) ->start(); } public function testRedisContainer(): void { $redisClient = new Client([ - 'host' => self::$container->getHost(), - 'port' => self::$container->getFirstMappedPort(), + 'host' => $this->container->getHost(), + 'port' => $this->container->getFirstMappedPort(), ]); $redisClient->ping(); From 53c67a15ed325eeca2e5047aaa94a47fa371b5de Mon Sep 17 00:00:00 2001 From: Sergei Shitikov Date: Sun, 19 Jan 2025 23:12:19 +0100 Subject: [PATCH 36/54] added withCopy* functionality to copy data into containers. Additional tests and improvements --- src/Container/GenericContainer.php | 145 +++++++++- src/Utils/TarBuilder.php | 293 +++++++++++++++++++++ tests/Integration/GenericContainerTest.php | 175 ++++++++++++ tests/Unit/Utils/TarBuilderTest.php | 218 +++++++++++++++ 4 files changed, 830 insertions(+), 1 deletion(-) create mode 100644 src/Utils/TarBuilder.php create mode 100644 tests/Unit/Utils/TarBuilderTest.php diff --git a/src/Container/GenericContainer.php b/src/Container/GenericContainer.php index ea85e7d..8afa2b1 100644 --- a/src/Container/GenericContainer.php +++ b/src/Container/GenericContainer.php @@ -16,10 +16,12 @@ use Docker\API\Model\PortBinding; use Docker\Docker; use Docker\Stream\CreateImageStream; use InvalidArgumentException; +use RuntimeException; use Testcontainers\ContainerClient\DockerContainerClient; use Testcontainers\Utils\PortGenerator\PortGenerator; use Testcontainers\Utils\PortGenerator\RandomUniquePortGenerator; use Testcontainers\Utils\PortNormalizer; +use Testcontainers\Utils\TarBuilder; use Testcontainers\Wait\WaitForContainer; use Testcontainers\Wait\WaitStrategy; @@ -58,8 +60,28 @@ class GenericContainer implements TestContainer protected PortGenerator $portGenerator; protected bool $isPrivileged = false; + protected ?string $networkName = null; + protected ?string $user = null; + + protected ?string $workingDir = null; + + /** + * @var array + */ + protected array $filesToCopy = []; + + /** + * @var array + */ + protected array $directoriesToCopy = []; + + /** + * @var array + */ + protected array $contentsToCopy = []; + protected int $startAttempts = 0; protected const MAX_START_ATTEMPTS = 2; @@ -94,6 +116,39 @@ class GenericContainer implements TestContainer return $this; } + /** + * @param array $files + */ + public function withCopyFilesToContainer(array $files): static + { + foreach ($files as $file) { + $this->filesToCopy[] = $file; + } + return $this; + } + + /** + * @param array $directories + */ + public function withCopyDirectoriesToContainer(array $directories): static + { + foreach ($directories as $directory) { + $this->directoriesToCopy[] = $directory; + } + return $this; + } + + /** + * @param array $contents + */ + public function withCopyContentToContainer(array $contents): static + { + foreach ($contents as $content) { + $this->contentsToCopy[] = $content; + } + return $this; + } + public function withEntryPoint(string $entryPoint): static { $this->entryPoint = $entryPoint; @@ -230,6 +285,20 @@ class GenericContainer implements TestContainer return $this; } + public function withUser(string $user): static + { + $this->user = $user; + + return $this; + } + + public function withWorkingDir(string $workingDir): static + { + $this->workingDir = $workingDir; + + return $this; + } + public function start(): StartedGenericContainer { $this->startAttempts++; @@ -244,7 +313,7 @@ class GenericContainer implements TestContainer $this->id = $containerCreateResponse?->getId() ?? ''; } catch (ContainerCreateNotFoundException) { if ($this->startAttempts >= self::MAX_START_ATTEMPTS) { - throw new \RuntimeException("Failed to start container after pulling image."); + 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 @@ -254,12 +323,84 @@ class GenericContainer implements TestContainer $this->dockerClient->containerStart($this->id); + if ($this->filesToCopy !== [] || $this->directoriesToCopy !== [] || $this->contentsToCopy !== []) { + $this->copyToContainer(); + } + $startedContainer = new StartedGenericContainer($this->id); $this->waitStrategy->wait($startedContainer); return $startedContainer; } + /** + * Uploads a tar archive containing files/directories/content to the container, + * extracting it into a chosen directory (`$containerPath`). Allows setting + * Docker's `noOverwriteDirNonDir` and `copyUIDGID` query parameters. + * + * @param string $containerPath Path within the container to extract the tar contents. Must be a directory in the container. + * @param bool $noOverwriteDirNonDir If true, Docker will error if it would replace an existing directory with a non-directory and vice versa. + * @param bool $copyUIDGID If true, Docker will attempt to preserve UID/GID from the tar entries. + * @throws RuntimeException|InvalidArgumentException + */ + protected function copyToContainer( + string $containerPath = '/', + bool $noOverwriteDirNonDir = false, + bool $copyUIDGID = false + ): void { + $tarBuilder = new TarBuilder(); + foreach ($this->filesToCopy as $file) { + $tarBuilder->addFile($file['source'], $file['target'], $file['mode'] ?? null); + } + + foreach ($this->directoriesToCopy as $directory) { + $tarBuilder->addDirectory($directory['source'], $directory['target'], $directory['mode'] ?? null); + } + + foreach ($this->contentsToCopy as $content) { + $tarBuilder->addContent($content['content'], $content['target'], $content['mode'] ?? null); + } + + $tarFilePath = $tarBuilder->buildTarArchive(); + + if (!is_file($tarFilePath)) { + throw new RuntimeException("Tar file does not exist at: $tarFilePath"); + } + + $handle = fopen($tarFilePath, 'rb'); + + if ($handle === false) { + throw new RuntimeException("Cannot open temporary tar archive at: $tarFilePath"); + } + + $queryParams = [ + 'path' => $containerPath, + ]; + + if ($noOverwriteDirNonDir) { + $queryParams['noOverwriteDirNonDir'] = 'true'; + } + + if ($copyUIDGID) { + $queryParams['copyUIDGID'] = 'true'; + } + + /** + * TODO: should be improved. Currently without using dummy $result or FETCH_RESPONSE, the request is failing. + * Probably an issue with the beluga-php/docker-php client library. + * */ + $result = $this->dockerClient->putContainerArchive( + $this->id, + $handle, + $queryParams, + $this->dockerClient::FETCH_RESPONSE + ); + + fclose($handle); + unlink($tarFilePath); + } + + protected function createContainerConfig(): ContainersCreatePostBody { $containerCreatePostBody = new ContainersCreatePostBody(); @@ -267,6 +408,8 @@ class GenericContainer implements TestContainer $containerCreatePostBody->setCmd($this->command); $containerCreatePostBody->setLabels($this->labels); $containerCreatePostBody->setHostname($this->hostname); + $containerCreatePostBody->setWorkingDir($this->workingDir); + $containerCreatePostBody->setUser($this->user); $envs = array_map(static fn ($key, $value) => "$key=$value", array_keys($this->env), $this->env); $containerCreatePostBody->setEnv($envs); diff --git a/src/Utils/TarBuilder.php b/src/Utils/TarBuilder.php new file mode 100644 index 0000000..ba615e7 --- /dev/null +++ b/src/Utils/TarBuilder.php @@ -0,0 +1,293 @@ + + */ + private array $files = []; + + /** + * @var array + */ + private array $directories = []; + + /** + * @var array + */ + private array $contents = []; + + /** + * Add a single file from the local filesystem. + */ + public function addFile(string $source, string $target, ?int $mode = null): self + { + if (!is_file($source)) { + throw new InvalidArgumentException("Invalid file path: {$source}"); + } + if (empty($target)) { + throw new InvalidArgumentException("Target path cannot be empty."); + } + if ($mode !== null && ($mode < 0 || $mode > 0o777)) { + throw new InvalidArgumentException("Invalid mode for file: {$mode}"); + } + $this->files[] = [ + 'source' => $source, + 'target' => $target, + 'mode' => $mode, + ]; + return $this; + } + + /** + * Add a directory (recursively) from the local filesystem. + */ + public function addDirectory(string $source, string $target, ?int $mode = null): self + { + $this->directories[] = [ + 'source' => $source, + 'target' => $target, + 'mode' => $mode, + ]; + return $this; + } + + /** + * Add inline string content that should become a file in the tar. + */ + public function addContent(string $content, string $target, ?int $mode = null): self + { + $this->contents[] = [ + 'content' => $content, + 'target' => $target, + 'mode' => $mode, + ]; + return $this; + } + + /** + * Builds the .tar archive from everything that was added (files, directories, contents). + * + * Returns the full path to the created .tar file. + */ + public function buildTarArchive(): string + { + $tempDir = $this->createTempDir(); + + $this->copyFilesToLocalDir($tempDir, $this->files); + $this->copyDirectoriesToLocalDir($tempDir, $this->directories); + $this->createFilesFromContent($tempDir, $this->contents); + + $tarFilePath = $this->createTempTarPath(); + $this->runTarCommand($tarFilePath, $tempDir); + $this->removeDirectoryRecursively($tempDir); + + return $tarFilePath; + } + + public function clear(): void + { + $this->files = []; + $this->directories = []; + $this->contents = []; + } + + private function createTempDir(): string + { + $tmpDirName = tempnam(sys_get_temp_dir(), 'tc_files_'); + if ($tmpDirName === false) { + throw new RuntimeException("Failed to create a temp file for tar data"); + } + // tempnam() creates a file; remove it and create directory instead + unlink($tmpDirName); + + if (!mkdir($tmpDirName) && !is_dir($tmpDirName)) { + throw new RuntimeException("Failed to create temp directory: {$tmpDirName}"); + } + + return $tmpDirName; + } + + private function createTempTarPath(): string + { + $tmpFile = tempnam(sys_get_temp_dir(), 'tc_tar_'); + + if ($tmpFile === false) { + throw new RuntimeException("Failed to create temp file for tar archive"); + } + + $tarFilePath = $tmpFile . '.tar'; + + if (!rename($tmpFile, $tarFilePath)) { + throw new RuntimeException("Failed renaming temp file to .tar"); + } + return $tarFilePath; + } + + private function runTarCommand(string $tarFilePath, string $sourceDir): void + { + // without --disable-copyfile and --no-xattrs combination, tar will fail on macOS + $cmd = sprintf( + 'tar --no-xattrs --disable-copyfile -cf %s -C %s . 2>&1', + escapeshellarg($tarFilePath), + escapeshellarg($sourceDir) + ); + + exec($cmd, $output, $exitCode); + + if ($exitCode !== 0) { + $errorText = implode("\n", $output); + throw new RuntimeException("Failed to create tar archive:\n{$errorText}"); + } + } + + private function removeDirectoryRecursively(string $dir): void + { + if (!is_dir($dir)) { + return; + } + + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ($iterator as $item) { + if (!$item instanceof SplFileInfo) { + continue; + } + $path = $item->getRealPath(); + if ($item->isDir()) { + rmdir($path); + } else { + unlink($path); + } + } + rmdir($dir); + } + + /** + * @param array $files + */ + private function copyFilesToLocalDir(string $tempDir, array $files): void + { + foreach ($files as $file) { + $source = $file['source']; + $target = $file['target']; + $mode = $file['mode'] ?? null; + + if (!is_file($source)) { + throw new InvalidArgumentException("File not found: $source"); + } + $destPath = $this->makeDestPath($tempDir, $target); + $this->ensureParentDir($destPath); + + if (!copy($source, $destPath)) { + throw new RuntimeException("Failed to copy file $source to $destPath"); + } + if ($mode !== null) { + chmod($destPath, $mode); + } + } + } + + /** + * @param array $directories + */ + private function copyDirectoriesToLocalDir(string $tempDir, array $directories): void + { + foreach ($directories as $dir) { + $source = $dir['source']; + $target = $dir['target']; + $mode = $dir['mode'] ?? null; + + if (!is_dir($source)) { + throw new InvalidArgumentException("Directory not found: $source"); + } + $destPath = $this->makeDestPath($tempDir, $target); + $this->copyDirectoryRecursively($source, $destPath); + + if ($mode !== null) { + chmod($destPath, $mode); + } + } + } + + /** + * @param array $contents + */ + private function createFilesFromContent(string $tempDir, array $contents): void + { + foreach ($contents as $content) { + $data = $content['content']; + $target = $content['target']; + $mode = $content['mode'] ?? null; + + $destPath = $this->makeDestPath($tempDir, $target); + $this->ensureParentDir($destPath); + + file_put_contents($destPath, $data); + if ($mode !== null) { + chmod($destPath, $mode); + } + } + } + + private function copyDirectoryRecursively(string $sourceDir, string $destDir): void + { + $this->ensureParentDir($destDir); + + $innerIterator = new RecursiveDirectoryIterator($sourceDir, \FilesystemIterator::SKIP_DOTS); + + /** @var RecursiveIteratorIterator $iterator */ + $iterator = new RecursiveIteratorIterator( + $innerIterator, + RecursiveIteratorIterator::SELF_FIRST + ); + + foreach ($iterator as $item) { + if (!$item instanceof SplFileInfo) { + continue; + } + + /** @var RecursiveDirectoryIterator $innerIterator */ + $innerIterator = $iterator->getInnerIterator(); + $subPathName = $innerIterator->getSubPathName(); + $targetPath = $destDir . '/' . $subPathName; + + // Ensure the parent directory for the target path exists + $this->ensureParentDir($targetPath); + + if ($item->isDir()) { + if (!mkdir($targetPath, 0o777, true) && !is_dir($targetPath)) { + throw new RuntimeException(sprintf('Directory "%s" was not created', $targetPath)); + } + } else { + copy($item->getPathname(), $targetPath); + } + } + } + + private function makeDestPath(string $tempDir, string $target): string + { + return rtrim($tempDir, '/') . '/' . ltrim($target, '/'); + } + + private function ensureParentDir(string $path): void + { + $parent = dirname($path); + if (!is_dir($parent) && !mkdir($parent, 0o777, true) && !is_dir($parent)) { + throw new RuntimeException("Failed to create parent directory: $parent"); + } + } +} diff --git a/tests/Integration/GenericContainerTest.php b/tests/Integration/GenericContainerTest.php index 7590db3..2a7c9e9 100644 --- a/tests/Integration/GenericContainerTest.php +++ b/tests/Integration/GenericContainerTest.php @@ -6,6 +6,7 @@ namespace Testcontainers\Tests\Integration; use Docker\API\Model\ContainersIdJsonGetResponse200; use PHPUnit\Framework\TestCase; +use RuntimeException; use Testcontainers\Container\GenericContainer; use Testcontainers\Utils\PortGenerator\FixedPortGenerator; use Testcontainers\Wait\WaitForHostPort; @@ -24,6 +25,94 @@ class GenericContainerTest extends TestCase $container->stop(); } + public function testShouldCopyContentToContainer(): void + { + $inlineContent = 'hello world'; + + $container = (new GenericContainer('alpine')) + ->withCommand(['tail', '-f', '/dev/null']) + ->withCopyContentToContainer([[ + 'content' => $inlineContent, + 'target' => '/tmp/inline.txt', + ]]) + ->start(); + + $output = $container->exec(['cat', '/tmp/inline.txt']); + + self::assertSame($inlineContent, $output); + + $container->stop(); + } + + public function testShouldCopyDirectoryToContainer(): void + { + $testDir = sys_get_temp_dir() . '/copy-dir-test'; + if (!is_dir($testDir)) { + mkdir($testDir); + } + file_put_contents($testDir . '/file1.txt', 'file1 contents'); + file_put_contents($testDir . '/file2.txt', 'file2 contents'); + + $container = (new GenericContainer('alpine')) + ->withCommand(['tail', '-f', '/dev/null']) + ->withCopyDirectoriesToContainer([[ + 'source' => $testDir, + 'target' => '/test-dir', + ]]) + ->start(); + + $output1 = $container->exec(['cat', '/test-dir/file1.txt']); + $output2 = $container->exec(['cat', '/test-dir/file2.txt']); + + self::assertSame('file1 contents', $output1); + self::assertSame('file2 contents', $output2); + + $container->stop(); + } + + public function testShouldCopyFileToContainer(): void + { + $localFilePath = sys_get_temp_dir() . '/copy-file-test.txt'; + file_put_contents($localFilePath, 'hello from file'); + + $container = (new GenericContainer('alpine')) + ->withCommand(['tail', '-f', '/dev/null']) + ->withCopyFilesToContainer([[ + 'source' => $localFilePath, + 'target' => '/tmp/test-file.txt', + ]]) + ->start(); + + $output = $container->exec(['cat', '/tmp/test-file.txt']); + + self::assertSame('hello from file', $output); + + $container->stop(); + } + + public function testShouldCopyFileWithPermissions(): void + { + $localFilePath = sys_get_temp_dir() . '/copy-perms-test.txt'; + file_put_contents($localFilePath, 'check perms'); + + $mode = 0o777; + + $container = (new GenericContainer('alpine')) + ->withCommand(['tail', '-f', '/dev/null']) + ->withCopyFilesToContainer([[ + 'source' => $localFilePath, + 'target' => '/tmp/perm-file.txt', + 'mode' => $mode, + ]]) + ->start(); + + $output = $container->exec(['stat', '-c', '%a', '/tmp/perm-file.txt']); + + self::assertSame('777', trim($output)); + + $container->stop(); + } + /** * @throws \JsonException */ @@ -41,6 +130,68 @@ class GenericContainerTest extends TestCase $container->stop(); } + public function testShouldSetLabels(): void + { + $labels = [ + 'label-1' => 'value-1', + 'label-2' => 'value-2', + ]; + $container = (new GenericContainer('alpine')) + ->withLabels($labels) + ->withCommand(['tail', '-f', '/dev/null']) + ->start(); + + /** @var ContainersIdJsonGetResponse200|null $inspectResult */ + $inspectResult = $container->getClient()->containerInspect($container->getId()); + $this->assertArrayHasKey('label-1', (array)$inspectResult?->getConfig()?->getLabels()); + $this->assertSame('value-1', ((array)$inspectResult?->getConfig()?->getLabels())['label-1']); + $this->assertArrayHasKey('label-2', (array)$inspectResult?->getConfig()?->getLabels()); + $this->assertSame('value-2', ((array)$inspectResult?->getConfig()?->getLabels())['label-2']); + + $container->stop(); + } + + public function testShouldSetName(): void + { + $name = 'test-container-name'; + $container = (new GenericContainer('alpine')) + ->withName($name) + ->withCommand(['tail', '-f', '/dev/null']) + ->start(); + + /** @var ContainersIdJsonGetResponse200|null $inspectResult */ + $inspectResult = $container->getClient()->containerInspect($container->getId()); + $this->assertSame('/'.$name, $inspectResult?->getName()); + + $container->stop(); + } + + public function testShouldSetUser(): void + { + $container = (new GenericContainer('alpine')) + ->withUser('nobody') + ->withCommand(['tail', '-f', '/dev/null']) + ->start(); + + $output = $container->exec(['whoami']); + $this->assertStringContainsString('nobody', $output); + + $container->stop(); + } + + public function testShouldSetWorkingDir(): void + { + $container = (new GenericContainer('alpine')) + ->withWorkingDir('/tmp') + ->withCommand(['tail', '-f', '/dev/null']) + ->start(); + + $output = $container->exec(['pwd']); + $this->assertStringContainsString('/tmp', $output); + + $container->stop(); + } + public function testShouldCaptureStderrWhenCommandFails(): void { $container = (new GenericContainer('alpine')) @@ -66,6 +217,26 @@ class GenericContainerTest extends TestCase $container->stop(); } + public function testShouldSetHealthCheckCommand(): void + { + $container = (new GenericContainer('alpine')) + ->withCommand(['tail', '-f', '/dev/null']) + ->withHealthCheckCommand('echo "healthy" || exit 1') + ->start(); + + /** @var ContainersIdJsonGetResponse200|null $inspectResult */ + $inspectResult = $container->getClient()->containerInspect($container->getId()); + $healthConfig = $inspectResult?->getConfig()?->getHealthcheck(); + + $this->assertNotNull($healthConfig); + $this->assertEquals(['CMD-SHELL', 'echo "healthy" || exit 1'], $healthConfig->getTest()); + $this->assertSame(1000000000, $healthConfig->getInterval()); + $this->assertSame(3000000000, $healthConfig->getTimeout()); + $this->assertSame(3, $healthConfig->getRetries()); + + $container->stop(); + } + public function testShouldSetEntrypoint(): void { $container = (new GenericContainer('cristianrgreco/testcontainer:1.1.14')) @@ -95,6 +266,8 @@ class GenericContainerTest extends TestCase $result = $container->exec(["cat", $containerPath.'/test.txt']); self::assertSame('hello world', $result); + + $container->stop(); } public function testShouldSetPrivilegedMode(): void @@ -109,5 +282,7 @@ class GenericContainerTest extends TestCase $privileged = $inspectResult?->getHostConfig()?->getPrivileged(); self::assertTrue($privileged); + + $container->stop(); } } diff --git a/tests/Unit/Utils/TarBuilderTest.php b/tests/Unit/Utils/TarBuilderTest.php new file mode 100644 index 0000000..b2f326f --- /dev/null +++ b/tests/Unit/Utils/TarBuilderTest.php @@ -0,0 +1,218 @@ +tempDir = sys_get_temp_dir() . '/tarbuilder_test_' . uniqid('', true); + mkdir($this->tempDir); + } + + protected function tearDown(): void + { + $this->removeDirectoryRecursively($this->tempDir); + parent::tearDown(); + } + + public function testShouldAddSingleFile(): void + { + $sourceFile = $this->tempDir . '/file.txt'; + file_put_contents($sourceFile, self::TEST_CONTENT); + + $tarBuilder = new TarBuilder(); + $tarBuilder->addFile($sourceFile, 'mydir/file_in_tar.txt', 0o644); + + $tarPath = $tarBuilder->buildTarArchive(); + + $this->assertFileExists($tarPath, 'Tar file was not created'); + + $extractDir = $this->tempDir . '/extract'; + mkdir($extractDir); + $this->extractTar($tarPath, $extractDir); + + $extractedFile = $extractDir . '/mydir/file_in_tar.txt'; + $this->assertFileExists($extractedFile); + $this->assertSame(self::TEST_CONTENT, file_get_contents($extractedFile)); + + $perms = substr(sprintf('%o', fileperms($extractedFile)), -3); + $this->assertSame('644', $perms, 'Expected file mode 0644'); + } + + public function testShouldAddDirectoryRecursively(): void + { + $localDir = $this->tempDir . '/localdir'; + mkdir($localDir); + file_put_contents($localDir . '/one.txt', 'file1'); + file_put_contents($localDir . '/two.txt', 'file2'); + + $tarBuilder = new TarBuilder(); + $tarBuilder->addDirectory($localDir, 'mydir', 0o755); + $tarPath = $tarBuilder->buildTarArchive(); + + $this->assertFileExists($tarPath); + + $extractDir = $this->tempDir . '/extractdir'; + mkdir($extractDir); + $this->extractTar($tarPath, $extractDir); + + $oneExtracted = $extractDir . '/mydir/one.txt'; + $twoExtracted = $extractDir . '/mydir/two.txt'; + $this->assertFileExists($oneExtracted); + $this->assertFileExists($twoExtracted); + + $this->assertSame('file1', file_get_contents($oneExtracted)); + $this->assertSame('file2', file_get_contents($twoExtracted)); + + $dirPerms = substr(sprintf('%o', fileperms($extractDir . '/mydir')), -3); + $this->assertSame('755', $dirPerms, 'Expected directory mode 0755'); + } + + public function testShouldAddInlineContent(): void + { + $content = "Inline content test\nLine2"; + + $tarBuilder = new TarBuilder(); + $tarBuilder->addContent($content, 'some/path/inline.txt', 0o777); + $tarPath = $tarBuilder->buildTarArchive(); + + $this->assertFileExists($tarPath); + + $extractDir = $this->tempDir . '/extractContent'; + mkdir($extractDir); + $this->extractTar($tarPath, $extractDir); + + $inlineExtracted = $extractDir . '/some/path/inline.txt'; + $this->assertFileExists($inlineExtracted); + $this->assertSame($content, file_get_contents($inlineExtracted)); + + $perms = substr(sprintf('%o', fileperms($inlineExtracted)), -3); + $this->assertSame('777', $perms, 'Expected file mode 0777'); + } + + public function testShouldFailOnInvalidFilePath(): void + { + $tarBuilder = new TarBuilder(); + $this->expectException(InvalidArgumentException::class); + $tarBuilder->addFile('/some/nonexistent/file', 'target.txt'); + } + + public function testShouldFailOnEmptyTarget(): void + { + $localFile = $this->tempDir . '/somefile.txt'; + file_put_contents($localFile, 'abc'); + + $tarBuilder = new TarBuilder(); + $this->expectException(InvalidArgumentException::class); + $tarBuilder->addFile($localFile, ''); + } + + public function testShouldFailOnInvalidMode(): void + { + $localFile = $this->tempDir . '/somefile.txt'; + file_put_contents($localFile, 'abc'); + + $tarBuilder = new TarBuilder(); + $this->expectException(InvalidArgumentException::class); + $tarBuilder->addFile($localFile, 'target.txt', 9999); + } + + public function testShouldCreateEmptyTarIfNoItemsAdded(): void + { + $tarBuilder = new TarBuilder(); + + $tarPath = $tarBuilder->buildTarArchive(); + $this->assertFileExists($tarPath); + + $extractDir = $this->tempDir . '/extractEmpty'; + mkdir($extractDir); + $this->extractTar($tarPath, $extractDir); + + $scanned = array_diff(scandir($extractDir), ['.', '..']); + $this->assertCount(0, $scanned, 'Expected empty directory'); + } + + public function testShouldClearItems(): void + { + $tarBuilder = new TarBuilder(); + $localFile = $this->tempDir . '/somefile.txt'; + file_put_contents($localFile, 'abc'); + $tarBuilder->addFile($localFile, 'test.txt'); + + $tarBuilder->clear(); + + $tarPath = $tarBuilder->buildTarArchive(); + $this->assertFileExists($tarPath); + + $extractDir = $this->tempDir . '/extractCleared'; + mkdir($extractDir); + $this->extractTar($tarPath, $extractDir); + + $scanned = array_diff(scandir($extractDir), ['.', '..']); + $this->assertCount(0, $scanned, 'Expected no files after clear()'); + } + + /** + * Helper function to extract a .tar for verification. + */ + private function extractTar(string $tarPath, string $destination): void + { + $cmd = sprintf( + 'tar -xpf %s -C %s 2>&1', + escapeshellarg($tarPath), + escapeshellarg($destination) + ); + + exec($cmd, $output, $exitCode); + if ($exitCode !== 0) { + $errorText = implode("\n", $output); + throw new RuntimeException("Failed to extract tar:\n{$errorText}"); + } + } + + /** + * Recursively remove directory. + */ + private function removeDirectoryRecursively(string $path): void + { + if (!is_dir($path)) { + return; + } + + /** @var RecursiveIteratorIterator $items */ + $items = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ($items as $item) { + if (!$item instanceof SplFileInfo) { + continue; + } + if ($item->isDir()) { + rmdir($item->getRealPath()); + } else { + unlink($item->getRealPath()); + } + } + rmdir($path); + } +} From 9d83a05bb1c60139d701edd3fa86100e3bdaf299 Mon Sep 17 00:00:00 2001 From: Sergei Shitikov Date: Tue, 21 Jan 2025 19:27:32 +0100 Subject: [PATCH 37/54] use array_merge instead of foreach on copy methods --- src/Container/GenericContainer.php | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/src/Container/GenericContainer.php b/src/Container/GenericContainer.php index 8afa2b1..65555f7 100644 --- a/src/Container/GenericContainer.php +++ b/src/Container/GenericContainer.php @@ -121,9 +121,8 @@ class GenericContainer implements TestContainer */ public function withCopyFilesToContainer(array $files): static { - foreach ($files as $file) { - $this->filesToCopy[] = $file; - } + $this->filesToCopy = array_merge($this->filesToCopy, $files); + return $this; } @@ -132,9 +131,8 @@ class GenericContainer implements TestContainer */ public function withCopyDirectoriesToContainer(array $directories): static { - foreach ($directories as $directory) { - $this->directoriesToCopy[] = $directory; - } + $this->directoriesToCopy = array_merge($this->directoriesToCopy, $directories); + return $this; } @@ -143,9 +141,8 @@ class GenericContainer implements TestContainer */ public function withCopyContentToContainer(array $contents): static { - foreach ($contents as $content) { - $this->contentsToCopy[] = $content; - } + $this->contentsToCopy = array_merge($this->contentsToCopy, $contents); + return $this; } From 1c928cc7677b96f24cafc28731286f7545a8b4ac Mon Sep 17 00:00:00 2001 From: Sergei Shitikov Date: Mon, 3 Feb 2025 20:47:02 +0100 Subject: [PATCH 38/54] Adjust StartedGenericContainer --- src/Container/StartedGenericContainer.php | 77 +++++-- src/Modules/MySQLContainer.php | 2 - tests/Integration/ContainerTestCase.php | 4 +- .../StartedGenericContainerTest.php | 207 ++++++++++++++++++ 4 files changed, 271 insertions(+), 19 deletions(-) create mode 100644 tests/Integration/StartedGenericContainerTest.php diff --git a/src/Container/StartedGenericContainer.php b/src/Container/StartedGenericContainer.php index 242e65f..d1c32f2 100644 --- a/src/Container/StartedGenericContainer.php +++ b/src/Container/StartedGenericContainer.php @@ -6,9 +6,11 @@ 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 JsonException; use Psr\Http\Message\ResponseInterface; use RuntimeException; use Testcontainers\ContainerClient\DockerContainerClient; @@ -99,7 +101,7 @@ class StartedGenericContainer implements StartedTestContainer public function getHost(): string { - return $this->inspect()['NetworkSettings']['Gateway'] ?? '127.0.0.1'; + return '127.0.0.1'; } public function getMappedPort(int $port): int @@ -138,7 +140,9 @@ class StartedGenericContainer implements StartedTestContainer */ public function getNetworkNames(): array { - $networks = $this->inspect()['NetworkSettings']['Networks'] ?? []; + /** @var array{NetworkSettings?: array{Networks?: array}} $inspectData */ + $inspectData = $this->inspect(); + $networks = $inspectData['NetworkSettings']['Networks'] ?? []; return array_keys($networks); } @@ -160,32 +164,73 @@ class StartedGenericContainer implements StartedTestContainer throw new RuntimeException("Network with name {$networkName} not exists"); } - private function inspect(): array + /** + * @return array The container details. + * @throws RuntimeException If the container inspection fails or the response format is invalid. + * TODO: refactor with object after beluga-php/docker-php client library is fixed + */ + protected function inspect(): array { - //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'); - } - try { - return json_decode( + /** + * For some reason, containerInspect can crash when using FETCH_OBJECT option (e.g. with OpenSearch) + * This is a workaround until the issue is fixed (should be checked within beluga-php/docker-php client library) + */ + /** @var ResponseInterface | null $containerInspectResponse */ + $containerInspectResponse = $this->dockerClient->containerInspect($this->id, [], $this->dockerClient::FETCH_RESPONSE); + if ($containerInspectResponse === null) { + throw new RuntimeException('Failed to inspect container: response is null'); + } + + // Decode the JSON response as an associative array + $decodedResponse = json_decode( $containerInspectResponse->getBody()->getContents(), true, 512, JSON_THROW_ON_ERROR ); - } catch (Throwable $exception) { - throw new RuntimeException('Failed to inspect container', 0, $exception); + + if (!is_array($decodedResponse)) { + throw new RuntimeException('Failed to inspect container: response is not a valid JSON object'); + } + + return $decodedResponse; + } catch (JsonException $e) { + throw new RuntimeException( + sprintf('Failed to decode container inspect response: %s', $e->getMessage()), + previous: $e + ); + } catch (Throwable $e) { + throw new RuntimeException( + sprintf('Unexpected error while inspecting container: %s', $e->getMessage()), + previous: $e + ); } } - private function ports(): array + /** + * @return array An associative array containing the `NetworkSettings` details. + * @throws RuntimeException If the container inspection is missing the `NetworkSettings` key. + */ + protected function networkSettings(): array + { + $inspectData = $this->inspect(); + + if (!isset($inspectData['NetworkSettings']) || !is_array($inspectData['NetworkSettings'])) { + throw new RuntimeException('Missing or invalid NetworkSettings in container inspection'); + } + + return $inspectData['NetworkSettings']; + } + + /** + * @return array>> + * @throws RuntimeException + */ + protected function ports(): array { /** @var array>> $ports */ - $ports = $this->inspect()['NetworkSettings']['Ports'] ?? []; + $ports = $this->networkSettings()['Ports'] ?? []; if ($ports === []) { throw new RuntimeException('Failed to get ports from container'); diff --git a/src/Modules/MySQLContainer.php b/src/Modules/MySQLContainer.php index 82b673b..00c6a90 100644 --- a/src/Modules/MySQLContainer.php +++ b/src/Modules/MySQLContainer.php @@ -17,8 +17,6 @@ class MySQLContainer extends GenericContainer $this->withWait(new WaitForExec([ "mysqladmin", "ping", - "-u", "root", - "-p{$mysqlRootPassword}", "-h", "127.0.0.1", ])); } diff --git a/tests/Integration/ContainerTestCase.php b/tests/Integration/ContainerTestCase.php index bbe9d84..9a3966f 100644 --- a/tests/Integration/ContainerTestCase.php +++ b/tests/Integration/ContainerTestCase.php @@ -13,7 +13,9 @@ abstract class ContainerTestCase extends TestCase protected function tearDown(): void { - $this->container->stop(); + if (isset($this->container)) { + $this->container->stop(); + } parent::tearDown(); } } diff --git a/tests/Integration/StartedGenericContainerTest.php b/tests/Integration/StartedGenericContainerTest.php new file mode 100644 index 0000000..a59976c --- /dev/null +++ b/tests/Integration/StartedGenericContainerTest.php @@ -0,0 +1,207 @@ +withCommand(['tail', '-f', '/dev/null']) + ->start(); + + $this->container = $container; + + self::assertNotEmpty($container->getId(), 'Container ID should not be empty'); + } + + public function testShouldReturnLastExecId(): void + { + $container = (new GenericContainer('alpine')) + ->withCommand(['tail', '-f', '/dev/null']) + ->start(); + + $this->container = $container; + + $container->exec(['echo', 'Test Exec ID']); + + $lastExecId = $container->getLastExecId(); + + self::assertNotNull($lastExecId, 'Last exec ID should not be null'); + self::assertNotEmpty($lastExecId, 'Last exec ID should not be empty'); + self::assertMatchesRegularExpression('/^[0-9a-f]+$/', $lastExecId, 'Last exec ID should be a valid hexadecimal string'); + } + + public function testShouldExecuteCommandInContainer(): void + { + $container = (new GenericContainer('alpine')) + ->withCommand(['tail', '-f', '/dev/null']) + ->start(); + + $this->container = $container; + + $output = $container->exec(['echo', 'Hello, Testcontainers!']); + self::assertSame('Hello, Testcontainers!', $output); + } + + public function testShouldStopContainer(): void + { + $container = (new GenericContainer('alpine')) + ->withCommand(['tail', '-f', '/dev/null']) + ->start(); + + self::assertNotEmpty($container->getId(), 'Container ID should not be empty'); + + $stoppedContainer = $container->stop(); + + self::assertNotNull($stoppedContainer, 'Stopped container should not be null'); + self::assertSame( + $container->getId(), + $stoppedContainer->getId(), + 'Stopped container ID should match the original container ID' + ); + + self::assertStringContainsString( + 'No such container', + $container->logs(), + 'Expected message indicating container does not exist' + ); + } + + public function testShouldRestartContainer(): void + { + $container = (new GenericContainer('nginx')) + ->withExposedPorts(80) + ->start(); + + $this->container = $container; + + $containerIdBeforeRestart = $container->getId(); + $container->restart(); + $containerIdAfterRestart = $container->getId(); + + self::assertSame( + $containerIdBeforeRestart, + $containerIdAfterRestart, + 'Container ID should remain the same after restart' + ); + } + + public function testShouldRetrieveLogs(): void + { + $container = (new GenericContainer('alpine')) + ->withCommand(['sh', '-c', 'echo "Hello from logs!" && tail -f /dev/null']) + ->start(); + + $this->container = $container; + + $logs = $container->logs(); + self::assertStringContainsString('Hello from logs!', $logs); + } + + public function testShouldRetrieveHost(): void + { + $container = (new GenericContainer('alpine')) + ->withCommand(['tail', '-f', '/dev/null']) + ->start(); + + $this->container = $container; + + $host = $container->getHost(); + self::assertSame('127.0.0.1', $host, 'Host should be 127.0.0.1'); + } + + public function testShouldRetrieveFirstMappedPort(): void + { + $container = (new GenericContainer('nginx')) + ->withExposedPorts(80) + ->start(); + + $this->container = $container; + + $mappedPort = $container->getFirstMappedPort(); + self::assertGreaterThan(0, $mappedPort, 'Mapped port should be greater than 0'); + } + + public function testShouldRetrieveMappedPort(): void + { + $container = (new GenericContainer('nginx')) + ->withExposedPorts(80) + ->start(); + + $this->container = $container; + + $mappedPort = $container->getMappedPort(80); + self::assertGreaterThan(0, $mappedPort, 'Mapped port for 80 should be greater than 0'); + } + + public function testShouldRetrieveContainerName(): void + { + $name = 'test-container-name'; + $container = (new GenericContainer('alpine')) + ->withName($name) + ->withCommand(['tail', '-f', '/dev/null']) + ->start(); + + $this->container = $container; + + self::assertSame($name, $container->getName(), 'Container name should match'); + } + + public function testShouldRetrieveLabels(): void + { + $labels = [ + 'label-1' => 'value-1', + 'label-2' => 'value-2', + ]; + + $container = (new GenericContainer('alpine')) + ->withLabels($labels) + ->withCommand(['tail', '-f', '/dev/null']) + ->start(); + + $this->container = $container; + + $retrievedLabels = $container->getLabels(); + + self::assertArrayHasKey('label-1', $retrievedLabels); + self::assertSame('value-1', $retrievedLabels['label-1']); + self::assertArrayHasKey('label-2', $retrievedLabels); + self::assertSame('value-2', $retrievedLabels['label-2']); + } + + public function testShouldRetrieveNetworkNames(): void + { + $container = (new GenericContainer('nginx')) + ->withExposedPorts(80) + ->start(); + + $this->container = $container; + + $networks = $container->getNetworkNames(); + + self::assertNotEmpty($networks, 'Networks should not be empty'); + } + + public function testShouldRetrieveIpAddressFromNetwork(): void + { + $container = (new GenericContainer('nginx')) + ->withExposedPorts(80) + ->start(); + + $this->container = $container; + + $networks = $container->getNetworkNames(); + $networkName = $networks[0] ?? null; + + self::assertNotNull($networkName, 'Network name should not be null'); + + $ipAddress = $container->getIpAddress($networkName); + + self::assertNotEmpty($ipAddress, 'IP address should not be empty'); + } +} From 345cdb40d0f20e049feeabc0f5dacffa408141cf Mon Sep 17 00:00:00 2001 From: Sergei Shitikov Date: Mon, 3 Feb 2025 23:57:59 +0100 Subject: [PATCH 39/54] Use inspect Model instead of plain Response after fix in beluga-php/docker-php. Update resolving host logic. --- src/Utils/HostResolver.php | 138 +++++++++++++++++++++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 src/Utils/HostResolver.php diff --git a/src/Utils/HostResolver.php b/src/Utils/HostResolver.php new file mode 100644 index 0000000..6c74dc2 --- /dev/null +++ b/src/Utils/HostResolver.php @@ -0,0 +1,138 @@ +dockerClient = $dockerClient ?? DockerContainerClient::getDockerClient(); + } + + /** + * Resolves the host address for connecting to a container. + * + * The resolution process is as follows: + * 1. If user overrides are allowed and TESTCONTAINERS_HOST_OVERRIDE is set, its value is returned. + * 2. Otherwise, the DOCKER_HOST environment variable is parsed. + * - If the scheme is one of http, https, or tcp, the hostname is used. + * - If the scheme is unix or npipe and the process is running in a container, the network gateway + * is determined by inspecting the relevant Docker network or running a temporary container. + * 3. If no other value can be determined, "localhost" is returned. + * + * @return string + * @throws RuntimeException If the DOCKER_HOST scheme is unsupported. + */ + public function resolveHost(): string + { + if ($this->allowUserOverrides() && ($override = getenv('TESTCONTAINERS_HOST_OVERRIDE')) !== false) { + return $override; + } + + // Get DOCKER_HOST URI, defaulting to a TCP endpoint if not set. + $dockerHostUri = getenv('DOCKER_HOST') ?: 'tcp://127.0.0.1:2375'; + $parts = parse_url($dockerHostUri); + if ($parts === false || !isset($parts['scheme'])) { + return 'localhost'; + } + + $scheme = $parts['scheme']; + + switch ($scheme) { + case 'http': + case 'https': + case 'tcp': + return $parts['host'] ?? 'localhost'; + + case 'unix': + case 'npipe': + if ($this->isInContainer()) { + // If using podman, choose "podman" network; otherwise, use "bridge" + $networkName = (str_contains($dockerHostUri, 'podman.sock')) ? 'podman' : 'bridge'; + if ($gateway = $this->findGateway($networkName)) { + return $gateway; + } + if ($defaultGateway = $this->findDefaultGateway()) { + return $defaultGateway; + } + } + return 'localhost'; + + default: + throw new RuntimeException("Unsupported Docker host scheme: {$scheme}"); + } + } + + protected function allowUserOverrides(): bool + { + return true; + } + + /** + * Determines if the code is running inside a container. + */ + protected function isInContainer(): bool + { + return file_exists('/.dockerenv'); + } + + /** + * Inspects the given network and returns its gateway IP address if found. + * + * @param string $networkName + * @return string|null + */ + protected function findGateway(string $networkName): ?string + { + try { + /** @var Network|null $networkInspect */ + $networkInspect = $this->dockerClient?->networkInspect($networkName); + $ipamConfig = $networkInspect?->getIPAM()?->getConfig(); + if ($ipamConfig !== null) { + foreach ($ipamConfig as $config) { + if ($config->getGateway() !== null) { + return $config->getGateway(); + } + } + } + } catch (\Throwable) { + return null; + } + return null; + } + + /** + * Runs a temporary container to determine the default gateway. + */ + protected function findDefaultGateway(): ?string + { + $tmpContainer = null; + try { + // Create a temporary container using a lightweight Alpine image. + $tmpContainer = (new GenericContainer('alpine:3.14')) + ->withCommand(['tail', '-f', '/dev/null']) + ->start(); + $result = $tmpContainer->exec(['sh', '-c', "ip route | awk '/default/ { print $3 }'"]); + $tmpContainer->stop(); + return $result; + } catch (\Throwable) { + return null; + } finally { + if ($tmpContainer !== null) { + try { + $tmpContainer->stop(); + } catch (\Throwable) { + // + } + } + } + } +} From 3194c8725fb97967c225368f5f76b5d7a662e654 Mon Sep 17 00:00:00 2001 From: Sergei Shitikov Date: Mon, 3 Feb 2025 23:59:25 +0100 Subject: [PATCH 40/54] add updates from StartedGenericContainer --- src/Container/StartedGenericContainer.php | 149 +++++++++------------- 1 file changed, 61 insertions(+), 88 deletions(-) diff --git a/src/Container/StartedGenericContainer.php b/src/Container/StartedGenericContainer.php index d1c32f2..259abd6 100644 --- a/src/Container/StartedGenericContainer.php +++ b/src/Container/StartedGenericContainer.php @@ -7,24 +7,26 @@ namespace Testcontainers\Container; use Docker\API\Client; use Docker\API\Model\ContainersIdExecPostBody; use Docker\API\Model\ContainersIdJsonGetResponse200; +use Docker\API\Model\EndpointSettings; use Docker\API\Model\IdResponse; +use Docker\API\Model\PortBinding; use Docker\API\Runtime\Client\Client as DockerRuntimeClient; use Docker\Docker; -use JsonException; -use Psr\Http\Message\ResponseInterface; use RuntimeException; use Testcontainers\ContainerClient\DockerContainerClient; -use Throwable; +use Testcontainers\Utils\HostResolver; class StartedGenericContainer implements StartedTestContainer { protected Docker $dockerClient; + protected ?ContainersIdJsonGetResponse200 $inspectResponse = null; + protected ?string $lastExecId = null; - public function __construct(protected readonly string $id) + public function __construct(protected readonly string $id, ?Docker $dockerClient = null) { - $this->dockerClient = DockerContainerClient::getDockerClient(); + $this->dockerClient = $dockerClient ?? DockerContainerClient::getDockerClient(); } public function getId(): string @@ -67,7 +69,7 @@ class StartedGenericContainer implements StartedTestContainer ?->getBody() ->getContents() ?? ''; - return preg_replace('/[\x00-\x1F\x7F]/u', '', $contents) ?? ''; + return $this->sanitizeOutput($contents); } public function stop(): StoppedTestContainer @@ -96,43 +98,52 @@ class StartedGenericContainer implements StartedTestContainer ?->getBody() ->getContents() ?? ''; - return preg_replace('/[\x00-\x1F\x7F]/u', '', mb_convert_encoding($output, 'UTF-8', 'UTF-8')) ?? ''; + return $this->sanitizeOutput(mb_convert_encoding($output, 'UTF-8', 'UTF-8')); } public function getHost(): string { - return '127.0.0.1'; + return (new HostResolver($this->dockerClient))->resolveHost(); } public function getMappedPort(int $port): int { - $ports = $this->ports(); - if (isset($ports["{$port}/tcp"][0]['HostPort'])) { - return (int) $ports["{$port}/tcp"][0]['HostPort']; + $ports = (array) $this->ports(); + /** @var PortBinding | null $portBinding */ + $portBinding = $ports["{$port}/tcp"][0] ?? null; + $mappedPort = $portBinding?->getHostPort(); + if ($mappedPort !== null) { + return (int) $mappedPort; } - throw new RuntimeException("Failed to get mapped port $port for container"); + throw new RuntimeException("Failed to get mapped port ‘{$mappedPort}’ for container"); } public function getFirstMappedPort(): int { - $ports = $this->ports(); + $ports = (array) $this->ports(); $port = array_key_first($ports); + /** @var PortBinding | null $firstPortBinding */ + $firstPortBinding = $ports[$port][0] ?? null; + $firstMappedPort = $firstPortBinding?->getHostPort(); + if ($firstMappedPort !== null) { + return (int) $firstMappedPort; + } - return (int) $ports[$port][0]['HostPort']; + throw new RuntimeException('Failed to get first mapped port for container'); } public function getName(): string { - return trim($this->inspect()['Name'], '/ '); + return trim($this->inspect()?->getName() ?? '', '/ '); } /** - * @return string[] + * @return array */ public function getLabels(): array { - return $this->inspect()['Config']['Labels'] ?? []; + return (array) $this->inspect()?->getConfig()?->getLabels(); } /** @@ -140,102 +151,64 @@ class StartedGenericContainer implements StartedTestContainer */ public function getNetworkNames(): array { - /** @var array{NetworkSettings?: array{Networks?: array}} $inspectData */ - $inspectData = $this->inspect(); - $networks = $inspectData['NetworkSettings']['Networks'] ?? []; + $networks = (array) $this->inspect()?->getNetworkSettings()?->getNetworks(); return array_keys($networks); } public function getNetworkId(string $networkName): string { - $networks = $this->inspect()['NetworkSettings']['Networks']; - if (isset($networks[$networkName])) { - return $networks[$networkName]['NetworkID']; + $networks = (array) $this->inspect()?->getNetworkSettings()?->getNetworks(); + /** @var EndpointSettings | null $endpointSettings */ + $endpointSettings = $networks[$networkName] ?? null; + $networkID = $endpointSettings?->getNetworkID(); + if ($networkID !== null) { + return $networkID; } - throw new RuntimeException("Network with name {$networkName} not exists"); + + throw new RuntimeException("Network with name ‘{$networkName}’ does not exist"); } public function getIpAddress(string $networkName): string { - $networks = $this->inspect()['NetworkSettings']['Networks']; - if (isset($networks[$networkName])) { - return $networks[$networkName]['IPAddress']; + $networks = (array) $this->inspect()?->getNetworkSettings()?->getNetworks(); + /** @var EndpointSettings | null $endpointSettings */ + $endpointSettings = $networks[$networkName] ?? null; + $ipAddress = $endpointSettings?->getIPAddress(); + if ($ipAddress !== null) { + return $ipAddress; } - throw new RuntimeException("Network with name {$networkName} not exists"); + + throw new RuntimeException("Network with name ‘{$networkName}’ does not exist"); } - /** - * @return array The container details. - * @throws RuntimeException If the container inspection fails or the response format is invalid. - * TODO: refactor with object after beluga-php/docker-php client library is fixed - */ - protected function inspect(): array + protected function inspect(): ContainersIdJsonGetResponse200 | null { - try { - /** - * For some reason, containerInspect can crash when using FETCH_OBJECT option (e.g. with OpenSearch) - * This is a workaround until the issue is fixed (should be checked within beluga-php/docker-php client library) - */ - /** @var ResponseInterface | null $containerInspectResponse */ - $containerInspectResponse = $this->dockerClient->containerInspect($this->id, [], $this->dockerClient::FETCH_RESPONSE); - if ($containerInspectResponse === null) { - throw new RuntimeException('Failed to inspect container: response is null'); - } - - // Decode the JSON response as an associative array - $decodedResponse = json_decode( - $containerInspectResponse->getBody()->getContents(), - true, - 512, - JSON_THROW_ON_ERROR - ); - - if (!is_array($decodedResponse)) { - throw new RuntimeException('Failed to inspect container: response is not a valid JSON object'); - } - - return $decodedResponse; - } catch (JsonException $e) { - throw new RuntimeException( - sprintf('Failed to decode container inspect response: %s', $e->getMessage()), - previous: $e - ); - } catch (Throwable $e) { - throw new RuntimeException( - sprintf('Unexpected error while inspecting container: %s', $e->getMessage()), - previous: $e - ); + if ($this->inspectResponse === null) { + /** @var ContainersIdJsonGetResponse200 | null $inspectResponse */ + $inspectResponse = $this->dockerClient->containerInspect($this->id); + $this->inspectResponse = $inspectResponse; } + + return $this->inspectResponse; } /** - * @return array An associative array containing the `NetworkSettings` details. - * @throws RuntimeException If the container inspection is missing the `NetworkSettings` key. - */ - protected function networkSettings(): array - { - $inspectData = $this->inspect(); - - if (!isset($inspectData['NetworkSettings']) || !is_array($inspectData['NetworkSettings'])) { - throw new RuntimeException('Missing or invalid NetworkSettings in container inspection'); - } - - return $inspectData['NetworkSettings']; - } - - /** - * @return array>> + * @return array> * @throws RuntimeException */ - protected function ports(): array + protected function ports(): iterable { - /** @var array>> $ports */ - $ports = $this->networkSettings()['Ports'] ?? []; + $ports = $this->inspect()?->getNetworkSettings()?->getPorts(); - if ($ports === []) { + if ($ports === null) { throw new RuntimeException('Failed to get ports from container'); } return $ports; } + + protected function sanitizeOutput(string $output): string + { + return preg_replace('/[\x00-\x1F\x7F]/u', '', $output) ?? ''; + } } From f899c413119a64f4dc94dd3d0392749339c68667 Mon Sep 17 00:00:00 2001 From: Sergei Shitikov Date: Tue, 4 Feb 2025 00:25:24 +0100 Subject: [PATCH 41/54] adjust phpstan memory, small phpstan fixes added HostResolverTest --- composer.json | 2 +- src/Container/StartedTestContainer.php | 6 + tests/Unit/Utils/HostResolverTest.php | 253 +++++++++++++++++++++++++ tests/Unit/Utils/TarBuilderTest.php | 4 +- 4 files changed, 262 insertions(+), 3 deletions(-) create mode 100644 tests/Unit/Utils/HostResolverTest.php diff --git a/composer.json b/composer.json index 773c728..42cc19a 100644 --- a/composer.json +++ b/composer.json @@ -45,7 +45,7 @@ "integration:old": "phpunit tests/Integration/OldTests --bootstrap vendor/autoload.php", "cs": "php-cs-fixer fix --dry-run", "cs:fix": "php-cs-fixer fix", - "phpstan": "phpstan analyse" + "phpstan": "phpstan analyse --memory-limit=256M" }, "config": { "allow-plugins": { diff --git a/src/Container/StartedTestContainer.php b/src/Container/StartedTestContainer.php index 3a75b0b..8d1acd3 100644 --- a/src/Container/StartedTestContainer.php +++ b/src/Container/StartedTestContainer.php @@ -23,6 +23,9 @@ interface StartedTestContainer public function getIpAddress(string $networkName): string; + /** + * @return array + */ public function getLabels(): array; public function logs(): string; @@ -35,6 +38,9 @@ interface StartedTestContainer public function getNetworkId(string $networkName): string; + /** + * @return string[] + */ public function getNetworkNames(): array; public function restart(): self; diff --git a/tests/Unit/Utils/HostResolverTest.php b/tests/Unit/Utils/HostResolverTest.php new file mode 100644 index 0000000..f428b94 --- /dev/null +++ b/tests/Unit/Utils/HostResolverTest.php @@ -0,0 +1,253 @@ +createMock(Docker::class); + $resolver = new HostResolver($dummyClient); + $host = $resolver->resolveHost(); + $this->assertEquals('tcp://another:2375', $host); + } + + public function testReturnsHostnameForTcpProtocols(): void + { + $protocols = ['tcp', 'http', 'https']; + foreach ($protocols as $protocol) { + putenv('DOCKER_HOST=' . $protocol . '://docker:2375'); + // Clear any override. + putenv('TESTCONTAINERS_HOST_OVERRIDE'); + $dummyClient = $this->createMock(Docker::class); + $resolver = new HostResolver($dummyClient); + $host = $resolver->resolveHost(); + $this->assertEquals('docker', $host, "Protocol {$protocol} did not return expected hostname."); + } + } + + public function testDoesNotReturnOverrideWhenAllowUserOverridesIsFalse(): void + { + $dummyClient = $this->createMock(Docker::class); + $resolver = new class ($dummyClient) extends HostResolver { + protected function allowUserOverrides(): bool + { + return false; + } + }; + + putenv('TESTCONTAINERS_HOST_OVERRIDE=tcp://another:2375'); + putenv('DOCKER_HOST=tcp://docker:2375'); + $host = $resolver->resolveHost(); + $this->assertEquals('docker', $host); + } + + public function testReturnsLocalhostForUnixAndNpipeProtocolsWhenNotInContainer(): void + { + $dummyClient = $this->createMock(Docker::class); + $resolver = new class ($dummyClient) extends HostResolver { + protected function isInContainer(): bool + { + return false; + } + }; + + foreach (['unix://docker:2375', 'npipe://docker:2375'] as $uri) { + putenv('DOCKER_HOST=' . $uri); + putenv('TESTCONTAINERS_HOST_OVERRIDE'); + $host = $resolver->resolveHost(); + $this->assertEquals('localhost', $host, "URI {$uri} should return 'localhost' when not in a container."); + } + } + + public function testReturnsHostFromGatewayWhenRunningInContainer(): void + { + // For this test we simulate that we are in a container and the Docker client returns a gateway. + $dockerClient = $this->getMockBuilder(Docker::class) + ->disableOriginalConstructor() + ->getMock(); + + // Build a fake network inspection response: + $fakeConfig = new class () { + public function getGateway(): ?string + { + return '172.0.0.1'; + } + }; + $fakeIPAM = new class ($fakeConfig) { + /** @var object[] */ + private array $config; + public function __construct(object $config) + { + $this->config = [$config]; + } + /** @return object[] */ + public function getConfig(): array + { + return $this->config; + } + }; + $fakeNetwork = new class ($fakeIPAM) { + private object $ipam; + public function __construct(object $ipam) + { + $this->ipam = $ipam; + } + public function getIPAM(): object + { + return $this->ipam; + } + }; + + // Expect that networkInspect will be called with "bridge" (since DOCKER_HOST does not contain "podman.sock") + $dockerClient->expects($this->once()) + ->method('networkInspect') + ->with($this->equalTo('bridge')) + ->willReturn($fakeNetwork); + + // Override isInContainer() to simulate being inside a container. + $resolver = new class ($dockerClient) extends HostResolver { + protected function isInContainer(): bool + { + return true; + } + }; + + putenv('DOCKER_HOST=unix://docker:2375'); + putenv('TESTCONTAINERS_HOST_OVERRIDE'); + $host = $resolver->resolveHost(); + $this->assertEquals('172.0.0.1', $host); + } + + public function testUsesBridgeNetworkAsGatewayForDockerProvider(): void + { + // For Docker provider (non-Podman) the network used should be "bridge". + $dockerClient = $this->getMockBuilder(Docker::class) + ->disableOriginalConstructor() + ->getMock(); + // Expect networkInspect to be called with "bridge" + $dockerClient->expects($this->once()) + ->method('networkInspect') + ->with($this->equalTo('bridge')) + ->willReturn(null); // Simulate not finding a gateway + + $resolver = new class ($dockerClient) extends HostResolver { + protected function isInContainer(): bool + { + return true; + } + }; + + putenv('DOCKER_HOST=unix://docker:2375'); + $host = $resolver->resolveHost(); + // Since no gateway is found, fallback is "localhost" + $this->assertEquals('localhost', $host); + } + + public function testUsesPodmanNetworkAsGatewayForPodmanProvider(): void + { + // For Podman, DOCKER_HOST contains "podman.sock" so the network should be "podman". + $dockerClient = $this->getMockBuilder(Docker::class) + ->disableOriginalConstructor() + ->getMock(); + // Expect networkInspect to be called with "podman" + $dockerClient->expects($this->once()) + ->method('networkInspect') + ->with($this->equalTo('podman')) + ->willReturn(null); // Simulate not finding a gateway + + $resolver = new class ($dockerClient) extends HostResolver { + protected function isInContainer(): bool + { + return true; + } + }; + + putenv('DOCKER_HOST=unix://podman.sock'); + $host = $resolver->resolveHost(); + $this->assertEquals('localhost', $host); + } + + public function testReturnsHostFromDefaultGatewayWhenRunningInContainer(): void + { + // Override both findGateway() and findDefaultGateway() to simulate a missing network gateway and a default gateway result. + $dummyClient = $this->createMock(Docker::class); + $resolver = new class ($dummyClient) extends HostResolver { + protected function isInContainer(): bool + { + return true; + } + protected function findGateway(string $networkName): ?string + { + return null; + } + protected function findDefaultGateway(): ?string + { + return '172.0.0.2'; + } + }; + + putenv('DOCKER_HOST=unix://docker:2375'); + $host = $resolver->resolveHost(); + $this->assertEquals('172.0.0.2', $host); + } + + public function testReturnsLocalhostIfUnableToFindGateway(): void + { + // Override to simulate that neither network inspection nor default gateway yield a result. + $dummyClient = $this->createMock(Docker::class); + $resolver = new class ($dummyClient) extends HostResolver { + protected function isInContainer(): bool + { + return true; + } + protected function findGateway(string $networkName): ?string + { + return null; + } + protected function findDefaultGateway(): ?string + { + return null; + } + }; + + putenv('DOCKER_HOST=unix://docker:2375'); + $host = $resolver->resolveHost(); + $this->assertEquals('localhost', $host); + } + + public function testThrowsForUnsupportedProtocol(): void + { + putenv('DOCKER_HOST=invalid://unknown'); + $dummyClient = $this->createMock(Docker::class); + $resolver = new HostResolver($dummyClient); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage("Unsupported Docker host scheme: invalid"); + + $resolver->resolveHost(); + } +} diff --git a/tests/Unit/Utils/TarBuilderTest.php b/tests/Unit/Utils/TarBuilderTest.php index b2f326f..965b9de 100644 --- a/tests/Unit/Utils/TarBuilderTest.php +++ b/tests/Unit/Utils/TarBuilderTest.php @@ -146,7 +146,7 @@ class TarBuilderTest extends TestCase mkdir($extractDir); $this->extractTar($tarPath, $extractDir); - $scanned = array_diff(scandir($extractDir), ['.', '..']); + $scanned = array_diff(scandir($extractDir) ?: [], ['.', '..']); $this->assertCount(0, $scanned, 'Expected empty directory'); } @@ -166,7 +166,7 @@ class TarBuilderTest extends TestCase mkdir($extractDir); $this->extractTar($tarPath, $extractDir); - $scanned = array_diff(scandir($extractDir), ['.', '..']); + $scanned = array_diff(scandir($extractDir) ?: [], ['.', '..']); $this->assertCount(0, $scanned, 'Expected no files after clear()'); } From 6e0952f8e3384d74d937b654addc6b3eb2702031 Mon Sep 17 00:00:00 2001 From: Soner Sayakci Date: Fri, 14 Feb 2025 15:20:34 +0100 Subject: [PATCH 42/54] fix: code style errors --- tests/Integration/GenericContainerTest.php | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/Integration/GenericContainerTest.php b/tests/Integration/GenericContainerTest.php index 2a7c9e9..6d7f124 100644 --- a/tests/Integration/GenericContainerTest.php +++ b/tests/Integration/GenericContainerTest.php @@ -6,7 +6,6 @@ namespace Testcontainers\Tests\Integration; use Docker\API\Model\ContainersIdJsonGetResponse200; use PHPUnit\Framework\TestCase; -use RuntimeException; use Testcontainers\Container\GenericContainer; use Testcontainers\Utils\PortGenerator\FixedPortGenerator; use Testcontainers\Wait\WaitForHostPort; From cf035bcbd5e8d2630169c147a46e99d5a3957e89 Mon Sep 17 00:00:00 2001 From: Soner Sayakci Date: Fri, 14 Feb 2025 15:27:11 +0100 Subject: [PATCH 43/54] fix: update tar command for macOS compatibility and clean up workflow --- .github/workflows/php.yml | 2 -- src/Utils/TarBuilder.php | 9 ++++++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 23c29bf..4273c5a 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -5,8 +5,6 @@ on: branches: - main pull_request: - branches: - - main permissions: contents: read diff --git a/src/Utils/TarBuilder.php b/src/Utils/TarBuilder.php index ba615e7..40c5e37 100644 --- a/src/Utils/TarBuilder.php +++ b/src/Utils/TarBuilder.php @@ -136,9 +136,16 @@ class TarBuilder private function runTarCommand(string $tarFilePath, string $sourceDir): void { + if (PHP_OS_FAMILY === 'Darwin') { + $additionalFlags = ' --disable-copyfile --no-xattrs'; + } else { + $additionalFlags = ''; + } + // without --disable-copyfile and --no-xattrs combination, tar will fail on macOS $cmd = sprintf( - 'tar --no-xattrs --disable-copyfile -cf %s -C %s . 2>&1', + 'tar %s -cf %s -C %s . 2>&1', + $additionalFlags, escapeshellarg($tarFilePath), escapeshellarg($sourceDir) ); From b25371687d784ddb4fe1dcf979cf81c5e47c79ca Mon Sep 17 00:00:00 2001 From: Soner Sayakci Date: Fri, 14 Feb 2025 15:31:41 +0100 Subject: [PATCH 44/54] fix: update port configuration in GenericContainerTest for non standard port --- tests/Integration/GenericContainerTest.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/Integration/GenericContainerTest.php b/tests/Integration/GenericContainerTest.php index 6d7f124..182d04f 100644 --- a/tests/Integration/GenericContainerTest.php +++ b/tests/Integration/GenericContainerTest.php @@ -118,13 +118,13 @@ class GenericContainerTest extends TestCase public function testShouldReturnFirstMappedPort(): void { $container = (new GenericContainer('nginx')) - ->withPortGenerator(new FixedPortGenerator([8080])) + ->withPortGenerator(new FixedPortGenerator([9950])) ->withExposedPorts(80) - ->withWait(new WaitForHostPort(8080)) + ->withWait(new WaitForHostPort(9950)) ->start(); $firstMappedPort = $container->getFirstMappedPort(); - self::assertSame($firstMappedPort, 8080, 'First mapped port does not match 8080'); + self::assertSame($firstMappedPort, 9950, 'First mapped port does not match 9950'); $container->stop(); } From 99b78a9f06b32181f59b2ba317c5cb3a9168a72d Mon Sep 17 00:00:00 2001 From: Soner Sayakci Date: Fri, 14 Feb 2025 15:34:37 +0100 Subject: [PATCH 45/54] fix: update test command in GitHub Actions workflow to use PHPUnit --- .github/workflows/php.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 4273c5a..d77299f 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -63,4 +63,4 @@ jobs: run: composer install --prefer-dist --no-progress - name: Run test suite - run: composer run integration + run: ./vendor/bin/phpunit From 6785bcb7180fe3e0daf3d9567ab2ea525bb0b08b Mon Sep 17 00:00:00 2001 From: Soner Sayakci Date: Fri, 14 Feb 2025 15:43:37 +0100 Subject: [PATCH 46/54] fix: update test command in GitHub Actions workflow to use composer run integration --- .github/workflows/php.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index d77299f..4273c5a 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -63,4 +63,4 @@ jobs: run: composer install --prefer-dist --no-progress - name: Run test suite - run: ./vendor/bin/phpunit + run: composer run integration From b36b08d91606b7bf86624a334beec019fa50a439 Mon Sep 17 00:00:00 2001 From: Sergei Shitikov Date: Sun, 16 Feb 2025 13:02:01 +0100 Subject: [PATCH 47/54] remove legacy --- composer.json | 3 +- src/Container/Container.php | 172 ------------------ src/Container/GenericContainer.php | 21 +-- src/Container/MariaDBContainer.php | 49 ----- src/Container/MySQLContainer.php | 49 ----- src/Container/OpenSearchContainer.php | 42 ----- src/Container/PostgresContainer.php | 53 ------ src/Container/RedisContainer.php | 29 --- src/Container/TestContainer.php | 5 +- src/Modules/MariaDBContainer.php | 8 +- src/Modules/MySQLContainer.php | 8 +- src/Modules/OpenSearchContainer.php | 9 +- src/Modules/PostgresContainer.php | 14 +- src/Wait/WaitForTcpPortOpen.php | 26 --- tests/Integration/GenericContainerTest.php | 1 - tests/Integration/OldTests/ContainerTest.php | 142 --------------- .../Integration/OldTests/WaitStrategyTest.php | 138 -------------- 17 files changed, 29 insertions(+), 740 deletions(-) delete mode 100644 src/Container/Container.php delete mode 100644 src/Container/MariaDBContainer.php delete mode 100644 src/Container/MySQLContainer.php delete mode 100644 src/Container/OpenSearchContainer.php delete mode 100644 src/Container/PostgresContainer.php delete mode 100644 src/Container/RedisContainer.php delete mode 100644 src/Wait/WaitForTcpPortOpen.php delete mode 100644 tests/Integration/OldTests/ContainerTest.php delete mode 100644 tests/Integration/OldTests/WaitStrategyTest.php diff --git a/composer.json b/composer.json index 42cc19a..4632765 100644 --- a/composer.json +++ b/composer.json @@ -41,8 +41,7 @@ } }, "scripts": { - "integration": "paratest tests/ --exclude-group=legacy --bootstrap vendor/autoload.php -f", - "integration:old": "phpunit tests/Integration/OldTests --bootstrap vendor/autoload.php", + "integration": "paratest tests/ --bootstrap vendor/autoload.php -f", "cs": "php-cs-fixer fix --dry-run", "cs:fix": "php-cs-fixer fix", "phpstan": "phpstan analyse --memory-limit=256M" diff --git a/src/Container/Container.php b/src/Container/Container.php deleted file mode 100644 index ade3661..0000000 --- a/src/Container/Container.php +++ /dev/null @@ -1,172 +0,0 @@ - $cmd - */ - public function withCmd(array $cmd): self - { - return $this->withCommand($cmd); - } - - /** - * @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 - { - $this->withPortGenerator(new FixedPortGenerator([(int)$localPort])); - 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 65555f7..abdbc5c 100644 --- a/src/Container/GenericContainer.php +++ b/src/Container/GenericContainer.php @@ -154,25 +154,13 @@ class GenericContainer implements TestContainer } /** - * 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. + * @param array $env An array of key-value pairs: $object->withEnvironment(['key1' => 'value1', 'key2' => 'value2']); * @return static Returns itself for chaining purposes. */ - public function withEnvironment(string | array $env, ?string $value = null): static + public function withEnvironment(array $env): static { - 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; + foreach ($env as $key => $val) { + $this->env[$key] = $val; } return $this; @@ -267,7 +255,6 @@ class GenericContainer implements TestContainer return $this; } - //TODO: not yet implemented public function withNetwork(string $networkName): static { $this->networkName = $networkName; diff --git a/src/Container/MariaDBContainer.php b/src/Container/MariaDBContainer.php deleted file mode 100644 index dfd5dbc..0000000 --- a/src/Container/MariaDBContainer.php +++ /dev/null @@ -1,49 +0,0 @@ -withPortGenerator(new FixedPortGenerator([3306])); - $this->withExposedPorts(3306); - $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 - { - 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/Container/MySQLContainer.php b/src/Container/MySQLContainer.php deleted file mode 100644 index ee8f653..0000000 --- a/src/Container/MySQLContainer.php +++ /dev/null @@ -1,49 +0,0 @@ -withPortGenerator(new FixedPortGenerator([3306])); - $this->withExposedPorts(3306); - $this->withEnvironment('MYSQL_ROOT_PASSWORD', $mysqlRootPassword); - $this->withWait(new WaitForExec([ - "mysqladmin", - "ping", - "-h", "127.0.0.1", - ])); - } - - 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/Container/OpenSearchContainer.php b/src/Container/OpenSearchContainer.php deleted file mode 100644 index dde44a7..0000000 --- a/src/Container/OpenSearchContainer.php +++ /dev/null @@ -1,42 +0,0 @@ -withPortGenerator(new FixedPortGenerator([9200])); - $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 - )); - } - - public static function make(string $version = 'latest'): self - { - return new self($version); - } - - public function disableSecurityPlugin(): self - { - $this->withEnvironment('plugins.security.disabled', 'true'); - - return $this; - } -} diff --git a/src/Container/PostgresContainer.php b/src/Container/PostgresContainer.php deleted file mode 100644 index 073144e..0000000 --- a/src/Container/PostgresContainer.php +++ /dev/null @@ -1,53 +0,0 @@ -withPortGenerator(new FixedPortGenerator([5432])); - $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])); - } - - 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 deleted file mode 100644 index 8b895ec..0000000 --- a/src/Container/RedisContainer.php +++ /dev/null @@ -1,29 +0,0 @@ -withPortGenerator(new FixedPortGenerator([6379])); - $this->withExposedPorts(6379); - $this->withWait(new WaitForLog('Ready to accept connections')); - } - - public static function make(string $version = 'latest'): self - { - return new self($version); - } -} diff --git a/src/Container/TestContainer.php b/src/Container/TestContainer.php index 2885e62..43b68ed 100644 --- a/src/Container/TestContainer.php +++ b/src/Container/TestContainer.php @@ -19,10 +19,9 @@ interface TestContainer public function withEntrypoint(string $entryPoint): static; /** - * TODO: replace with array after deprecated implementation is removed - * @param array|string $env + * @param array $env An array of key-value pairs */ - public function withEnvironment(array | string $env, ?string $value): static; + public function withEnvironment(array $env): static; /** @param int|string|array $ports One or more ports to expose. */ public function withExposedPorts(...$ports): static; diff --git a/src/Modules/MariaDBContainer.php b/src/Modules/MariaDBContainer.php index a45ffb3..c6603d4 100644 --- a/src/Modules/MariaDBContainer.php +++ b/src/Modules/MariaDBContainer.php @@ -13,7 +13,7 @@ class MariaDBContainer extends GenericContainer { parent::__construct('mariadb:' . $version); $this->withExposedPorts(3306); - $this->withEnvironment('MARIADB_ROOT_PASSWORD', $mysqlRootPassword); + $this->withEnvironment(['MARIADB_ROOT_PASSWORD' => $mysqlRootPassword]); $this->withWait(new WaitForExec([ "mariadb-admin", "ping", @@ -23,15 +23,15 @@ class MariaDBContainer extends GenericContainer public function withMariaDBUser(string $username, string $password): self { - $this->withEnvironment('MARIADB_USER', $username); - $this->withEnvironment('MARIADB_PASSWORD', $password); + $this->withEnvironment(['MARIADB_USER' => $username]); + $this->withEnvironment(['MARIADB_PASSWORD' => $password]); return $this; } public function withMariaDBDatabase(string $database): self { - $this->withEnvironment('MARIADB_DATABASE', $database); + $this->withEnvironment(['MARIADB_DATABASE' => $database]); return $this; } diff --git a/src/Modules/MySQLContainer.php b/src/Modules/MySQLContainer.php index 00c6a90..283c999 100644 --- a/src/Modules/MySQLContainer.php +++ b/src/Modules/MySQLContainer.php @@ -13,7 +13,7 @@ class MySQLContainer extends GenericContainer { parent::__construct('mysql:' . $version); $this->withExposedPorts(3306); - $this->withEnvironment('MYSQL_ROOT_PASSWORD', $mysqlRootPassword); + $this->withEnvironment(['MYSQL_ROOT_PASSWORD' => $mysqlRootPassword]); $this->withWait(new WaitForExec([ "mysqladmin", "ping", @@ -23,15 +23,15 @@ class MySQLContainer extends GenericContainer public function withMySQLUser(string $username, string $password): self { - $this->withEnvironment('MYSQL_USER', $username); - $this->withEnvironment('MYSQL_PASSWORD', $password); + $this->withEnvironment(['MYSQL_USER' => $username]); + $this->withEnvironment(['MYSQL_PASSWORD' => $password]); return $this; } public function withMySQLDatabase(string $database): self { - $this->withEnvironment('MYSQL_DATABASE', $database); + $this->withEnvironment(['MYSQL_DATABASE' => $database]); return $this; } diff --git a/src/Modules/OpenSearchContainer.php b/src/Modules/OpenSearchContainer.php index ddbb445..0f74cde 100644 --- a/src/Modules/OpenSearchContainer.php +++ b/src/Modules/OpenSearchContainer.php @@ -13,8 +13,11 @@ class OpenSearchContainer extends GenericContainer { parent::__construct('opensearchproject/opensearch:' . $version); $this->withExposedPorts(9200); - $this->withEnvironment('discovery.type', 'single-node'); - $this->withEnvironment('OPENSEARCH_INITIAL_ADMIN_PASSWORD', 'c3o_ZPHo!'); + $this->withEnvironment([ + 'discovery.type' => 'single-node', + 'OPENSEARCH_INITIAL_ADMIN_PASSWORD' => 'c3o_ZPHo!' + ]); + $this->withWait(new WaitForLog( '/\]\s+started\?\[/', true, @@ -24,7 +27,7 @@ class OpenSearchContainer extends GenericContainer public function withDisabledSecurityPlugin(): self { - $this->withEnvironment('plugins.security.disabled', 'true'); + $this->withEnvironment(['plugins.security.disabled' => 'true']); return $this; } diff --git a/src/Modules/PostgresContainer.php b/src/Modules/PostgresContainer.php index 764663b..6b5e43f 100644 --- a/src/Modules/PostgresContainer.php +++ b/src/Modules/PostgresContainer.php @@ -17,29 +17,31 @@ class PostgresContainer extends GenericContainer ) { 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->withEnvironment([ + 'POSTGRES_USER' => $this->username, + 'POSTGRES_PASSWORD' => $this->password, + 'POSTGRES_DB' => $this->database, + ]); $this->withWait(new WaitForExec(["pg_isready", "-h", "127.0.0.1", "-U", $this->username])); } public function withPostgresUser(string $username): self { - $this->withEnvironment('POSTGRES_USER', $username); + $this->withEnvironment(['POSTGRES_USER' => $username]); return $this; } public function withPostgresPassword(string $password): self { - $this->withEnvironment('POSTGRES_PASSWORD', $password); + $this->withEnvironment(['POSTGRES_PASSWORD' => $password]); return $this; } public function withPostgresDatabase(string $database): self { - $this->withEnvironment('POSTGRES_DB', $database); + $this->withEnvironment(['POSTGRES_DB' => $database]); return $this; } diff --git a/src/Wait/WaitForTcpPortOpen.php b/src/Wait/WaitForTcpPortOpen.php deleted file mode 100644 index a65cebe..0000000 --- a/src/Wait/WaitForTcpPortOpen.php +++ /dev/null @@ -1,26 +0,0 @@ -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']); - - $container->stop(); - } - - 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 deleted file mode 100644 index a7c63cb..0000000 --- a/tests/Integration/OldTests/WaitStrategyTest.php +++ /dev/null @@ -1,138 +0,0 @@ -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); - - $container->stop(); - } - - public function testWaitForLog(): void - { - $container = RedisContainer::make() - ->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(3000)) - ->withPort('3000', '80'); - - $container->run(); - - $ch = curl_init(); - curl_setopt($ch, CURLOPT_URL, sprintf('http://%s:%d', $container->getAddress(), $container->getPort())); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - - $response = (string) curl_exec($ch); - - curl_close($ch); - - $this->assertNotEmpty($response); - - $container->stop(); - } - - public function testWaitForTcpPortOpen(): void - { - $container = Container::make('nginx:alpine') - ->withWait(WaitForTcpPortOpen::make(80)) - ->withPort('80', '80'); - - $container->run(); - - static::assertIsResource(fsockopen($container->getAddress(), 80), 'Failed to connect to container'); - - $container->stop(); - } - - public function testWaitForHealthCheck(): void - { - $container = Container::make('nginx') - ->withHealthCheckCommand('curl --fail http://localhost') - ->withPort('80', '80') - ->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); - - $container->stop(); - } -} From 51f6e9d22ba9c1148fbf20e59ee514a16753a600 Mon Sep 17 00:00:00 2001 From: Sergei Shitikov Date: Sun, 16 Feb 2025 13:03:02 +0100 Subject: [PATCH 48/54] php-cs-fixer --- src/Modules/OpenSearchContainer.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Modules/OpenSearchContainer.php b/src/Modules/OpenSearchContainer.php index 0f74cde..57b1f94 100644 --- a/src/Modules/OpenSearchContainer.php +++ b/src/Modules/OpenSearchContainer.php @@ -15,7 +15,7 @@ class OpenSearchContainer extends GenericContainer $this->withExposedPorts(9200); $this->withEnvironment([ 'discovery.type' => 'single-node', - 'OPENSEARCH_INITIAL_ADMIN_PASSWORD' => 'c3o_ZPHo!' + 'OPENSEARCH_INITIAL_ADMIN_PASSWORD' => 'c3o_ZPHo!', ]); $this->withWait(new WaitForLog( From cc99febf716ee3e5f36a75c5ac8b4beb3b80a93d Mon Sep 17 00:00:00 2001 From: Sergei Shitikov Date: Sun, 16 Feb 2025 13:15:50 +0100 Subject: [PATCH 49/54] try to solve testShouldReturnFirstMappedPort --- tests/Integration/GenericContainerTest.php | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/tests/Integration/GenericContainerTest.php b/tests/Integration/GenericContainerTest.php index 6d7f124..88977cf 100644 --- a/tests/Integration/GenericContainerTest.php +++ b/tests/Integration/GenericContainerTest.php @@ -112,14 +112,11 @@ class GenericContainerTest extends TestCase $container->stop(); } - /** - * @throws \JsonException - */ public function testShouldReturnFirstMappedPort(): void { - $container = (new GenericContainer('nginx')) + $container = (new GenericContainer('cristianrgreco/testcontainer:1.1.14')) ->withPortGenerator(new FixedPortGenerator([8080])) - ->withExposedPorts(80) + ->withExposedPorts(8080) ->withWait(new WaitForHostPort(8080)) ->start(); $firstMappedPort = $container->getFirstMappedPort(); From 1fb7efd7bd5921e2d541b54efec5edc3d8c7419c Mon Sep 17 00:00:00 2001 From: Sergei Shitikov Date: Sun, 16 Feb 2025 13:42:33 +0100 Subject: [PATCH 50/54] increase default timeout for mysql and mariadb, change image for testShouldReturnFirstMappedPort() --- src/Modules/MariaDBContainer.php | 2 +- src/Modules/MySQLContainer.php | 2 +- tests/Integration/GenericContainerTest.php | 10 +++++----- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/Modules/MariaDBContainer.php b/src/Modules/MariaDBContainer.php index c6603d4..b8c868e 100644 --- a/src/Modules/MariaDBContainer.php +++ b/src/Modules/MariaDBContainer.php @@ -18,7 +18,7 @@ class MariaDBContainer extends GenericContainer "mariadb-admin", "ping", "-h", "127.0.0.1", - ])); + ], null, 15000)); } public function withMariaDBUser(string $username, string $password): self diff --git a/src/Modules/MySQLContainer.php b/src/Modules/MySQLContainer.php index 283c999..580dfd8 100644 --- a/src/Modules/MySQLContainer.php +++ b/src/Modules/MySQLContainer.php @@ -18,7 +18,7 @@ class MySQLContainer extends GenericContainer "mysqladmin", "ping", "-h", "127.0.0.1", - ])); + ], null, 15000)); } public function withMySQLUser(string $username, string $password): self diff --git a/tests/Integration/GenericContainerTest.php b/tests/Integration/GenericContainerTest.php index 7e00fc8..88977cf 100644 --- a/tests/Integration/GenericContainerTest.php +++ b/tests/Integration/GenericContainerTest.php @@ -114,14 +114,14 @@ class GenericContainerTest extends TestCase public function testShouldReturnFirstMappedPort(): void { - $container = (new GenericContainer('nginx')) - ->withPortGenerator(new FixedPortGenerator([9950])) - ->withExposedPorts(80) - ->withWait(new WaitForHostPort(9950)) + $container = (new GenericContainer('cristianrgreco/testcontainer:1.1.14')) + ->withPortGenerator(new FixedPortGenerator([8080])) + ->withExposedPorts(8080) + ->withWait(new WaitForHostPort(8080)) ->start(); $firstMappedPort = $container->getFirstMappedPort(); - self::assertSame($firstMappedPort, 9950, 'First mapped port does not match 9950'); + self::assertSame($firstMappedPort, 8080, 'First mapped port does not match 8080'); $container->stop(); } From 1f7178e3135daa1678bef856aa6e11aca3fb9996 Mon Sep 17 00:00:00 2001 From: Sergei Shitikov Date: Sun, 16 Feb 2025 15:45:28 +0100 Subject: [PATCH 51/54] try to fix testShouldReturnFirstMappedPort in ci --- tests/Integration/GenericContainerTest.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/Integration/GenericContainerTest.php b/tests/Integration/GenericContainerTest.php index 88977cf..79ca358 100644 --- a/tests/Integration/GenericContainerTest.php +++ b/tests/Integration/GenericContainerTest.php @@ -114,14 +114,14 @@ class GenericContainerTest extends TestCase public function testShouldReturnFirstMappedPort(): void { - $container = (new GenericContainer('cristianrgreco/testcontainer:1.1.14')) - ->withPortGenerator(new FixedPortGenerator([8080])) - ->withExposedPorts(8080) - ->withWait(new WaitForHostPort(8080)) + $container = (new GenericContainer('nginx')) + ->withPortGenerator(new FixedPortGenerator([9090])) + ->withExposedPorts(80) + ->withWait(new WaitForHostPort(9090)) ->start(); $firstMappedPort = $container->getFirstMappedPort(); - self::assertSame($firstMappedPort, 8080, 'First mapped port does not match 8080'); + self::assertSame($firstMappedPort, 9090, 'First mapped port does not match 9090'); $container->stop(); } From 5e86ed1443f6084678d1bfb1b0b74054aa9a611a Mon Sep 17 00:00:00 2001 From: Sergei Shitikov Date: Sun, 16 Feb 2025 18:02:16 +0100 Subject: [PATCH 52/54] adjust wait for host port strategy --- src/Container/StartedGenericContainer.php | 8 ++--- src/Container/StartedTestContainer.php | 6 ++++ src/Wait/WaitForHostPort.php | 33 +++++++++++++------ tests/Integration/GenericContainerTest.php | 6 ++-- .../StartedGenericContainerTest.php | 1 + 5 files changed, 36 insertions(+), 18 deletions(-) diff --git a/src/Container/StartedGenericContainer.php b/src/Container/StartedGenericContainer.php index 259abd6..d3a8194 100644 --- a/src/Container/StartedGenericContainer.php +++ b/src/Container/StartedGenericContainer.php @@ -108,7 +108,7 @@ class StartedGenericContainer implements StartedTestContainer public function getMappedPort(int $port): int { - $ports = (array) $this->ports(); + $ports = (array) $this->getBoundPorts(); /** @var PortBinding | null $portBinding */ $portBinding = $ports["{$port}/tcp"][0] ?? null; $mappedPort = $portBinding?->getHostPort(); @@ -121,7 +121,7 @@ class StartedGenericContainer implements StartedTestContainer public function getFirstMappedPort(): int { - $ports = (array) $this->ports(); + $ports = (array) $this->getBoundPorts(); $port = array_key_first($ports); /** @var PortBinding | null $firstPortBinding */ $firstPortBinding = $ports[$port][0] ?? null; @@ -193,10 +193,10 @@ class StartedGenericContainer implements StartedTestContainer } /** - * @return array> + * @return iterable> * @throws RuntimeException */ - protected function ports(): iterable + public function getBoundPorts(): iterable { $ports = $this->inspect()?->getNetworkSettings()?->getPorts(); diff --git a/src/Container/StartedTestContainer.php b/src/Container/StartedTestContainer.php index 8d1acd3..551d9be 100644 --- a/src/Container/StartedTestContainer.php +++ b/src/Container/StartedTestContainer.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Testcontainers\Container; +use Docker\API\Model\PortBinding; use Docker\Docker; interface StartedTestContainer @@ -13,6 +14,11 @@ interface StartedTestContainer */ public function exec(array $command): string; + /** + * @return iterable> + */ + public function getBoundPorts(): iterable; + public function getClient(): Docker; public function getFirstMappedPort(): int; diff --git a/src/Wait/WaitForHostPort.php b/src/Wait/WaitForHostPort.php index d9f7f86..9a97388 100644 --- a/src/Wait/WaitForHostPort.php +++ b/src/Wait/WaitForHostPort.php @@ -9,18 +9,9 @@ use Testcontainers\Exception\ContainerWaitingTimeoutException; class WaitForHostPort extends BaseWaitStrategy { - public function __construct( - protected int $port, - int $timeout = 10000, - int $pollInterval = 500 - ) { - parent::__construct($timeout, $pollInterval); - } - public function wait(StartedTestContainer $container): void { $startTime = microtime(true) * 1000; - $containerAddress = $container->getHost(); while (true) { $elapsedTime = (microtime(true) * 1000) - $startTime; @@ -29,7 +20,7 @@ class WaitForHostPort extends BaseWaitStrategy throw new ContainerWaitingTimeoutException($container->getId()); } - if ($this->isPortOpen($containerAddress, $this->port)) { + if ($this->boundPortsOpened($container)) { return; // Port is open, container is ready } @@ -37,6 +28,28 @@ class WaitForHostPort extends BaseWaitStrategy } } + /** + * @param StartedTestContainer $container + * @return bool + */ + private function boundPortsOpened(StartedTestContainer $container): bool + { + $boundPorts = $container->getBoundPorts(); + foreach ($boundPorts as $bindings) { + foreach ($bindings as $binding) { + $hostIp = trim($binding->getHostIp() ?? ''); + if ($hostIp === '' || $hostIp === '0.0.0.0') { + $hostIp = $container->getHost(); + } + $hostPort = (int)$binding->getHostPort(); + if (!$this->isPortOpen($hostIp, $hostPort)) { + return false; + } + } + } + return true; + } + private function isPortOpen(string $ipAddress, int $port): bool { $connection = @fsockopen($ipAddress, $port, $errno, $errstr, 2); diff --git a/tests/Integration/GenericContainerTest.php b/tests/Integration/GenericContainerTest.php index 79ca358..7ed65bb 100644 --- a/tests/Integration/GenericContainerTest.php +++ b/tests/Integration/GenericContainerTest.php @@ -7,7 +7,6 @@ namespace Testcontainers\Tests\Integration; use Docker\API\Model\ContainersIdJsonGetResponse200; use PHPUnit\Framework\TestCase; use Testcontainers\Container\GenericContainer; -use Testcontainers\Utils\PortGenerator\FixedPortGenerator; use Testcontainers\Wait\WaitForHostPort; class GenericContainerTest extends TestCase @@ -115,13 +114,12 @@ class GenericContainerTest extends TestCase public function testShouldReturnFirstMappedPort(): void { $container = (new GenericContainer('nginx')) - ->withPortGenerator(new FixedPortGenerator([9090])) ->withExposedPorts(80) - ->withWait(new WaitForHostPort(9090)) + ->withWait(new WaitForHostPort()) ->start(); $firstMappedPort = $container->getFirstMappedPort(); - self::assertSame($firstMappedPort, 9090, 'First mapped port does not match 9090'); + self::assertSame($firstMappedPort, $container->getMappedPort(80)); $container->stop(); } diff --git a/tests/Integration/StartedGenericContainerTest.php b/tests/Integration/StartedGenericContainerTest.php index a59976c..6ec5a5f 100644 --- a/tests/Integration/StartedGenericContainerTest.php +++ b/tests/Integration/StartedGenericContainerTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Testcontainers\Tests\Integration; use Testcontainers\Container\GenericContainer; +use Testcontainers\Wait\WaitForHostPort; class StartedGenericContainerTest extends ContainerTestCase { From cb4dc2aff0b8aa668b997364981a248e13c0cde2 Mon Sep 17 00:00:00 2001 From: Sergei Shitikov Date: Sun, 16 Feb 2025 18:06:21 +0100 Subject: [PATCH 53/54] cs fix, remove unused import --- tests/Integration/StartedGenericContainerTest.php | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/Integration/StartedGenericContainerTest.php b/tests/Integration/StartedGenericContainerTest.php index 6ec5a5f..a59976c 100644 --- a/tests/Integration/StartedGenericContainerTest.php +++ b/tests/Integration/StartedGenericContainerTest.php @@ -5,7 +5,6 @@ declare(strict_types=1); namespace Testcontainers\Tests\Integration; use Testcontainers\Container\GenericContainer; -use Testcontainers\Wait\WaitForHostPort; class StartedGenericContainerTest extends ContainerTestCase { From 26e9a672fdc8b5bd1a0fb71ae4b5e5ab695f255b Mon Sep 17 00:00:00 2001 From: Sergei Shitikov Date: Sun, 16 Feb 2025 18:17:09 +0100 Subject: [PATCH 54/54] remove forgotten legacy part, adjust doc --- README.md | 6 +++++- src/Wait/WaitForHttp.php | 10 ---------- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index c5fad87..788453f 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ use Testcontainers\Wait\WaitForExec; use Testcontainers\Wait\WaitForLog; use Testcontainers\Wait\WaitForHttp; use Testcontainers\Wait\WaitForHealthCheck; +use Testcontainers\Wait\WaitForHostPort; $container = new GenericContainer('nginx:alpine'); @@ -58,7 +59,10 @@ $container->withWait(new WaitForLog('Ready to accept connections')); // Wait for an http request to succeed -$container->withWait(WaitForHttp::make($port, $method = 'GET', $path = '/')); +$container->withWait(new WaitForHttp($port, $method = 'GET', $path = '/')); + +// Wait for all bound ports to be open +$container->withWait(new WaitForHostPort()); // Wait until the docker heartcheck is green $container->withWait(new WaitForHealthCheck()); diff --git a/src/Wait/WaitForHttp.php b/src/Wait/WaitForHttp.php index 3ae06c2..b2dceeb 100644 --- a/src/Wait/WaitForHttp.php +++ b/src/Wait/WaitForHttp.php @@ -38,16 +38,6 @@ class WaitForHttp extends BaseWaitStrategy parent::__construct($timeout, $pollInterval); } - /** - * @deprecated Use constructor instead - * Kept for backward compatibility - * Should be removed in next major version - */ - public static function make(int $port): self - { - return new self($port); - } - /** * @param HttpMethod|value-of $method */