Merge pull request #17 from rw4lll/feat/docker-engine-api-client

Feat/docker engine api client
This commit is contained in:
Shyim
2024-10-01 11:34:49 +02:00
committed by GitHub
54 changed files with 1801 additions and 565 deletions
+62 -43
View File
@@ -17,12 +17,15 @@ composer req --dev testcontainers/testcontainers
```php
<?php
use Testcontainers\Container\Container;
use Testcontainers\Container\GenericContainer;
$container = Container::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
});
@@ -58,16 +69,19 @@ $container->withWait(new WaitForHealthCheck());
```php
<?php
use Testcontainers\Container\MySQLContainer;
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',
);
@@ -80,16 +94,19 @@ $pdo = new \PDO(
```php
<?php
use Testcontainers\Container\MariaDBContainer;
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',
);
@@ -102,18 +119,21 @@ $pdo = new \PDO(
```php
<?php
use Testcontainers\Container\PostgresContainer;
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
@@ -123,14 +143,13 @@ $pdo = new \PDO(
```php
use Testcontainers\Container\RedisContainer;
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
```
@@ -139,12 +158,11 @@ $redis->connect($container->getAddress());
```php
use Testcontainers\Container\OpenSearchContainer;
use Testcontainers\Modules\OpenSearchContainer;
$container = OpenSearchContainer::make('2');
$container->disableSecurityPlugin();
$container->run();
$container = (new OpenSearchContainer())
->withDisabledSecurityPlugin()
->start();
// Do something with opensearch
```
@@ -166,7 +184,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
{
@@ -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);
}
+7 -2
View File
@@ -14,10 +14,14 @@
}
],
"require": {
"ext-curl": "*",
"php": ">= 8.1",
"symfony/process": "^5.0|^6.0|^7.0"
"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",
@@ -44,7 +48,8 @@
},
"config": {
"allow-plugins": {
"phpstan/extension-installer": true
"phpstan/extension-installer": true,
"php-http/discovery": false
}
}
}
+122 -249
View File
@@ -4,97 +4,63 @@ declare(strict_types=1);
namespace Testcontainers\Container;
use Symfony\Component\Process\Process;
use Testcontainers\Exception\ContainerNotReadyException;
use Testcontainers\Registry;
use Testcontainers\Trait\DockerContainerAwareTrait;
use Testcontainers\Wait\WaitForNothing;
use Testcontainers\Wait\WaitInterface;
use Testcontainers\Utils\PortGenerator\FixedPortGenerator;
/**
* @phpstan-type ContainerInspectSingleNetwork array<int, array{'NetworkSettings': array{'IPAddress': string}}>
* @phpstan-type ContainerInspectMultipleNetworks array<int, array{'NetworkSettings': array{'Networks': array<string, array{'IPAddress': string}>}}>
* @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}
* Added for backward compatibility.
* @deprecated Use GenericContainer instead.
* TODO: Remove in next major release.
*/
class Container
class Container extends GenericContainer
{
use DockerContainerAwareTrait;
protected ?StartedTestContainer $startedContainer = null;
private string $id;
private ?string $entryPoint = null;
/**
* @var array<string, string>
*/
private array $env = [];
private Process $process;
private WaitInterface $wait;
private ?string $hostname = null;
private bool $privileged = false;
private ?string $network = null;
private ?string $healthCheckCommand = null;
private int $healthCheckIntervalInMS;
/**
* @var array<string>
*/
private array $cmd = [];
/**
* @var ContainerInspect
*/
private array $inspectedData;
/**
* @var array<string>
*/
private array $mounts = [];
/**
* @var array<string>
*/
private array $ports = [];
protected function __construct(private string $image)
{
$this->wait = new WaitForNothing();
}
protected ?StoppedTestContainer $stoppedContainer = null;
public static function make(string $image): self
{
return new Container($image);
return new self($image);
}
public function getId(): string
/**
* @deprecated Use `withCommand` instead
* @param array<string> $cmd
*/
public function withCmd(array $cmd): self
{
return $this->id;
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
{
$this->hostname = $hostname;
return $this;
}
public function withEntryPoint(string $entryPoint): self
/**
* @deprecated Use `withPrivilegedMode` instead
*/
public function withPrivileged(bool $privileged = true): self
{
$this->entryPoint = $entryPoint;
return $this;
return $this->withPrivilegedMode($privileged);
}
public function withEnvironment(string $name, string $value): self
/**
* @deprecated Use `withExposedPorts` instead
*/
public function withPort(string $localPort, string $containerPort): self
{
$this->env[$name] = $value;
return $this;
$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;
@@ -102,208 +68,115 @@ class Container
return $this;
}
public function withWait(WaitInterface $wait): self
/**
* @deprecated Use `start` instead
*/
public function run(): self
{
$this->wait = $wait;
return $this;
}
public function withHealthCheckCommand(string $command, int $healthCheckIntervalInMS = 1000): self
{
$this->healthCheckCommand = $command;
$this->healthCheckIntervalInMS = $healthCheckIntervalInMS;
$this->startedContainer = $this->start();
return $this;
}
/**
* @param array<string> $cmd
* @param array<string> $commandAsArray
* @deprecated Use 'exec' from StartedTestContainer instead
*/
public function withCmd(array $cmd): self
public function execute(array $commandAsArray): string
{
$this->cmd = $cmd;
return $this;
}
public function withMount(string $localPath, string $containerPath): self
{
$this->mounts[] = '-v';
$this->mounts[] = sprintf('%s:%s', $localPath, $containerPath);
return $this;
}
public function withPort(string $localPort, string $containerPort): self
{
$this->ports[] = '-p';
$this->ports[] = sprintf('%s:%s', $localPort, $containerPort);
return $this;
}
public function withPrivileged(bool $privileged = true): self
{
$this->privileged = $privileged;
return $this;
}
public function withNetwork(string $network): self
{
$this->network = $network;
return $this;
}
public function run(bool $wait = true): self
{
$this->id = uniqid('testcontainer', true);
$params = [
'docker',
'run',
'--rm',
'--detach',
'--name',
$this->id,
...$this->mounts,
...$this->ports,
];
foreach ($this->env as $name => $value) {
$params[] = '--env';
$params[] = $name . '=' . $value;
if ($this->startedContainer === null) {
throw new \RuntimeException('Container is not started');
}
if ($this->healthCheckCommand !== null) {
$params[] = '--health-cmd';
$params[] = $this->healthCheckCommand;
$params[] = '--health-interval';
$params[] = $this->healthCheckIntervalInMS . 'ms';
}
if ($this->network !== null) {
$params[] = '--network';
$params[] = $this->network;
}
if ($this->hostname !== null) {
$params[] = '--hostname';
$params[] = $this->hostname;
}
if ($this->entryPoint !== null) {
$params[] = '--entrypoint';
$params[] = $this->entryPoint;
}
if ($this->privileged) {
$params[] = '--privileged';
}
$params[] = $this->image;
if (count($this->cmd) > 0) {
array_push($params, ...$this->cmd);
}
$this->process = new Process($params);
$this->process->mustRun();
$this->inspectedData = self::dockerContainerInspect($this->id);
Registry::add($this);
if ($wait) {
$this->wait();
}
return $this;
}
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);
}
}
throw new ContainerNotReadyException($this->id);
}
public function stop(): self
{
$stop = new Process(['docker', 'stop', $this->id]);
$stop->mustRun();
return $this;
}
public function start(): self
{
$start = new Process(['docker', 'start', $this->id]);
$start->mustRun();
return $this;
}
public function restart(): self
{
$restart = new Process(['docker', 'restart', $this->id]);
$restart->mustRun();
return $this;
}
public function remove(): self
{
$remove = new Process(['docker', 'rm', '-f', $this->id]);
$remove->mustRun();
Registry::remove($this);
return $this;
}
public function kill(): self
{
$kill = new Process(['docker', 'kill', $this->id]);
$kill->mustRun();
return $this;
return $this->startedContainer->exec($commandAsArray);
}
/**
* @param array<string> $command
* @deprecated Use 'logs' from StartedTestContainer instead
*/
public function execute(array $command): Process
{
$process = new Process(['docker', 'exec', $this->id, ...$command]);
$process->mustRun();
return $process;
}
public function logs(): string
{
$logs = new Process(['docker', 'logs', $this->id]);
$logs->mustRun();
if ($this->startedContainer === null) {
throw new \RuntimeException('Container is not started');
}
return $logs->getOutput();
return $this->startedContainer->logs();
}
/**
* @deprecated Use 'getHost' from StartedTestContainer instead
*/
public function getAddress(): string
{
return self::dockerContainerAddress(
containerId: $this->id,
networkName: $this->network,
inspectedData: $this->inspectedData
);
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;
}
}
+309
View File
@@ -0,0 +1,309 @@
<?php
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\PortGenerator;
use Testcontainers\Utils\PortGenerator\RandomUniquePortGenerator;
use Testcontainers\Utils\PortNormalizer;
use Testcontainers\Wait\WaitForContainer;
use Testcontainers\Wait\WaitStrategy;
class GenericContainer implements TestContainer
{
protected Docker $dockerClient;
protected string $image;
protected string $id;
/** @var list<string> */
protected array $command = [];
protected ?string $entryPoint = null;
protected ?HealthConfig $healthConfig = null;
/**
* @var array<string, string>
*/
protected array $env = [];
protected WaitStrategy $waitStrategy;
protected PortGenerator $portGenerator;
protected bool $isPrivileged = false;
protected ?string $networkName = null;
protected int $startAttempts = 0;
protected const MAX_START_ATTEMPTS = 2;
/**
* @var array<Mount>
*/
protected array $mounts = [];
/** @var array<string> List of exposed ports in the format ['8080/tcp'] */
protected array $exposedPorts = [];
public function __construct(string $image)
{
$this->image = $image;
$this->dockerClient = DockerContainerClient::getDockerClient();
$this->waitStrategy = new WaitForContainer();
$this->portGenerator = new RandomUniquePortGenerator();
}
public function getId(): string
{
return $this->id;
}
/**
* @param list<string> $command
*/
public function withCommand(array $command): static
{
$this->command = $command;
return $this;
}
public function withEntryPoint(string $entryPoint): static
{
$this->entryPoint = $entryPoint;
return $this;
}
/**
* 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<string, string> $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
{
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 withWait(WaitStrategy $waitStrategy): static
{
$this->waitStrategy = $waitStrategy;
return $this;
}
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;
}
public function withMount(string $localPath, string $containerPath): static
{
$this->mounts[] = new Mount(['type' => 'bind', 'source' => $localPath, 'target' => $containerPath]);
return $this;
}
/**
* 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<int|string> $ports One or more ports to expose.
* @return static Fluent interface for chaining.
*/
public function withExposedPorts(...$ports): static
{
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[] = PortNormalizer::normalizePort($port);
}
}
return $this;
}
public function withPrivilegedMode(bool $privileged = true): static
{
$this->isPrivileged = $privileged;
return $this;
}
//TODO: not yet implemented
public function withNetwork(string $networkName): static
{
$this->networkName = $networkName;
return $this;
}
public function withPortGenerator(PortGenerator $portGenerator): static
{
$this->portGenerator = $portGenerator;
return $this;
}
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();
}
$this->dockerClient->containerStart($this->id);
$startedContainer = new StartedGenericContainer($this->id);
$this->waitStrategy->wait($startedContainer);
return $startedContainer;
}
protected function createContainerConfig(): ContainersCreatePostBody
{
$containerCreatePostBody = new ContainersCreatePostBody();
$containerCreatePostBody->setImage($this->image);
$containerCreatePostBody->setCmd($this->command);
$envs = array_map(static fn ($key, $value) => "$key=$value", array_keys($this->env), $this->env);
$containerCreatePostBody->setEnv($envs);
$hostConfig = $this->createHostConfig();
$containerCreatePostBody->setHostConfig($hostConfig);
if ($this->entryPoint !== null) {
$containerCreatePostBody->setEntrypoint([$this->entryPoint]);
}
if ($this->healthConfig !== null) {
$containerCreatePostBody->setHealthcheck($this->healthConfig);
}
if ($this->networkName !== null) {
$networkingConfig = new NetworkingConfig();
$endpointsConfig = new \ArrayObject([
$this->networkName => new EndpointSettings(),
]);
$networkingConfig->setEndpointsConfig($endpointsConfig);
$containerCreatePostBody->setNetworkingConfig($networkingConfig);
}
return $containerCreatePostBody;
}
protected function createHostConfig(): ?HostConfig
{
/**
* For some reason, if some of the properties are not set, but HostConfig is returned,
* the API will throw ContainerCreateBadRequestException: bad parameter.
* Until it will be checked and fixed, we just return null if these properties are not set.
* */
if ($this->exposedPorts === [] && !$this->isPrivileged && $this->mounts === []) {
return null;
}
$hostConfig = new HostConfig();
if ($this->exposedPorts !== []) {
$portBindings = $this->createPortBindings();
$hostConfig->setPortBindings($portBindings);
}
if ($this->isPrivileged) {
$hostConfig->setPrivileged(true);
}
if ($this->mounts !== []) {
$hostConfig->setMounts($this->mounts);
}
return $hostConfig;
}
/**
* @return array<string, array<int, PortBinding>>
*/
protected function createPortBindings(): array
{
$portBindings = [];
foreach ($this->exposedPorts as $port) {
$portBinding = new PortBinding();
$portBinding->setHostPort((string)$this->portGenerator->generatePort());
$portBinding->setHostIp('0.0.0.0');
$portBindings[$port] = [$portBinding];
}
return $portBindings;
}
protected function pullImage(): void
{
[$fromImage, $tag] = explode(':', $this->image) + [1 => 'latest'];
/** @var CreateImageStream $imageCreateResponse */
$imageCreateResponse = $this->dockerClient->imageCreate(null, [
'fromImage' => $fromImage,
'tag' => $tag,
]);
$imageCreateResponse->wait();
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Testcontainers\Container;
/**
* The IP protocols supported by Docker.
*/
enum InternetProtocol: string
{
case TCP = 'TCP';
case UDP = 'UDP';
public function toDockerNotation(): string
{
return strtolower($this->value);
}
public static function fromDockerNotation(string $protocol): self
{
return self::from(strtoupper($protocol));
}
}
+14 -9
View File
@@ -4,22 +4,27 @@ declare(strict_types=1);
namespace Testcontainers\Container;
use Testcontainers\Utils\PortGenerator\FixedPortGenerator;
use Testcontainers\Wait\WaitForExec;
/**
* Left for namespace backward compatibility
* @deprecated Use \Testcontainers\Modules\MariaDBContainer instead.
* TODO: Remove in next major release.
*/
class MariaDBContainer extends Container
{
private function __construct(string $version, string $mysqlRootPassword)
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);
$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']));
$this->withWait(new WaitForExec([
"mariadb-admin",
"ping",
"-h", "127.0.0.1",
]));
}
public static function make(string $version = 'latest', string $mysqlRootPassword = 'root'): self
+14 -2
View File
@@ -4,15 +4,27 @@ declare(strict_types=1);
namespace Testcontainers\Container;
use Testcontainers\Utils\PortGenerator\FixedPortGenerator;
use Testcontainers\Wait\WaitForExec;
/**
* Left for namespace backward compatibility
* @deprecated Use \Testcontainers\Modules\MySQLContainer instead.
* TODO: Remove in next major release.
*/
class MySQLContainer extends Container
{
private function __construct(string $version, string $mysqlRootPassword)
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(['mysqladmin', 'ping', '-h', '127.0.0.1']));
$this->withWait(new WaitForExec([
"mysqladmin",
"ping",
"-h", "127.0.0.1",
]));
}
public static function make(string $version = 'latest', string $mysqlRootPassword = 'root'): self
+15 -3
View File
@@ -4,16 +4,28 @@ declare(strict_types=1);
namespace Testcontainers\Container;
use Testcontainers\Wait\WaitForHttp;
use Testcontainers\Utils\PortGenerator\FixedPortGenerator;
use Testcontainers\Wait\WaitForLog;
/**
* Left for namespace backward compatibility
* @deprecated Use \Testcontainers\Modules\OpenSearchContainer instead.
* TODO: Remove in next major release.
*/
class OpenSearchContainer extends Container
{
private function __construct(string $version)
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!');
$this->withWait(WaitForHttp::make(9200));
$this->withWait(new WaitForLog(
'/\]\s+started\?\[/',
true,
30000
));
}
public static function make(string $version = 'latest'): self
+22 -5
View File
@@ -4,20 +4,37 @@ declare(strict_types=1);
namespace Testcontainers\Container;
use Testcontainers\Utils\PortGenerator\FixedPortGenerator;
use Testcontainers\Wait\WaitForExec;
/**
* Left for namespace backward compatibility
* @deprecated Use \Testcontainers\Modules\PostgresContainer instead.
* TODO: Remove in next major release.
*/
class PostgresContainer extends Container
{
private function __construct(string $version, string $rootPassword)
{
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->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, $dbPassword);
return new self(
version: $version,
password: $dbPassword
);
}
public function withPostgresUser(string $username): self
+9 -1
View File
@@ -4,13 +4,21 @@ declare(strict_types=1);
namespace Testcontainers\Container;
use Testcontainers\Utils\PortGenerator\FixedPortGenerator;
use Testcontainers\Wait\WaitForLog;
/**
* Left for namespace backward compatibility
* @deprecated Use \Testcontainers\Modules\RedisContainer instead.
* TODO: Remove in next major release.
*/
class RedisContainer extends Container
{
private function __construct(string $version)
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'));
}
+173
View File
@@ -0,0 +1,173 @@
<?php
declare(strict_types=1);
namespace Testcontainers\Container;
use Docker\API\Client;
use Docker\API\Model\ContainersIdExecPostBody;
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
{
protected Docker $dockerClient;
protected ?string $lastExecId = null;
public function __construct(protected readonly string $id)
{
$this->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<string> $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];
}
/**
* @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<string, array<array<string, string>>> $ports */
$ports = $containerInspectResponseAsArray['NetworkSettings']['Ports'] ?? [];
if ($ports === []) {
throw new \RuntimeException('Failed to get ports from container');
}
$port = array_key_first($ports);
return (int) $ports[$port][0]['HostPort'];
}
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 '';
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace Testcontainers\Container;
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<string> $command
*/
public function exec(array $command): string;
public function logs(): string;
}
+17
View File
@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace Testcontainers\Container;
class StoppedGenericContainer implements StoppedTestContainer
{
public function __construct(protected readonly string $id)
{
}
public function getId(): string
{
return $this->id;
}
}
+10
View File
@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace Testcontainers\Container;
interface StoppedTestContainer
{
public function getId(): string;
}
+34
View File
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace Testcontainers\Container;
use Testcontainers\Wait\WaitStrategy;
interface TestContainer
{
public function start(): StartedGenericContainer;
/**
* TODO: replace with array after deprecated implementation is removed
* @param array<string, string>|string $env
*/
public function withEnvironment(array | string $env, ?string $value): static;
/**
* @param array<string> $command
*/
public function withCommand(array $command): static;
public function withEntrypoint(string $entryPoint): static;
/** @param int|string|array<int|string> $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;
}
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace Testcontainers\ContainerClient;
use Docker\Docker as DockerClient;
class DockerContainerClient
{
/**
* @var DockerClient|null Singleton instance of DockerClient
*/
private static ?DockerClient $dockerClient = null;
private function __construct()
{
}
/**
* Returns the singleton DockerClient instance.
*
* @return DockerClient The singleton DockerClient instance.
* @throws \RuntimeException If the DockerClient instance could not be created.
*/
public static function getDockerClient(): DockerClient
{
if (self::$dockerClient === null) {
self::$dockerClient = DockerClient::create();
}
return self::$dockerClient;
}
/**
* Injects a DockerClient instance for testing or special use cases.
*
* @param DockerClient $client The DockerClient instance to set.
*/
public static function setDockerClient(DockerClient $client): void
{
self::$dockerClient = $client;
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Testcontainers\Exception;
class ContainerException extends \RuntimeException
{
protected string $containerId;
public function __construct(string $message, string $containerId = '', ?\Throwable $previous = null)
{
$this->containerId = $containerId;
parent::__construct($message, 0, $previous);
}
public function getContainerId(): string
{
return $this->containerId;
}
}
+1 -5
View File
@@ -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);
}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace Testcontainers\Exception;
class ContainerStateException extends ContainerException
{
public function __construct(string $containerId, ?\Throwable $previous = null)
{
$message = sprintf('Unable to retrieve state for container %s', $containerId);
parent::__construct($message, $containerId, $previous);
}
}
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace Testcontainers\Exception;
class ContainerWaitingTimeoutException extends ContainerNotReadyException
{
public function __construct(string $containerId, ?string $message = null, ?\Throwable $previous = null)
{
$message ??= sprintf('Timeout reached while waiting for container %s', $containerId);
parent::__construct($message, $containerId, $previous);
}
}
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace Testcontainers\Exception;
class HealthCheckFailedException extends ContainerNotReadyException
{
public function __construct(string $containerId, ?\Throwable $previous = null)
{
$message = sprintf('Health check failed: Container %s is unhealthy', $containerId);
parent::__construct($message, $containerId, $previous);
}
}
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace Testcontainers\Exception;
class HealthCheckNotConfiguredException extends ContainerNotReadyException
{
public function __construct(string $containerId, ?\Throwable $previous = null)
{
$message = sprintf('Health check not configured for container %s', $containerId);
parent::__construct($message, $containerId, $previous);
}
}
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace Testcontainers\Exception;
class UnknownHealthStatusException extends ContainerNotReadyException
{
public function __construct(string $containerId, string $status, ?\Throwable $previous = null)
{
$message = sprintf('Unknown health status %s for container %s', $status, $containerId);
parent::__construct($message, $containerId, $previous);
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Testcontainers\Modules;
use Testcontainers\Container\GenericContainer;
use Testcontainers\Wait\WaitForExec;
class MariaDBContainer extends GenericContainer
{
public function __construct(string $version = 'latest', string $mysqlRootPassword = 'root')
{
parent::__construct('mariadb:' . $version);
$this->withExposedPorts(3306);
$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
{
$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;
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Testcontainers\Modules;
use Testcontainers\Container\GenericContainer;
use Testcontainers\Wait\WaitForExec;
class MySQLContainer extends GenericContainer
{
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",
]));
}
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;
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Testcontainers\Modules;
use Testcontainers\Container\GenericContainer;
use Testcontainers\Wait\WaitForLog;
class OpenSearchContainer extends GenericContainer
{
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
));
}
public function withDisabledSecurityPlugin(): self
{
$this->withEnvironment('plugins.security.disabled', 'true');
return $this;
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace Testcontainers\Modules;
use Testcontainers\Container\GenericContainer;
use Testcontainers\Wait\WaitForExec;
class PostgresContainer extends GenericContainer
{
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]));
}
public function withPostgresUser(string $username): self
{
$this->withEnvironment('POSTGRES_USER', $username);
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);
return $this;
}
}
+18
View File
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace Testcontainers\Modules;
use Testcontainers\Container\GenericContainer;
use Testcontainers\Wait\WaitForLog;
class RedisContainer extends GenericContainer
{
public function __construct(string $version = 'latest')
{
parent::__construct('redis:' . $version);
$this->withExposedPorts(6379);
$this->withWait(new WaitForLog('Ready to accept connections'));
}
}
-39
View File
@@ -1,39 +0,0 @@
<?php
declare(strict_types=1);
namespace Testcontainers;
use Testcontainers\Container\Container;
class Registry
{
private static bool $registeredCleanup = false;
/**
* @var array<int|string, Container>
*/
private static array $registry = [];
public static function add(Container $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(Container $container): void
{
unset(self::$registry[spl_object_id($container)]);
}
public static function cleanup(): void
{
foreach (self::$registry as $container) {
$container->remove();
}
}
}
-108
View File
@@ -1,108 +0,0 @@
<?php
declare(strict_types=1);
namespace Testcontainers\Trait;
use JsonException;
use Symfony\Component\Process\Process;
use Testcontainers\Container\Container;
use UnexpectedValueException;
/**
* @phpstan-import-type ContainerInspect from Container
* @phpstan-import-type DockerNetwork from Container
*/
trait DockerContainerAwareTrait
{
/**
* @param string $containerId
* @param string|null $networkName
* @param ContainerInspect|null $inspectedData
* @return string
*
* @throws JsonException
*/
private static function dockerContainerAddress(string $containerId, ?string $networkName = null, ?array $inspectedData = null): string
{
if (! is_array($inspectedData)) {
$inspectedData = self::dockerContainerInspect($containerId);
}
if (is_string($networkName)) {
$containerAddress = $inspectedData[0]['NetworkSettings']['Networks'][$networkName]['IPAddress'] ?? null;
if (is_string($containerAddress)) {
return $containerAddress;
}
}
$containerAddress = $inspectedData[0]['NetworkSettings']['IPAddress'] ?? null;
if (is_string($containerAddress)) {
return $containerAddress;
}
throw new UnexpectedValueException('Unable to find container IP address');
}
/**
* @param string $containerId
* @return ContainerInspect
*
* @throws JsonException
*/
private static function dockerContainerInspect(string $containerId): array
{
$process = new Process(['docker', 'inspect', $containerId]);
$process->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<int, DockerNetwork> $output */
$output = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
/** @var array<int, DockerNetwork> $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();
}
}
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Testcontainers\Utils\PortGenerator;
class FixedPortGenerator implements PortGenerator
{
protected int $portIndex = 0;
public function __construct(
/** @var int[] */
protected array $ports
) {
}
public function generatePort(): int
{
if (!isset($this->ports[$this->portIndex])) {
throw new \RuntimeException('No more ports available in the fixed list.');
}
return $this->ports[$this->portIndex++];
}
}
+10
View File
@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace Testcontainers\Utils\PortGenerator;
interface PortGenerator
{
public function generatePort(): int;
}
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace Testcontainers\Utils\PortGenerator;
class RandomPortGenerator implements PortGenerator
{
public function generatePort(): int
{
return $this->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;
}
}
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Testcontainers\Utils\PortGenerator;
class RandomUniquePortGenerator implements PortGenerator
{
/** @var int[] */
protected static array $assignedPorts = [];
public function __construct(protected PortGenerator $portGenerator = new RandomPortGenerator())
{
}
public function generatePort(): int
{
do {
$port = $this->portGenerator->generatePort();
} while (in_array($port, self::$assignedPorts));
self::$assignedPorts[] = $port;
return $port;
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Testcontainers\Utils;
use Testcontainers\Container\InternetProtocol;
class PortNormalizer
{
/**
* 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.
*/
public static function normalizePort(string|int $port, InternetProtocol $internetProtocol = InternetProtocol::TCP): string
{
if (is_int($port)) {
// Direct integer ports default to tcp
return "{$port}/{$internetProtocol->toDockerNotation()}";
}
// Check if the port specification already includes a protocol
if (is_string($port) && !str_contains($port, '/')) {
return "{$port}/{$internetProtocol->toDockerNotation()}";
}
return $port;
}
}
+16
View File
@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Testcontainers\Wait;
use Testcontainers\Container\StartedTestContainer;
abstract class BaseWaitStrategy implements WaitStrategy
{
public function __construct(protected int $timeout = 10000, protected int $pollInterval = 500)
{
}
abstract public function wait(StartedTestContainer $container): void;
}
+40
View File
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace Testcontainers\Wait;
use Docker\API\Model\ContainersIdJsonGetResponse200;
use Testcontainers\Container\StartedTestContainer;
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 WaitForContainer extends BaseWaitStrategy
{
public function wait(StartedTestContainer $container): void
{
$id = $container->getId();
$startTime = microtime(true) * 1000;
while (true) {
$elapsedTime = (microtime(true) * 1000) - $startTime;
if ($elapsedTime > $this->timeout) {
throw new ContainerNotReadyException($id);
}
/** @var ContainersIdJsonGetResponse200 | null $containerInspect */
$containerInspect = $container->getClient()->containerInspect($id);
$containerStatus = $containerInspect?->getState()?->getStatus();
if ($containerStatus === 'running') {
return;
}
usleep($this->pollInterval * 1000);
}
}
}
+40 -15
View File
@@ -5,31 +5,56 @@ declare(strict_types=1);
namespace Testcontainers\Wait;
use Closure;
use Symfony\Component\Process\Process;
use Testcontainers\Exception\ContainerNotReadyException;
use Docker\API\Model\ExecIdJsonGetResponse200;
use Testcontainers\Container\StartedTestContainer;
use Testcontainers\Exception\ContainerWaitingTimeoutException;
class WaitForExec implements WaitInterface
/**
* Uses $timout and $pollInterval in milliseconds to set the parameters for waiting.
*/
class WaitForExec extends BaseWaitStrategy
{
/**
* @param array<string> $command
*/
public function __construct(private array $command, private ?Closure $checkFunction = null)
{
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
public function wait(StartedTestContainer $container): void
{
$process = new Process(['docker', 'exec', $id, ...$this->command]);
$startTime = microtime(true) * 1000;
try {
$process->mustRun();
} catch (\Exception $e) {
throw new ContainerNotReadyException($id, $e);
}
while (true) {
$elapsedTime = (microtime(true) * 1000) - $startTime;
if ($this->checkFunction !== null) {
$func = $this->checkFunction;
$func($process);
if ($elapsedTime > $this->timeout) {
throw new ContainerWaitingTimeoutException($container->getId());
}
$contents = $container->exec($this->command);
// Inspect the exec to check the exit code
/** @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) {
$checkResult = ($this->checkFunction)($exitCode, $contents);
if ($checkResult) {
return;
}
} elseif ($exitCode === 0) {
return; // Command succeeded
}
usleep($this->pollInterval * 1000);
}
}
}
+57 -14
View File
@@ -4,27 +4,70 @@ declare(strict_types=1);
namespace Testcontainers\Wait;
use RuntimeException;
use Symfony\Component\Process\Process;
use Testcontainers\Exception\ContainerNotReadyException;
use Docker\API\Model\ContainersIdJsonGetResponse200;
use Testcontainers\Container\StartedTestContainer;
use Testcontainers\Exception\ContainerStateException;
use Testcontainers\Exception\ContainerWaitingTimeoutException;
use Testcontainers\Exception\HealthCheckFailedException;
use Testcontainers\Exception\HealthCheckNotConfiguredException;
use Testcontainers\Exception\UnknownHealthStatusException;
class WaitForHealthCheck implements WaitInterface
/**
* Wait strategy that waits until the container's health status is 'healthy'.
*
* Possible health statuses:
* - "none": No health check configured.
* - "starting": Health check is in progress.
* - "healthy": Container is healthy.
* - "unhealthy": Container is unhealthy.
*/
class WaitForHealthCheck extends BaseWaitStrategy
{
public function wait(string $id): void
public function wait(StartedTestContainer $container): void
{
$process = new Process(['docker', 'inspect', '--format', '{{json .State.Health.Status}}', $id]);
$process->mustRun();
$startTime = microtime(true);
$status = json_decode($process->getOutput(), true, 512, JSON_THROW_ON_ERROR);
while (true) {
$elapsedTime = (microtime(true) - $startTime) * 1000;
if (!is_string($status)) {
throw new ContainerNotReadyException($id, new RuntimeException('Invalid json output'));
}
if ($elapsedTime > $this->timeout) {
throw new ContainerWaitingTimeoutException($container->getId());
}
$status = trim($status, '"');
/** @var ContainersIdJsonGetResponse200|null $containerInspect */
$containerInspect = $container->getClient()->containerInspect($container->getId());
if ($status !== 'healthy') {
throw new ContainerNotReadyException($id);
$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());
}
usleep($this->pollInterval * 1000);
}
}
}
+13 -5
View File
@@ -4,13 +4,12 @@ declare(strict_types=1);
namespace Testcontainers\Wait;
use Docker\Docker;
use Testcontainers\Exception\ContainerNotReadyException;
use Testcontainers\Trait\DockerContainerAwareTrait;
class WaitForHttp implements WaitInterface
//TODO: not ready yet
class WaitForHttp implements WaitStrategy
{
use DockerContainerAwareTrait;
public const METHOD_GET = 'GET';
public const METHOD_POST = 'POST';
public const METHOD_PUT = 'PUT';
@@ -22,9 +21,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 +59,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));
+30 -15
View File
@@ -4,30 +4,45 @@ declare(strict_types=1);
namespace Testcontainers\Wait;
use Symfony\Component\Process\Process;
use Testcontainers\Exception\ContainerNotReadyException;
use Testcontainers\Container\StartedTestContainer;
use Testcontainers\Exception\ContainerWaitingTimeoutException;
class WaitForLog implements WaitInterface
/**
* Uses $timout and $pollInterval in milliseconds to set the parameters for waiting.
*/
class WaitForLog extends BaseWaitStrategy
{
public function __construct(private string $message, private bool $enableRegex = false)
{
public function __construct(
protected string $message,
protected bool $enableRegex = false,
int $timeout = 10000,
int $pollInterval = 500
) {
parent::__construct($timeout, $pollInterval);
}
public function wait(string $id): void
public function wait(StartedTestContainer $container): void
{
$process = new Process(['docker', 'logs', $id]);
$process->mustRun();
$startTime = microtime(true) * 1000;
$output = $process->getOutput() . PHP_EOL . $process->getErrorOutput();
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($container->getId());
}
} else {
if (!str_contains($output, $this->message)) {
throw new ContainerNotReadyException($id, new \RuntimeException('Message not found in logs'));
$output = $container->logs();
if ($this->enableRegex) {
if (preg_match($this->message, $output)) {
return;
}
} elseif (str_contains($output, $this->message)) {
return;
}
usleep($this->pollInterval * 1000);
}
}
}
-13
View File
@@ -1,13 +0,0 @@
<?php
declare(strict_types=1);
namespace Testcontainers\Wait;
class WaitForNothing implements WaitInterface
{
public function wait(string $id): void
{
// does nothing
}
}
+15 -4
View File
@@ -4,17 +4,19 @@ 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
//TODO: not ready yet
final class WaitForTcpPortOpen implements WaitStrategy
{
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 +29,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'));
}
}
-10
View File
@@ -1,10 +0,0 @@
<?php
declare(strict_types=1);
namespace Testcontainers\Wait;
interface WaitInterface
{
public function wait(string $id): void;
}
+12
View File
@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace Testcontainers\Wait;
use Testcontainers\Container\StartedTestContainer;
interface WaitStrategy
{
public function wait(StartedTestContainer $container): void;
}
+18
View File
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace Testcontainers\Tests\Integration;
use PHPUnit\Framework\TestCase;
use Testcontainers\Container\StartedTestContainer;
abstract class ContainerTestCase extends TestCase
{
protected static StartedTestContainer $container;
protected function tearDown(): void
{
self::$container->stop();
}
}
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace Testcontainers\Tests\Integration;
use Testcontainers\Container\GenericContainer;
class GenericContainerTest extends ContainerTestCase
{
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);
}
}
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace Testcontainers\Tests\Integration;
use Testcontainers\Modules\MariaDBContainer;
class MariaDBContainerTest extends ContainerTestCase
{
public static function setUpBeforeClass(): void
{
self::$container = (new MariaDBContainer())
->withMariaDBDatabase('foo')
->withMariaDBUser('bar', 'baz')
->start();
}
public function testMariaDBContainer(): void
{
$pdo = new \PDO(
sprintf(
'mysql:host=%s;port=%d',
self::$container->getHost(),
self::$container->getFirstMappedPort()
),
'bar',
'baz',
);
$query = $pdo->query('SHOW databases');
$this->assertInstanceOf(\PDOStatement::class, $query);
$databases = $query->fetchAll(\PDO::FETCH_COLUMN);
$this->assertContains('foo', $databases);
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace Testcontainers\Tests\Integration;
use Testcontainers\Modules\MySQLContainer;
class MySQLContainerTest extends ContainerTestCase
{
public static function setUpBeforeClass(): void
{
self::$container = (new MySQLContainer())
->withMySQLDatabase('foo')
->withMySQLUser('bar', 'baz')
->start();
}
public function testMySQLContainer(): void
{
$pdo = new \PDO(
sprintf(
'mysql:host=%s;port=%d',
self::$container->getHost(),
self::$container->getFirstMappedPort()
),
'bar',
'baz',
);
$query = $pdo->query('SHOW databases');
$this->assertInstanceOf(\PDOStatement::class, $query);
$databases = $query->fetchAll(\PDO::FETCH_COLUMN);
$this->assertContains('foo', $databases);
}
}
@@ -2,7 +2,7 @@
declare(strict_types=1);
namespace Testcontainers\Tests\Integration;
namespace Testcontainers\Tests\Integration\OldTests;
use PHPUnit\Framework\TestCase;
use Predis\Client;
@@ -12,6 +12,9 @@ use Testcontainers\Container\OpenSearchContainer;
use Testcontainers\Container\PostgresContainer;
use Testcontainers\Container\RedisContainer;
/**
* Old test classes kept to check backward compatibility
*/
class ContainerTest extends TestCase
{
public function testMySQL(): void
@@ -35,6 +38,8 @@ class ContainerTest extends TestCase
$databases = $query->fetchAll(\PDO::FETCH_COLUMN);
$this->assertContains('foo', $databases);
$container->stop();
}
public function testMariaDB(): void
@@ -58,6 +63,8 @@ class ContainerTest extends TestCase
$databases = $query->fetchAll(\PDO::FETCH_COLUMN);
$this->assertContains('foo', $databases);
$container->stop();
}
public function testRedis(): void
@@ -75,8 +82,13 @@ class ContainerTest extends TestCase
$redis->ping();
$this->assertTrue($redis->isConnected());
$container->stop();
}
/**
* @throws \JsonException
*/
public function testOpenSearch(): void
{
$container = OpenSearchContainer::make();
@@ -93,11 +105,13 @@ class ContainerTest extends TestCase
$this->assertNotEmpty($response);
/** @var array{cluster_name: string} $data */
$data = json_decode($response, true, JSON_THROW_ON_ERROR);
$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
@@ -121,5 +135,7 @@ class ContainerTest extends TestCase
$databases = $query->fetchAll(\PDO::FETCH_COLUMN);
$this->assertContains('foo', $databases);
$container->stop();
}
}
@@ -2,48 +2,44 @@
declare(strict_types=1);
namespace Testcontainers\Tests\Integration;
namespace Testcontainers\Tests\Integration\OldTests;
use PHPUnit\Framework\TestCase;
use Predis\Client;
use Predis\Connection\ConnectionException;
use Symfony\Component\Process\Process;
use Testcontainers\Container\Container;
use Testcontainers\Exception\ContainerNotReadyException;
use Testcontainers\Registry;
use Testcontainers\Trait\DockerContainerAwareTrait;
use Testcontainers\Container\MySQLContainer;
use Testcontainers\Container\RedisContainer;
use Testcontainers\Wait\WaitForExec;
use Testcontainers\Wait\WaitForHealthCheck;
use Testcontainers\Wait\WaitForHttp;
use Testcontainers\Wait\WaitForLog;
use Testcontainers\Wait\WaitForTcpPortOpen;
/**
* Old test classes kept to check backward compatibility
*/
class WaitStrategyTest extends TestCase
{
use DockerContainerAwareTrait;
public static function tearDownAfterClass(): void
//TODO: remove after check
protected function setUp(): void
{
parent::tearDownAfterClass();
Registry::cleanup();
$this->markTestIncomplete();
}
public function testWaitForExec(): void
{
$called = false;
$container = Container::make('mysql')
$container = MySQLContainer::make()
->withEnvironment('MYSQL_ROOT_PASSWORD', 'root')
->withWait(new WaitForExec(['mysqladmin', 'ping', '-h', '127.0.0.1'], function (Process $process) use (&$called) {
$called = true;
}));
->withWait(
new WaitForExec([
'mysqladmin', 'ping',
'-h', '127.0.0.1',
])
);
$container->run();
$this->assertTrue($called, 'Wait function was not called');
unset($called);
$pdo = new \PDO(
sprintf('mysql:host=%s;port=3306', $container->getAddress()),
'root',
@@ -57,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();
@@ -143,6 +141,7 @@ class WaitStrategyTest extends TestCase
{
$container = Container::make('nginx')
->withHealthCheckCommand('curl --fail http://localhost')
->withPort('80', '80')
->withWait(new WaitForHealthCheck());
$container->run();
@@ -158,5 +157,7 @@ class WaitStrategyTest extends TestCase
$this->assertIsString($response);
$this->assertStringContainsString('Welcome to nginx!', $response);
$container->stop();
}
}
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace Testcontainers\Tests\Integration;
use Testcontainers\Modules\OpenSearchContainer;
class OpenSearchContainerTest extends ContainerTestCase
{
public static function setUpBeforeClass(): void
{
self::$container = (new OpenSearchContainer())
->withDisabledSecurityPlugin()
->start();
}
/**
* @throws \JsonException
*/
public function testOpenSearch(): void
{
$ch = curl_init();
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);
$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']);
}
}
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace Testcontainers\Tests\Integration;
use Testcontainers\Modules\PostgresContainer;
class PostgreSQLContainerTest extends ContainerTestCase
{
public static function setUpBeforeClass(): void
{
self::$container = (new PostgresContainer())
->withPostgresUser('bar')
->withPostgresDatabase('foo')
->start();
}
public function testPostgreSQLContainer(): void
{
$pdo = new \PDO(
sprintf(
'pgsql:host=%s;port=%d;dbname=foo',
self::$container->getHost(),
self::$container->getFirstMappedPort()
),
'bar',
'test',
);
$query = $pdo->query('SELECT datname FROM pg_database');
$this->assertInstanceOf(\PDOStatement::class, $query);
$databases = $query->fetchAll(\PDO::FETCH_COLUMN);
$this->assertContains('foo', $databases);
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Testcontainers\Tests\Integration;
use Predis\Client;
use Testcontainers\Modules\RedisContainer;
class RedisContainerTest extends ContainerTestCase
{
public static function setUpBeforeClass(): void
{
self::$container = (new RedisContainer())
->start();
}
public function testRedisContainer(): void
{
$redisClient = new Client([
'host' => self::$container->getHost(),
'port' => self::$container->getFirstMappedPort(),
]);
$redisClient->ping();
$this->assertTrue($redisClient->isConnected());
$redisClient->set('greetings', 'Hello, World!');
$this->assertEquals('Hello, World!', $redisClient->get('greetings'));
}
}