mirror of
https://github.com/stan220/testcontainers-php.git
synced 2026-09-08 17:29:29 +00:00
Merge pull request #29 from testcontainers/0.2
This commit is contained in:
@@ -5,8 +5,6 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|||||||
@@ -17,12 +17,15 @@ composer req --dev testcontainers/testcontainers
|
|||||||
```php
|
```php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
use Testcontainers\Container\Container;
|
use Testcontainers\Container\GenericContainer;
|
||||||
|
|
||||||
$container = Container::make('nginx:alpine');
|
$container = new GenericContainer('nginx:alpine');
|
||||||
|
|
||||||
// set an environment variable
|
// set an environment variable
|
||||||
$container->withEnvironment('name', 'var');
|
$container->withEnvironment([
|
||||||
|
'key1' => 'val1',
|
||||||
|
'key2' => 'val2'
|
||||||
|
]);
|
||||||
|
|
||||||
// enable health check for an container
|
// enable health check for an container
|
||||||
$container->withHealthCheckCommand('curl --fail localhost');
|
$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
|
```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
|
// 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']));
|
||||||
|
|
||||||
$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
|
// 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
|
// 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
|
// Wait until the docker heartcheck is green
|
||||||
$container->withWait(new WaitForHealthCheck());
|
$container->withWait(new WaitForHealthCheck());
|
||||||
@@ -58,16 +73,19 @@ $container->withWait(new WaitForHealthCheck());
|
|||||||
```php
|
```php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
use Testcontainers\Container\MySQLContainer;
|
use Testcontainers\Modules\MySQLContainer;
|
||||||
|
|
||||||
$container = MySQLContainer::make('8.0');
|
$container = (new MySQLContainer('8.0'))
|
||||||
$container->withMySQLDatabase('foo');
|
->withMySQLDatabase('foo')
|
||||||
$container->withMySQLUser('bar', 'baz');
|
->withMySQLUser('bar', 'baz')
|
||||||
|
->start();
|
||||||
$container->run();
|
|
||||||
|
|
||||||
$pdo = new \PDO(
|
$pdo = new \PDO(
|
||||||
sprintf('mysql:host=%s;port=3306', $container->getAddress()),
|
sprintf(
|
||||||
|
'mysql:host=%s;port=%d',
|
||||||
|
$container->getHost(),
|
||||||
|
$container->getFirstMappedPort()
|
||||||
|
),
|
||||||
'bar',
|
'bar',
|
||||||
'baz',
|
'baz',
|
||||||
);
|
);
|
||||||
@@ -80,16 +98,19 @@ $pdo = new \PDO(
|
|||||||
```php
|
```php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
use Testcontainers\Container\MariaDBContainer;
|
use Testcontainers\Modules\MariaDBContainer;
|
||||||
|
|
||||||
$container = MariaDBContainer::make('8.0');
|
$container = $container = (new MariaDBContainer())
|
||||||
$container->withMariaDBDatabase('foo');
|
->withMariaDBDatabase('foo')
|
||||||
$container->withMariaDBUser('bar', 'baz');
|
->withMariaDBUser('bar', 'baz')
|
||||||
|
->start();
|
||||||
$container->run();
|
|
||||||
|
|
||||||
$pdo = new \PDO(
|
$pdo = new \PDO(
|
||||||
sprintf('mysql:host=%s;port=3306', $container->getAddress()),
|
sprintf(
|
||||||
|
'mysql:host=%s;port=%d',
|
||||||
|
$container->getHost(),
|
||||||
|
$container->getFirstMappedPort()
|
||||||
|
),
|
||||||
'bar',
|
'bar',
|
||||||
'baz',
|
'baz',
|
||||||
);
|
);
|
||||||
@@ -102,18 +123,21 @@ $pdo = new \PDO(
|
|||||||
```php
|
```php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
use Testcontainers\Container\PostgresContainer;
|
use Testcontainers\Modules\PostgresContainer;
|
||||||
|
|
||||||
$container = PostgresContainer::make('15.0', 'password');
|
$container = (new PostgresContainer())
|
||||||
$container->withPostgresDatabase('database');
|
->withPostgresUser('bar')
|
||||||
$container->withPostgresUser('username');
|
->withPostgresDatabase('foo')
|
||||||
|
->start();
|
||||||
$container->run();
|
|
||||||
|
|
||||||
$pdo = new \PDO(
|
$pdo = new \PDO(
|
||||||
sprintf('pgsql:host=%s;port=5432;dbname=database', $container->getAddress()),
|
sprintf(
|
||||||
'username',
|
'pgsql:host=%s;port=%d;dbname=foo',
|
||||||
'password',
|
self::$container->getHost(),
|
||||||
|
self::$container->getFirstMappedPort()
|
||||||
|
),
|
||||||
|
'bar',
|
||||||
|
'test',
|
||||||
);
|
);
|
||||||
|
|
||||||
// Do something with pdo
|
// Do something with pdo
|
||||||
@@ -123,14 +147,13 @@ $pdo = new \PDO(
|
|||||||
|
|
||||||
```php
|
```php
|
||||||
|
|
||||||
use Testcontainers\Container\RedisContainer;
|
use Testcontainers\Modules\RedisContainer;
|
||||||
|
|
||||||
$container = RedisContainer::make('6.0');
|
$container = (new RedisContainer())
|
||||||
|
->start();
|
||||||
$container->run();
|
|
||||||
|
|
||||||
$redis = new \Redis();
|
$redis = new \Redis();
|
||||||
$redis->connect($container->getAddress());
|
$redis->connect($container->getHost(), $container->getFirstMappedPort());
|
||||||
|
|
||||||
// Do something with redis
|
// Do something with redis
|
||||||
```
|
```
|
||||||
@@ -139,12 +162,11 @@ $redis->connect($container->getAddress());
|
|||||||
|
|
||||||
```php
|
```php
|
||||||
|
|
||||||
use Testcontainers\Container\OpenSearchContainer;
|
use Testcontainers\Modules\OpenSearchContainer;
|
||||||
|
|
||||||
$container = OpenSearchContainer::make('2');
|
$container = (new OpenSearchContainer())
|
||||||
$container->disableSecurityPlugin();
|
->withDisabledSecurityPlugin()
|
||||||
|
->start();
|
||||||
$container->run();
|
|
||||||
|
|
||||||
// Do something with opensearch
|
// Do something with opensearch
|
||||||
```
|
```
|
||||||
@@ -166,7 +188,7 @@ use Doctrine\Bundle\DoctrineBundle\ConnectionFactory;
|
|||||||
use Doctrine\Common\EventManager;
|
use Doctrine\Common\EventManager;
|
||||||
use Doctrine\DBAL\Configuration;
|
use Doctrine\DBAL\Configuration;
|
||||||
use Doctrine\DBAL\Tools\DsnParser;
|
use Doctrine\DBAL\Tools\DsnParser;
|
||||||
use Testcontainers\Container\PostgresContainer;
|
use Testcontainers\Modules\PostgresContainer;
|
||||||
|
|
||||||
class TestConnectionFactory extends ConnectionFactory
|
class TestConnectionFactory extends ConnectionFactory
|
||||||
{
|
{
|
||||||
@@ -175,11 +197,12 @@ class TestConnectionFactory extends ConnectionFactory
|
|||||||
public function __construct(array $typesConfig, ?DsnParser $dsnParser = null)
|
public function __construct(array $typesConfig, ?DsnParser $dsnParser = null)
|
||||||
{
|
{
|
||||||
if (!$this::$testDsn) {
|
if (!$this::$testDsn) {
|
||||||
$psql = PostgresContainer::make('14.0', 'password');
|
$psql = (new PostgresContainer())
|
||||||
$psql->withPostgresDatabase('database');
|
->withPostgresUser('user')
|
||||||
$psql->withPostgresUser('user');
|
->withPostgresPassword('password')
|
||||||
$psql->run();
|
->withPostgresDatabase('database')
|
||||||
$this::$testDsn = sprintf('postgresql://user:password@%s:5432/database?serverVersion=14&charset=utf8', $psql->getAddress());
|
->start();
|
||||||
|
$this::$testDsn = sprintf('postgresql://user:password@%s:%d/database?serverVersion=14&charset=utf8', $psql->getAddress(), $psql->getFirstMappedPort());
|
||||||
}
|
}
|
||||||
parent::__construct($typesConfig, $dsnParser);
|
parent::__construct($typesConfig, $dsnParser);
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-4
@@ -14,12 +14,16 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"require": {
|
"require": {
|
||||||
|
"ext-curl": "*",
|
||||||
"php": ">= 8.1",
|
"php": ">= 8.1",
|
||||||
"symfony/process": "^5.0|^6.0|^7.0"
|
"beluga-php/docker-php": "^1.45"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
|
"ext-pdo": "*",
|
||||||
|
"ext-pdo_mysql": "*",
|
||||||
|
"ext-pdo_pgsql": "*",
|
||||||
"phpunit/phpunit": "^9.5",
|
"phpunit/phpunit": "^9.5",
|
||||||
"brianium/paratest": "^6.6",
|
"brianium/paratest": "^6.11",
|
||||||
"friendsofphp/php-cs-fixer": "^3.12",
|
"friendsofphp/php-cs-fixer": "^3.12",
|
||||||
"phpstan/phpstan": "^1.8",
|
"phpstan/phpstan": "^1.8",
|
||||||
"phpstan/phpstan-phpunit": "^1.1",
|
"phpstan/phpstan-phpunit": "^1.1",
|
||||||
@@ -40,11 +44,12 @@
|
|||||||
"integration": "paratest tests/ --bootstrap vendor/autoload.php -f",
|
"integration": "paratest tests/ --bootstrap vendor/autoload.php -f",
|
||||||
"cs": "php-cs-fixer fix --dry-run",
|
"cs": "php-cs-fixer fix --dry-run",
|
||||||
"cs:fix": "php-cs-fixer fix",
|
"cs:fix": "php-cs-fixer fix",
|
||||||
"phpstan": "phpstan analyse"
|
"phpstan": "phpstan analyse --memory-limit=256M"
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
"allow-plugins": {
|
"allow-plugins": {
|
||||||
"phpstan/extension-installer": true
|
"phpstan/extension-installer": true,
|
||||||
|
"php-http/discovery": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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) ?? '';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Testcontainers\Container;
|
||||||
|
|
||||||
|
interface StoppedTestContainer
|
||||||
|
{
|
||||||
|
public function getId(): string;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,10 +4,6 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Testcontainers\Exception;
|
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Testcontainers\Trait;
|
|
||||||
|
|
||||||
use JsonException;
|
|
||||||
use Symfony\Component\Process\Process;
|
|
||||||
use Testcontainers\Container\Container;
|
|
||||||
use UnexpectedValueException;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @phpstan-import-type ContainerInspect from Container
|
|
||||||
* @phpstan-import-type DockerNetwork from Container
|
|
||||||
*/
|
|
||||||
trait DockerContainerAwareTrait
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @param string $containerId
|
|
||||||
* @param string|null $networkName
|
|
||||||
* @param ContainerInspect|null $inspectedData
|
|
||||||
* @return string
|
|
||||||
*
|
|
||||||
* @throws JsonException
|
|
||||||
*/
|
|
||||||
private static function dockerContainerAddress(string $containerId, ?string $networkName = null, ?array $inspectedData = null): string
|
|
||||||
{
|
|
||||||
if (! is_array($inspectedData)) {
|
|
||||||
$inspectedData = self::dockerContainerInspect($containerId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (is_string($networkName)) {
|
|
||||||
$containerAddress = $inspectedData[0]['NetworkSettings']['Networks'][$networkName]['IPAddress'] ?? null;
|
|
||||||
|
|
||||||
if (is_string($containerAddress)) {
|
|
||||||
return $containerAddress;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$containerAddress = $inspectedData[0]['NetworkSettings']['IPAddress'] ?? null;
|
|
||||||
|
|
||||||
if (is_string($containerAddress)) {
|
|
||||||
return $containerAddress;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new UnexpectedValueException('Unable to find container IP address');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param string $containerId
|
|
||||||
* @return ContainerInspect
|
|
||||||
*
|
|
||||||
* @throws JsonException
|
|
||||||
*/
|
|
||||||
private static function dockerContainerInspect(string $containerId): array
|
|
||||||
{
|
|
||||||
$process = new Process(['docker', 'inspect', $containerId]);
|
|
||||||
$process->mustRun();
|
|
||||||
|
|
||||||
/** @var ContainerInspect */
|
|
||||||
return json_decode($process->getOutput(), true, 512, JSON_THROW_ON_ERROR);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param string $networkName
|
|
||||||
* @return DockerNetwork|false
|
|
||||||
*
|
|
||||||
* @throws JsonException
|
|
||||||
*/
|
|
||||||
private static function dockerNetworkFind(string $networkName): array|false
|
|
||||||
{
|
|
||||||
$process = new Process(['docker', 'network', 'ls', '--format', 'json', '--filter', 'name=' . $networkName]);
|
|
||||||
$process->mustRun();
|
|
||||||
|
|
||||||
$json = $process->getOutput();
|
|
||||||
|
|
||||||
if ($json === '') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$json = str_replace("\n", ',', $json);
|
|
||||||
$json = '['. rtrim($json, ',') .']';
|
|
||||||
|
|
||||||
/** @var array<int, DockerNetwork> $output */
|
|
||||||
$output = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
|
|
||||||
|
|
||||||
/** @var array<int, DockerNetwork> $matchingNetworks */
|
|
||||||
$matchingNetworks = array_filter($output, static fn (array $network) => $network['Name'] === $networkName);
|
|
||||||
|
|
||||||
if (count($matchingNetworks) === 0) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $matchingNetworks[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function dockerNetworkCreate(string $networkName, string $driver = 'bridge'): void
|
|
||||||
{
|
|
||||||
$process = new Process(['docker', 'network', 'create', '--driver', $driver, $networkName]);
|
|
||||||
$process->mustRun();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function dockerNetworkRemove(string $networkName): void
|
|
||||||
{
|
|
||||||
$process = new Process(['docker', 'network', 'rm', $networkName, '-f']);
|
|
||||||
$process->mustRun();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,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++];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+38
-13
@@ -5,31 +5,56 @@ declare(strict_types=1);
|
|||||||
namespace Testcontainers\Wait;
|
namespace Testcontainers\Wait;
|
||||||
|
|
||||||
use Closure;
|
use Closure;
|
||||||
use Symfony\Component\Process\Process;
|
use Docker\API\Model\ExecIdJsonGetResponse200;
|
||||||
use Testcontainers\Exception\ContainerNotReadyException;
|
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
|
* @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 {
|
while (true) {
|
||||||
$process->mustRun();
|
$elapsedTime = (microtime(true) * 1000) - $startTime;
|
||||||
} catch (\Exception $e) {
|
|
||||||
throw new ContainerNotReadyException($id, $e);
|
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) {
|
if ($this->checkFunction !== null) {
|
||||||
$func = $this->checkFunction;
|
$checkResult = ($this->checkFunction)($exitCode, $contents);
|
||||||
$func($process);
|
if ($checkResult) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} elseif ($exitCode === 0) {
|
||||||
|
return; // Command succeeded
|
||||||
|
}
|
||||||
|
|
||||||
|
usleep($this->pollInterval * 1000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,27 +4,70 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Testcontainers\Wait;
|
namespace Testcontainers\Wait;
|
||||||
|
|
||||||
use RuntimeException;
|
use Docker\API\Model\ContainersIdJsonGetResponse200;
|
||||||
use Symfony\Component\Process\Process;
|
use Testcontainers\Container\StartedTestContainer;
|
||||||
use Testcontainers\Exception\ContainerNotReadyException;
|
use Testcontainers\Exception\ContainerStateException;
|
||||||
|
use Testcontainers\Exception\ContainerWaitingTimeoutException;
|
||||||
|
use Testcontainers\Exception\HealthCheckFailedException;
|
||||||
|
use Testcontainers\Exception\HealthCheckNotConfiguredException;
|
||||||
|
use Testcontainers\Exception\UnknownHealthStatusException;
|
||||||
|
|
||||||
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]);
|
$startTime = microtime(true);
|
||||||
$process->mustRun();
|
|
||||||
|
|
||||||
$status = json_decode($process->getOutput(), true, 512, JSON_THROW_ON_ERROR);
|
while (true) {
|
||||||
|
$elapsedTime = (microtime(true) - $startTime) * 1000;
|
||||||
|
|
||||||
if (!is_string($status)) {
|
if ($elapsedTime > $this->timeout) {
|
||||||
throw new ContainerNotReadyException($id, new RuntimeException('Invalid json output'));
|
throw new ContainerWaitingTimeoutException($container->getId());
|
||||||
}
|
}
|
||||||
|
|
||||||
$status = trim($status, '"');
|
/** @var ContainersIdJsonGetResponse200|null $containerInspect */
|
||||||
|
$containerInspect = $container->getClient()->containerInspect($container->getId());
|
||||||
|
|
||||||
if ($status !== 'healthy') {
|
$containerState = $containerInspect?->getState();
|
||||||
throw new ContainerNotReadyException($id);
|
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+100
-36
@@ -4,75 +4,139 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Testcontainers\Wait;
|
namespace Testcontainers\Wait;
|
||||||
|
|
||||||
use Testcontainers\Exception\ContainerNotReadyException;
|
use Testcontainers\Container\HttpMethod;
|
||||||
use Testcontainers\Trait\DockerContainerAwareTrait;
|
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';
|
protected string $path = '/';
|
||||||
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 $protocol = 'http';
|
||||||
|
|
||||||
private string $method = 'GET';
|
protected int $expectedStatusCode = 200;
|
||||||
private string $path = '/';
|
|
||||||
private int $statusCode = 200;
|
|
||||||
|
|
||||||
public function __construct(private int $port)
|
protected bool $allowInsecure = false;
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function make(int $port): self
|
/**
|
||||||
{
|
* @var array<string, string>
|
||||||
return new WaitForHttp($port);
|
*/
|
||||||
|
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;
|
$this->method = $method;
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function withPath(string $path): self
|
public function withPath(string $path): self
|
||||||
{
|
{
|
||||||
$this->path = $path;
|
$this->path = $path;
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function withStatusCode(int $statusCode): self
|
public function withExpectedStatusCode(int $statusCode): self
|
||||||
{
|
{
|
||||||
$this->statusCode = $statusCode;
|
$this->expectedStatusCode = $statusCode;
|
||||||
|
|
||||||
return $this;
|
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();
|
$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_RETURNTRANSFER, true);
|
||||||
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $this->method);
|
|
||||||
curl_setopt($ch, CURLOPT_HEADER, true);
|
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);
|
||||||
|
|
||||||
|
// 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);
|
curl_exec($ch);
|
||||||
|
$responseCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
if (curl_getinfo($ch, CURLINFO_HTTP_CODE) !== $this->statusCode) {
|
|
||||||
throw new ContainerNotReadyException($id, new \RuntimeException('HTTP status code does not match'));
|
|
||||||
}
|
|
||||||
|
|
||||||
curl_close($ch);
|
curl_close($ch);
|
||||||
|
|
||||||
|
return $responseCode;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+30
-15
@@ -4,30 +4,45 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Testcontainers\Wait;
|
namespace Testcontainers\Wait;
|
||||||
|
|
||||||
use Symfony\Component\Process\Process;
|
use Testcontainers\Container\StartedTestContainer;
|
||||||
use Testcontainers\Exception\ContainerNotReadyException;
|
use Testcontainers\Exception\ContainerWaitingTimeoutException;
|
||||||
|
|
||||||
class WaitForLog implements WaitInterface
|
/**
|
||||||
{
|
* Uses $timout and $pollInterval in milliseconds to set the parameters for waiting.
|
||||||
public function __construct(private string $message, private bool $enableRegex = false)
|
*/
|
||||||
|
class WaitForLog extends BaseWaitStrategy
|
||||||
{
|
{
|
||||||
|
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]);
|
$startTime = microtime(true) * 1000;
|
||||||
$process->mustRun();
|
|
||||||
|
|
||||||
$output = $process->getOutput() . PHP_EOL . $process->getErrorOutput();
|
while (true) {
|
||||||
|
$elapsedTime = (microtime(true) * 1000) - $startTime;
|
||||||
|
|
||||||
|
if ($elapsedTime > $this->timeout) {
|
||||||
|
throw new ContainerWaitingTimeoutException($container->getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
$output = $container->logs();
|
||||||
|
|
||||||
if ($this->enableRegex) {
|
if ($this->enableRegex) {
|
||||||
if (!preg_match($this->message, $output)) {
|
if (preg_match($this->message, $output)) {
|
||||||
throw new ContainerNotReadyException($id, new \RuntimeException('Message not found in logs'));
|
return;
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (!str_contains($output, $this->message)) {
|
|
||||||
throw new ContainerNotReadyException($id, new \RuntimeException('Message not found in logs'));
|
|
||||||
}
|
}
|
||||||
|
} elseif (str_contains($output, $this->message)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
usleep($this->pollInterval * 1000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Testcontainers\Wait;
|
|
||||||
|
|
||||||
class WaitForNothing implements WaitInterface
|
|
||||||
{
|
|
||||||
public function wait(string $id): void
|
|
||||||
{
|
|
||||||
// does nothing
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Testcontainers\Wait;
|
|
||||||
|
|
||||||
interface WaitInterface
|
|
||||||
{
|
|
||||||
public function wait(string $id): void;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Testcontainers\Wait;
|
||||||
|
|
||||||
|
use Testcontainers\Container\StartedTestContainer;
|
||||||
|
|
||||||
|
interface WaitStrategy
|
||||||
|
{
|
||||||
|
public function wait(StartedTestContainer $container): void;
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
hello world
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user