Merge pull request #29 from testcontainers/0.2

This commit is contained in:
Shyim
2025-02-16 20:00:23 +01:00
committed by GitHub
63 changed files with 3368 additions and 1102 deletions
-2
View File
@@ -5,8 +5,6 @@ on:
branches:
- main
pull_request:
branches:
- main
permissions:
contents: read
+67 -44
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,19 @@ 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;
use Testcontainers\Wait\WaitForHostPort;
$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
});
@@ -47,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());
@@ -58,16 +73,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 +98,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 +123,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 +147,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 +162,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 +188,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 +197,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);
}
+9 -4
View File
@@ -14,12 +14,16 @@
}
],
"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",
"brianium/paratest": "^6.11",
"friendsofphp/php-cs-fixer": "^3.12",
"phpstan/phpstan": "^1.8",
"phpstan/phpstan-phpunit": "^1.1",
@@ -40,11 +44,12 @@
"integration": "paratest tests/ --bootstrap vendor/autoload.php -f",
"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": {
"phpstan/extension-installer": true
"phpstan/extension-installer": true,
"php-http/discovery": false
}
}
}
-309
View File
@@ -1,309 +0,0 @@
<?php
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;
/**
* @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}
*/
class Container
{
use DockerContainerAwareTrait;
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();
}
public static function make(string $image): self
{
return new Container($image);
}
public function getId(): string
{
return $this->id;
}
public function withHostname(string $hostname): self
{
$this->hostname = $hostname;
return $this;
}
public function withEntryPoint(string $entryPoint): self
{
$this->entryPoint = $entryPoint;
return $this;
}
public function withEnvironment(string $name, string $value): self
{
$this->env[$name] = $value;
return $this;
}
public function withImage(string $image): self
{
$this->image = $image;
return $this;
}
public function withWait(WaitInterface $wait): self
{
$this->wait = $wait;
return $this;
}
public function withHealthCheckCommand(string $command, int $healthCheckIntervalInMS = 1000): self
{
$this->healthCheckCommand = $command;
$this->healthCheckIntervalInMS = $healthCheckIntervalInMS;
return $this;
}
/**
* @param array<string> $cmd
*/
public function withCmd(array $cmd): self
{
$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->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;
}
/**
* @param array<string> $command
*/
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();
return $logs->getOutput();
}
public function getAddress(): string
{
return self::dockerContainerAddress(
containerId: $this->id,
networkName: $this->network,
inspectedData: $this->inspectedData
);
}
}
+480
View File
@@ -0,0 +1,480 @@
<?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 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;
class GenericContainer implements TestContainer
{
protected Docker $dockerClient;
protected string $image;
protected ?string $name = null;
/**
* User-defined key/value metadata.
* @var array<string, string>|null $labels
*/
protected ?array $labels = null;
protected ?string $hostname = null;
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 ?string $user = null;
protected ?string $workingDir = null;
/**
* @var array<array{source: string, target: string, mode?: int}>
*/
protected array $filesToCopy = [];
/**
* @var array<array{source: string, target: string, mode?: int}>
*/
protected array $directoriesToCopy = [];
/**
* @var array<array{content: string, target: string, mode?: int}>
*/
protected array $contentsToCopy = [];
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;
}
/**
* @param array<array{source: string, target: string, mode?: int}> $files
*/
public function withCopyFilesToContainer(array $files): static
{
$this->filesToCopy = array_merge($this->filesToCopy, $files);
return $this;
}
/**
* @param array<array{source: string, target: string, mode?: int}> $directories
*/
public function withCopyDirectoriesToContainer(array $directories): static
{
$this->directoriesToCopy = array_merge($this->directoriesToCopy, $directories);
return $this;
}
/**
* @param array<array{content: string, target: string, mode?: int}> $contents
*/
public function withCopyContentToContainer(array $contents): static
{
$this->contentsToCopy = array_merge($this->contentsToCopy, $contents);
return $this;
}
public function withEntryPoint(string $entryPoint): static
{
$this->entryPoint = $entryPoint;
return $this;
}
/**
* @param array<string, string> $env An array of key-value pairs: $object->withEnvironment(['key1' => 'value1', 'key2' => 'value2']);
* @return static Returns itself for chaining purposes.
*/
public function withEnvironment(array $env): static
{
foreach ($env as $key => $val) {
$this->env[$key] = $val;
}
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 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,
]);
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 withName(string $name): static
{
$this->name = $name;
return $this;
}
/**
* @param array<string, string> $labels
*/
public function withLabels(array $labels): static
{
$this->labels = $labels;
return $this;
}
public function withPrivilegedMode(bool $privileged = true): static
{
$this->isPrivileged = $privileged;
return $this;
}
public function withNetwork(string $networkName): static
{
$this->networkName = $networkName;
return $this;
}
public function withPortGenerator(PortGenerator $portGenerator): static
{
$this->portGenerator = $portGenerator;
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++;
$containerConfig = $this->createContainerConfig();
$queryParameters = [];
if ($this->name !== null) {
$queryParameters['name'] = $this->name;
}
try {
/** @var ContainerCreateResponse|null $containerCreateResponse */
$containerCreateResponse = $this->dockerClient->containerCreate($containerConfig, $queryParameters);
$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);
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();
$containerCreatePostBody->setImage($this->image);
$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);
$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();
}
}
+20
View File
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace Testcontainers\Container;
enum HttpMethod: string
{
case GET = 'GET';
case POST = 'POST';
case PUT = 'PUT';
case DELETE = 'DELETE';
case HEAD = 'HEAD';
case OPTIONS = 'OPTIONS';
public static function fromString(string $method): self
{
return self::tryFrom(strtoupper($method)) ?? throw new \InvalidArgumentException("Invalid HTTP method: $method");
}
}
+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));
}
}
-44
View File
@@ -1,44 +0,0 @@
<?php
declare(strict_types=1);
namespace Testcontainers\Container;
use Testcontainers\Wait\WaitForExec;
class MariaDBContainer extends Container
{
private function __construct(string $version, string $mysqlRootPassword)
{
parent::__construct('mariadb:' . $version);
$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']));
}
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;
}
}
-37
View File
@@ -1,37 +0,0 @@
<?php
declare(strict_types=1);
namespace Testcontainers\Container;
use Testcontainers\Wait\WaitForExec;
class MySQLContainer extends Container
{
private function __construct(string $version, string $mysqlRootPassword)
{
parent::__construct('mysql:' . $version);
$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;
}
}
-30
View File
@@ -1,30 +0,0 @@
<?php
declare(strict_types=1);
namespace Testcontainers\Container;
use Testcontainers\Wait\WaitForHttp;
class OpenSearchContainer extends Container
{
private function __construct(string $version)
{
parent::__construct('opensearchproject/opensearch:' . $version);
$this->withEnvironment('discovery.type', 'single-node');
$this->withEnvironment('OPENSEARCH_INITIAL_ADMIN_PASSWORD', 'c3o_ZPHo!');
$this->withWait(WaitForHttp::make(9200));
}
public static function make(string $version = 'latest'): self
{
return new self($version);
}
public function disableSecurityPlugin(): self
{
$this->withEnvironment('plugins.security.disabled', 'true');
return $this;
}
}
-36
View File
@@ -1,36 +0,0 @@
<?php
declare(strict_types=1);
namespace Testcontainers\Container;
use Testcontainers\Wait\WaitForExec;
class PostgresContainer extends Container
{
private function __construct(string $version, string $rootPassword)
{
parent::__construct('postgres:' . $version);
$this->withEnvironment('POSTGRES_PASSWORD', $rootPassword);
$this->withWait(new WaitForExec(["pg_isready", "-h", "127.0.0.1"]));
}
public static function make(string $version = 'latest', string $dbPassword = 'root'): self
{
return new self($version, $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;
}
}
-21
View File
@@ -1,21 +0,0 @@
<?php
declare(strict_types=1);
namespace Testcontainers\Container;
use Testcontainers\Wait\WaitForLog;
class RedisContainer extends Container
{
private function __construct(string $version)
{
parent::__construct('redis:' . $version);
$this->withWait(new WaitForLog('Ready to accept connections'));
}
public static function make(string $version = 'latest'): self
{
return new self($version);
}
}
+214
View File
@@ -0,0 +1,214 @@
<?php
declare(strict_types=1);
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 RuntimeException;
use Testcontainers\ContainerClient\DockerContainerClient;
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, ?Docker $dockerClient = null)
{
$this->dockerClient = $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 $this->sanitizeOutput($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 $this->sanitizeOutput(mb_convert_encoding($output, 'UTF-8', 'UTF-8'));
}
public function getHost(): string
{
return (new HostResolver($this->dockerClient))->resolveHost();
}
public function getMappedPort(int $port): int
{
$ports = (array) $this->getBoundPorts();
/** @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 {$mappedPort} for container");
}
public function getFirstMappedPort(): int
{
$ports = (array) $this->getBoundPorts();
$port = array_key_first($ports);
/** @var PortBinding | null $firstPortBinding */
$firstPortBinding = $ports[$port][0] ?? null;
$firstMappedPort = $firstPortBinding?->getHostPort();
if ($firstMappedPort !== null) {
return (int) $firstMappedPort;
}
throw new RuntimeException('Failed to get first mapped port for container');
}
public function getName(): string
{
return trim($this->inspect()?->getName() ?? '', '/ ');
}
/**
* @return array<string, string>
*/
public function getLabels(): array
{
return (array) $this->inspect()?->getConfig()?->getLabels();
}
/**
* @return string[]
*/
public function getNetworkNames(): array
{
$networks = (array) $this->inspect()?->getNetworkSettings()?->getNetworks();
return array_keys($networks);
}
public function getNetworkId(string $networkName): string
{
$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} does not exist");
}
public function getIpAddress(string $networkName): string
{
$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} does not exist");
}
protected function inspect(): ContainersIdJsonGetResponse200 | null
{
if ($this->inspectResponse === null) {
/** @var ContainersIdJsonGetResponse200 | null $inspectResponse */
$inspectResponse = $this->dockerClient->containerInspect($this->id);
$this->inspectResponse = $inspectResponse;
}
return $this->inspectResponse;
}
/**
* @return iterable<string, array<PortBinding>>
* @throws RuntimeException
*/
public function getBoundPorts(): iterable
{
$ports = $this->inspect()?->getNetworkSettings()?->getPorts();
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) ?? '';
}
}
+55
View File
@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace Testcontainers\Container;
use Docker\API\Model\PortBinding;
use Docker\Docker;
interface StartedTestContainer
{
/**
* @param list<string> $command
*/
public function exec(array $command): string;
/**
* @return iterable<string, array<PortBinding>>
*/
public function getBoundPorts(): iterable;
public function getClient(): Docker;
public function getFirstMappedPort(): int;
public function getHost(): string;
public function getId(): string;
public function getIpAddress(string $networkName): string;
/**
* @return array<string, 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;
/**
* @return string[]
*/
public function getNetworkNames(): array;
public function restart(): self;
public function stop(): StoppedTestContainer;
}
+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;
}
+55
View File
@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace Testcontainers\Container;
use Testcontainers\Utils\PortGenerator\PortGenerator;
use Testcontainers\Wait\WaitStrategy;
interface TestContainer
{
public function start(): StartedGenericContainer;
/**
* @param array<string> $command
*/
public function withCommand(array $command): static;
public function withEntrypoint(string $entryPoint): static;
/**
* @param array<string, string> $env An array of key-value pairs
*/
public function withEnvironment(array $env): static;
/** @param int|string|array<int|string> $ports One or more ports to expose. */
public function withExposedPorts(...$ports): static;
public function withHealthCheckCommand(
string $command,
int $intervalInMilliseconds,
int $timeoutInMilliseconds,
int $retries,
int $startPeriodInMilliseconds
): static;
public function withHostname(string $hostname): static;
/**
* @param array<string, string> $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 withPortGenerator(PortGenerator $portGenerator): static;
public function withPrivilegedMode(bool $privileged): static;
public function withWait(WaitStrategy $waitStrategy): 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",
], null, 15000));
}
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",
], null, 15000));
}
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;
}
}
+34
View File
@@ -0,0 +1,34 @@
<?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',
'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;
}
}
+48
View File
@@ -0,0 +1,48 @@
<?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,
'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]);
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();
}
}
+138
View File
@@ -0,0 +1,138 @@
<?php
declare(strict_types=1);
namespace Testcontainers\Utils;
use Docker\API\Model\Network;
use Docker\Docker;
use RuntimeException;
use Testcontainers\Container\GenericContainer;
use Testcontainers\ContainerClient\DockerContainerClient;
class HostResolver
{
public function __construct(protected ?Docker $dockerClient = null)
{
$this->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) {
//
}
}
}
}
}
@@ -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;
}
}
+300
View File
@@ -0,0 +1,300 @@
<?php
declare(strict_types=1);
namespace Testcontainers\Utils;
use InvalidArgumentException;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use RuntimeException;
use SplFileInfo;
class TarBuilder
{
/**
* @var array<array{source: string, target: string, mode: int|null}>
*/
private array $files = [];
/**
* @var array<array{source: string, target: string, mode: int|null}>
*/
private array $directories = [];
/**
* @var array<array{content: string, target: string, mode: int|null}>
*/
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
{
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 %s -cf %s -C %s . 2>&1',
$additionalFlags,
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<array{source: string, target: string, mode: int|null}> $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<array{source: string, target: string, mode: int|null}> $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<array{content: string, target: string, mode: int|null}> $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<RecursiveDirectoryIterator> $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");
}
}
}
+28
View File
@@ -0,0 +1,28 @@
<?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;
public function withTimeout(int $timeout): static
{
$this->timeout = $timeout;
return $this;
}
public function withPollInterval(int $pollInterval): static
{
$this->pollInterval = $pollInterval;
return $this;
}
}
+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);
}
}
}
+64
View File
@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace Testcontainers\Wait;
use Testcontainers\Container\StartedTestContainer;
use Testcontainers\Exception\ContainerWaitingTimeoutException;
class WaitForHostPort extends BaseWaitStrategy
{
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());
}
if ($this->boundPortsOpened($container)) {
return; // Port is open, container is ready
}
usleep($this->pollInterval * 1000); // Wait for the next polling interval
}
}
/**
* @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);
if ($connection !== false) {
fclose($connection);
return true;
}
return false;
}
}
+99 -35
View File
@@ -4,75 +4,139 @@ declare(strict_types=1);
namespace Testcontainers\Wait;
use Testcontainers\Exception\ContainerNotReadyException;
use Testcontainers\Trait\DockerContainerAwareTrait;
use Testcontainers\Container\HttpMethod;
use Testcontainers\Container\StartedTestContainer;
use Testcontainers\Exception\ContainerWaitingTimeoutException;
class WaitForHttp implements WaitInterface
class WaitForHttp extends BaseWaitStrategy
{
use DockerContainerAwareTrait;
protected HttpMethod $method = HttpMethod::GET;
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 string $path = '/';
protected string $protocol = 'http';
private string $method = 'GET';
private string $path = '/';
private int $statusCode = 200;
protected int $expectedStatusCode = 200;
public function __construct(private int $port)
{
}
protected bool $allowInsecure = false;
public static function make(int $port): self
{
return new WaitForHttp($port);
/**
* @var array<string, string>
*/
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
* @param HttpMethod|value-of<HttpMethod> $method
*/
public function withMethod(string $method): self
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
{
$containerAddress = self::dockerContainerAddress(containerId: $id);
$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<string, string> $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, sprintf('http://%s:%d%s', $containerAddress, $this->port, $this->path));
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $this->method->value);
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);
curl_setopt($ch, CURLOPT_NOBODY, true); // No need for response body, just headers
curl_setopt($ch, CURLOPT_TIMEOUT_MS, $this->readTimeout);
curl_exec($ch);
if (curl_getinfo($ch, CURLINFO_HTTP_CODE) !== $this->statusCode) {
throw new ContainerNotReadyException($id, new \RuntimeException('HTTP status code does not match'));
// Allow insecure connections if requested
if ($this->allowInsecure) {
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
}
// 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);
$responseCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return $responseCode;
}
}
+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
}
}
-34
View File
@@ -1,34 +0,0 @@
<?php
declare(strict_types=1);
namespace Testcontainers\Wait;
use JsonException;
use RuntimeException;
use Testcontainers\Exception\ContainerNotReadyException;
use Testcontainers\Trait\DockerContainerAwareTrait;
final class WaitForTcpPortOpen implements WaitInterface
{
use DockerContainerAwareTrait;
public function __construct(private readonly int $port, private readonly ?string $network = null)
{
}
public static function make(int $port, ?string $network = null): self
{
return new self($port, $network);
}
/**
* @throws JsonException
*/
public function wait(string $id): void
{
if (@fsockopen(self::dockerContainerAddress(containerId: $id, networkName: $this->network), $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;
}
+1
View File
@@ -0,0 +1 @@
hello world
-125
View File
@@ -1,125 +0,0 @@
<?php
declare(strict_types=1);
namespace Testcontainers\Tests\Integration;
use PHPUnit\Framework\TestCase;
use Predis\Client;
use Testcontainers\Container\MariaDBContainer;
use Testcontainers\Container\MySQLContainer;
use Testcontainers\Container\OpenSearchContainer;
use Testcontainers\Container\PostgresContainer;
use Testcontainers\Container\RedisContainer;
class ContainerTest extends TestCase
{
public function testMySQL(): void
{
$container = MySQLContainer::make();
$container->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);
}
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);
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Testcontainers\Tests\Integration;
use PHPUnit\Framework\TestCase;
use Testcontainers\Container\StartedTestContainer;
abstract class ContainerTestCase extends TestCase
{
protected StartedTestContainer $container;
protected function tearDown(): void
{
if (isset($this->container)) {
$this->container->stop();
}
parent::tearDown();
}
}
+282
View File
@@ -0,0 +1,282 @@
<?php
declare(strict_types=1);
namespace Testcontainers\Tests\Integration;
use Docker\API\Model\ContainersIdJsonGetResponse200;
use PHPUnit\Framework\TestCase;
use Testcontainers\Container\GenericContainer;
use Testcontainers\Wait\WaitForHostPort;
class GenericContainerTest extends TestCase
{
public function testExec(): void
{
$container = (new GenericContainer('alpine'))
->withCommand(['tail', '-f', '/dev/null'])
->start();
$result = $container->exec(['echo', 'testcontainers']);
self::assertSame('testcontainers', $result);
$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();
}
public function testShouldReturnFirstMappedPort(): void
{
$container = (new GenericContainer('nginx'))
->withExposedPorts(80)
->withWait(new WaitForHostPort())
->start();
$firstMappedPort = $container->getFirstMappedPort();
self::assertSame($firstMappedPort, $container->getMappedPort(80));
$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'))
->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 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'))
->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);
$container->stop();
}
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);
$container->stop();
}
}
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace Testcontainers\Tests\Integration;
use Testcontainers\Modules\MariaDBContainer;
class MariaDBContainerTest extends ContainerTestCase
{
public function setUp(): void
{
$this->container = (new MariaDBContainer())
->withMariaDBDatabase('foo')
->withMariaDBUser('bar', 'baz')
->start();
}
public function testMariaDBContainer(): void
{
$pdo = new \PDO(
sprintf(
'mysql:host=%s;port=%d',
$this->container->getHost(),
$this->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 function setUp(): void
{
$this->container = (new MySQLContainer())
->withMySQLDatabase('foo')
->withMySQLUser('bar', 'baz')
->start();
}
public function testMySQLContainer(): void
{
$pdo = new \PDO(
sprintf(
'mysql:host=%s;port=%d',
$this->container->getHost(),
$this->container->getFirstMappedPort()
),
'bar',
'baz',
);
$query = $pdo->query('SHOW databases');
$this->assertInstanceOf(\PDOStatement::class, $query);
$databases = $query->fetchAll(\PDO::FETCH_COLUMN);
$this->assertContains('foo', $databases);
}
}
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace Testcontainers\Tests\Integration;
use Testcontainers\Modules\OpenSearchContainer;
class OpenSearchContainerTest extends ContainerTestCase
{
public function setUp(): void
{
$this->container = (new OpenSearchContainer())
->withDisabledSecurityPlugin()
->start();
}
/**
* @throws \JsonException
*/
public function testOpenSearch(): void
{
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, sprintf(
'http://%s:%d',
$this->container->getHost(),
$this->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 function setUp(): void
{
$this->container = (new PostgresContainer())
->withPostgresUser('bar')
->withPostgresDatabase('foo')
->start();
}
public function testPostgreSQLContainer(): void
{
$pdo = new \PDO(
sprintf(
'pgsql:host=%s;port=%d;dbname=foo',
$this->container->getHost(),
$this->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 function setUp(): void
{
$this->container = (new RedisContainer())
->start();
}
public function testRedisContainer(): void
{
$redisClient = new Client([
'host' => $this->container->getHost(),
'port' => $this->container->getFirstMappedPort(),
]);
$redisClient->ping();
$this->assertTrue($redisClient->isConnected());
$redisClient->set('greetings', 'Hello, World!');
$this->assertEquals('Hello, World!', $redisClient->get('greetings'));
}
}
@@ -0,0 +1,207 @@
<?php
declare(strict_types=1);
namespace Testcontainers\Tests\Integration;
use Testcontainers\Container\GenericContainer;
class StartedGenericContainerTest extends ContainerTestCase
{
public function testShouldReturnContainerId(): void
{
$container = (new GenericContainer('alpine'))
->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');
}
}
-162
View File
@@ -1,162 +0,0 @@
<?php
declare(strict_types=1);
namespace Testcontainers\Tests\Integration;
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\Wait\WaitForExec;
use Testcontainers\Wait\WaitForHealthCheck;
use Testcontainers\Wait\WaitForHttp;
use Testcontainers\Wait\WaitForLog;
use Testcontainers\Wait\WaitForTcpPortOpen;
class WaitStrategyTest extends TestCase
{
use DockerContainerAwareTrait;
public static function tearDownAfterClass(): void
{
parent::tearDownAfterClass();
Registry::cleanup();
}
public function testWaitForExec(): void
{
$called = false;
$container = Container::make('mysql')
->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 = 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<string, array<bool>>
*/
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);
}
}
+253
View File
@@ -0,0 +1,253 @@
<?php
declare(strict_types=1);
namespace Testcontainers\Tests\Unit\Utils;
use Docker\Docker;
use PHPUnit\Framework\TestCase;
use RuntimeException;
use Testcontainers\Utils\HostResolver;
class HostResolverTest extends TestCase
{
protected function setUp(): void
{
putenv('TESTCONTAINERS_HOST_OVERRIDE');
putenv('DOCKER_HOST');
}
protected function tearDown(): void
{
putenv('TESTCONTAINERS_HOST_OVERRIDE');
putenv('DOCKER_HOST');
}
public function testReturnsTestcontainersHostOverrideFromEnvironment(): void
{
// When the override is set, it should be returned.
putenv('TESTCONTAINERS_HOST_OVERRIDE=tcp://another:2375');
putenv('DOCKER_HOST=tcp://docker:2375');
$dummyClient = $this->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();
}
}
+218
View File
@@ -0,0 +1,218 @@
<?php
declare(strict_types=1);
namespace Testcontainers\Tests\Unit\Utils;
use InvalidArgumentException;
use PHPUnit\Framework\TestCase;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use RuntimeException;
use SplFileInfo;
use Testcontainers\Utils\TarBuilder;
/**
* @covers \Testcontainers\Utils\TarBuilder
*/
class TarBuilderTest extends TestCase
{
private const TEST_CONTENT = 'hello world';
private string $tempDir;
protected function setUp(): void
{
parent::setUp();
$this->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<RecursiveDirectoryIterator> $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);
}
}