first working version with simple WaitForContainerRunning implementation

This commit is contained in:
Sergei Shitikov
2024-08-29 18:01:04 +02:00
parent 27caae56c0
commit b7a274b1d9
8 changed files with 276 additions and 201 deletions
+14
View File
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace Testcontainers\Container;
/**
* Added for backward compatibility.
* Just a wrapper for GenericContainer.
* @deprecated Use GenericContainer instead.
*/
class Container extends GenericContainer
{
}
+144 -83
View File
@@ -4,17 +4,18 @@ declare(strict_types=1);
namespace Testcontainers\Container; namespace Testcontainers\Container;
use Docker\API\Exception\ContainerCreateNotFoundException;
use Docker\API\Model\ContainersCreatePostBody; use Docker\API\Model\ContainersCreatePostBody;
use Docker\API\Model\ContainersIdExecPostBody; use Docker\API\Model\ContainersIdExecPostBody;
use Docker\API\Model\EndpointSettings;
use Docker\API\Model\HealthConfig; use Docker\API\Model\HealthConfig;
use Docker\API\Model\HostConfig;
use Docker\API\Model\Mount; use Docker\API\Model\Mount;
use Docker\API\Model\NetworkingConfig; use Docker\API\Model\PortBinding;
use Docker\API\Model\Port;
use Docker\Docker; use Docker\Docker;
use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ResponseInterface;
use Testcontainers\Exception\ContainerNotReadyException; use Testcontainers\ContainerRuntime\ContainerRuntimeClient;
use Testcontainers\Registry; use Testcontainers\Registry;
use Testcontainers\Wait\WaitForContainerRunning;
use Testcontainers\Wait\WaitInterface; use Testcontainers\Wait\WaitInterface;
/** /**
@@ -46,7 +47,7 @@ class GenericContainer
protected WaitInterface $wait; protected WaitInterface $wait;
protected bool $privileged = false; protected bool $isPrivileged = false;
protected ?string $networkName = null; protected ?string $networkName = null;
/** /**
@@ -54,17 +55,19 @@ class GenericContainer
*/ */
protected array $mounts = []; protected array $mounts = [];
/** /** @var array<string> List of exposed ports in the format ['8080/tcp'] */
* @var array<Port> protected array $exposedPorts = [];
*/
protected array $ports = [];
protected function __construct(string $image) public function __construct(string $image)
{ {
$this->image = $image; $this->image = $image;
$this->dockerClient = Docker::create(); $this->dockerClient = ContainerRuntimeClient::getDockerClient();
} }
/**
* @deprecated Use constructor instead
* Left for backward compatibility
*/
public static function make(string $image): self public static function make(string $image): self
{ {
return new GenericContainer($image); return new GenericContainer($image);
@@ -120,16 +123,67 @@ class GenericContainer
return $this; return $this;
} }
/**
* @deprecated Use `withExposedPorts` instead
*/
public function withPort(string $localPort, string $containerPort): self public function withPort(string $localPort, string $containerPort): self
{ {
$this->ports[] = new Port(['privatePort' => (int) $containerPort, 'publicPort' => (int) $localPort]); return $this->withExposedPorts($containerPort);
}
/**
* @psalm-param string|int|array<string|int> $port
*/
/**
* Add ports to be exposed by the Docker container.
* This method accepts multiple inputs: single port, multiple ports, or ports with specific protocols
* to attempt to align with other language implementations.
*
* @psalm-param int|string|array<int|string> $ports One or more ports to expose.
* @return self Fluent interface for chaining.
*/
public function withExposedPorts(...$ports): self
{
foreach ($ports as $port) {
if (is_array($port)) {
// Flatten the array and recurse
$this->withExposedPorts(...$port);
} else {
// Handle single port entry, either string or int
$this->exposedPorts[] = $this->normalizePort($port);
}
}
return $this; return $this;
} }
/**
* Normalize a port specification to ensure it includes a protocol.
* Defaults to 'tcp' if no protocol is specified.
*
* @param string|int $port Port to normalize.
* @return string Normalized port string.
*
* TODO: move this to a utility class
*/
private function normalizePort(string|int $port): string
{
if (is_int($port)) {
// Direct integer ports default to tcp
return "{$port}/tcp";
}
// Check if the port specification already includes a protocol
if (is_string($port) && !str_contains($port, '/')) {
return "{$port}/tcp";
}
return $port;
}
public function withPrivileged(bool $privileged = true): self public function withPrivileged(bool $privileged = true): self
{ {
$this->privileged = $privileged; $this->isPrivileged = $privileged;
return $this; return $this;
} }
@@ -141,72 +195,12 @@ class GenericContainer
return $this; return $this;
} }
public function run(bool $wait = true): self public function wait(): self
{ {
$this->containerName = uniqid('testcontainer', true); $this->wait->wait($this->id);
$this->containerConfig = new ContainersCreatePostBody();
$this->containerConfig->setImage($this->image);
$envs = [];
foreach ($this->env as $name => $value) {
$envs[] = $name . '=' . $value;
}
$this->containerConfig->setEnv($envs);
if ($this->healthConfig !== null) {
$this->containerConfig->setHealthcheck($this->healthConfig);
}
if ($this->networkName !== null) {
$this->containerConfig->setNetworkingConfig(new NetworkingConfig([
'endpointsConfig' => [
$this->networkName => new EndpointSettings([
'aliases' => [$this->containerName],
'networkID' => $this->networkName,
]),
]]));
}
if ($this->entryPoint !== null) {
$this->containerConfig->setEntrypoint([$this->entryPoint]);
}
if ($this->privileged) {
//TODO: Implement privileged mode
}
$containerCreateResponse = $this->dockerClient->containerCreate($this->containerConfig, ['name' => $this->containerName]);
$this->id = $containerCreateResponse->getId();
Registry::add($this);
if ($wait) {
$this->wait();
}
return $this; return $this;
} }
public function wait(int $wait = 100): self
{
usleep(500000);
return $this;
// for ($i = 0; $i < $wait; $i++) {
// try {
// $this->dockerClient->containerWait($this->id);
// return $this;
// } catch (ContainerNotReadyException $e) {
// usleep(500000);
// }
// }
//
// throw new ContainerNotReadyException($this->id);
}
public function stop(): self public function stop(): self
{ {
$this->dockerClient->containerStop($this->id); $this->dockerClient->containerStop($this->id);
@@ -216,8 +210,41 @@ class GenericContainer
public function start(): self public function start(): self
{ {
try {
$containerCreatePostBody = new ContainersCreatePostBody();
$portMap = new \ArrayObject();
foreach ($this->exposedPorts as $port) {
$portBinding = new PortBinding();
$portBinding->setHostPort(explode('/', $port)[0]);
$portBinding->setHostIp('0.0.0.0');
$portMap[$port] = [$portBinding];
}
$hostConfig = new HostConfig();
$hostConfig->setPortBindings($portMap);
$containerCreatePostBody->setHostConfig($hostConfig);
$containerCreatePostBody->setImage($this->image);
//$containerCreatePostBody->setEnv($this->env);
$containerCreateResponse = $this->dockerClient->containerCreate($containerCreatePostBody);
$this->id = $containerCreateResponse?->getId() ?? '';
} catch (ContainerCreateNotFoundException) {
$this->dockerClient->imageCreate(null, [
'fromImage' => explode(':', $this->image)[0],
'tag' => explode(':', $this->image)[1] ?? 'latest',
]);
return $this->start();
}
Registry::add($this);
$this->dockerClient->containerStart($this->id); $this->dockerClient->containerStart($this->id);
if(!isset($this->wait)) {
$this->withWait(new WaitForContainerRunning());
}
$this->wait();
return $this; return $this;
} }
@@ -230,6 +257,7 @@ class GenericContainer
public function remove(): self public function remove(): self
{ {
$this->dockerClient->containerStop($this->id);
$this->dockerClient->containerDelete($this->id); $this->dockerClient->containerDelete($this->id);
Registry::remove($this); Registry::remove($this);
@@ -244,6 +272,15 @@ class GenericContainer
return $this; return $this;
} }
/**
* @deprecated Use `start` instead
* Left for backward compatibility
*/
public function run(): self
{
return $this->start();
}
/** /**
* @param array<string> $commandAsArray * @param array<string> $commandAsArray
*/ */
@@ -261,15 +298,39 @@ class GenericContainer
public function getAddress(): string public function getAddress(): string
{ {
$containerNetworks = $this->dockerClient->containerInspect($this->id) $inspection = $this->inspect();
->getNetworkSettings()->getNetworks(); return $inspection['gateway'];
$containerAddress = ''; // foreach ($containerNetworks as $network) {
foreach ($containerNetworks as $network) { // var_dump($network->getNetworkID(), $this->id, $network->getIPAddress());
if($network->getNetworkID() === $this->id) { // if($network->getNetworkID() === $this->id) {
$containerAddress = $network->getIpAddress(); // $containerAddress = $network->getIpAddress();
break; // break;
// }
// }
// return $containerAddress;
}
/**
* @return array{gateway: string, ports: array<string, int>}
*/
public function inspect(): array
{
$response = $this->dockerClient->containerInspect($this->id);
$settings = $response->getNetworkSettings();
var_dump($settings);
$ports = [];
foreach ($settings->getPorts() as $port => $value) {
if ($value === null) {
continue;
} }
$ports[$port] = (int) $value[0]->getHostPort();
} }
return $containerAddress;
return [
'gateway' => $settings->getGateway(),
'ports' => $ports,
];
} }
} }
+3 -3
View File
@@ -32,8 +32,8 @@ class Registry
public static function cleanup(): void public static function cleanup(): void
{ {
foreach (self::$registry as $container) { // foreach (self::$registry as $container) {
$container->remove(); // $container->remove();
} // }
} }
} }
-108
View File
@@ -1,108 +0,0 @@
<?php
declare(strict_types=1);
namespace Testcontainers\Trait;
use JsonException;
use Symfony\Component\Process\Process;
use Testcontainers\Container\GenericContainer;
use UnexpectedValueException;
/**
* @phpstan-import-type ContainerInspect from GenericContainer
* @phpstan-import-type DockerNetwork from GenericContainer
*/
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();
}
}
+47
View File
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace Testcontainers\Wait;
use Docker\API\Model\ContainersIdJsonGetResponse200;
use Docker\Docker;
use Testcontainers\ContainerRuntime\ContainerRuntimeClient;
use Testcontainers\Exception\ContainerNotReadyException;
/**
* Simply makes container inspect and checks if container is running.
* Uses $timout and $pollInterval in milliseconds to set the parameters for waiting.
*/
class WaitForContainerRunning implements WaitInterface
{
protected Docker $dockerClient;
public function __construct(protected int $timeout = 10000, protected int $pollInterval = 500)
{
$this->dockerClient = ContainerRuntimeClient::getDockerClient();
}
public function wait(string $id): void
{
$startTime = microtime(true) * 1000;
while (true) {
$elapsedTime = (microtime(true) * 1000) - $startTime;
if ($elapsedTime > $this->timeout) {
throw new ContainerNotReadyException($id);
}
/** @var ContainersIdJsonGetResponse200 | null $containerInspect */
$containerInspect = $this->dockerClient->containerInspect($id);
$containerStatus = $containerInspect?->getState()?->getStatus();
if ($containerStatus === 'running') {
return;
}
usleep($this->pollInterval * 1000);
}
}
}
+31 -7
View File
@@ -5,24 +5,48 @@ declare(strict_types=1);
namespace Testcontainers\Wait; namespace Testcontainers\Wait;
use Docker\Docker; use Docker\Docker;
use Docker\DockerClientFactory;
use Http\Client\Socket\Exception\TimeoutException;
use Testcontainers\ContainerRuntime\ContainerRuntimeClient;
use Testcontainers\Exception\ContainerNotReadyException; use Testcontainers\Exception\ContainerNotReadyException;
class WaitForHealthCheck implements WaitInterface class WaitForHealthCheck implements WaitInterface
{ {
protected Docker $dockerClient; protected Docker $dockerClient;
protected int $timeout;
protected int $pollInterval;
public function __construct() public function __construct(int $timeout = 5000, int $pollInterval = 1000)
{ {
$this->dockerClient = Docker::create(); $this->dockerClient = ContainerRuntimeClient::getDockerClient();
$this->timeout = $timeout;
$this->pollInterval = $pollInterval;
} }
public function wait(string $id): void public function wait(string $id): void
{ {
$containerInspect = $this->dockerClient->containerInspect($id); $startTime = microtime(true) * 1000;
$containerInspect->getBody()->getContents();
dd($containerInspect->getStatusCode());
if ($status !== 'healthy') { while (true) {
throw new ContainerNotReadyException($id); $elapsedTime = (microtime(true) * 1000) - $startTime;
if ($elapsedTime > $this->timeout) {
throw new TimeoutException(sprintf("Health check not healthy after %d ms", $this->timeout));
}
$containerInspect = $this->dockerClient->containerInspect($id, [], Docker::FETCH_RESPONSE);
//$containerStatus = $containerInspect?->getArrayCopy() ?? null;
var_dump($containerInspect->getBody()->getContents());
$containerStatus='';
if ($containerStatus === 'healthy') {
return;
}
if ($containerStatus === 'unhealthy') {
throw new ContainerNotReadyException(sprintf("Health check failed: %s", $containerStatus));
}
usleep($this->pollInterval * 1000); // Sleep for the polling interval
} }
} }
} }
+1
View File
@@ -21,6 +21,7 @@ class ContainerTest extends TestCase
$container->withMySQLUser('bar', 'baz'); $container->withMySQLUser('bar', 'baz');
$container->run(); $container->run();
die(123);
$pdo = new \PDO( $pdo = new \PDO(
sprintf('mysql:host=%s;port=3306', $container->getAddress()), sprintf('mysql:host=%s;port=3306', $container->getAddress()),
+36
View File
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Testcontainers\Tests\Integration;
use PHPUnit\Framework\TestCase;
use Testcontainers\Container\GenericContainer;
use Testcontainers\Registry;
class RedisContainerTest extends TestCase
{
// public static function tearDownAfterClass(): void
// {
// parent::tearDownAfterClass();
//
// Registry::cleanup();
// }
public function testRedisContainer(): void
{
$redisContainer = (new GenericContainer('redis:alpine'))
->withExposedPorts(6379)
->start();
$redisClient = new \Predis\Client([
'host' => 'localhost',
'port' => 6379,
]);
$redisClient->set('greetings', 'Hello, World!');
$this->assertEquals('Hello, World!', $redisClient->get('greetings'));
$redisContainer->remove();
}
}