mirror of
https://github.com/stan220/testcontainers-php.git
synced 2026-09-08 16:29:29 +00:00
first working version with simple WaitForContainerRunning implementation
This commit is contained in:
@@ -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
|
||||
{
|
||||
}
|
||||
@@ -4,17 +4,18 @@ declare(strict_types=1);
|
||||
|
||||
namespace Testcontainers\Container;
|
||||
|
||||
use Docker\API\Exception\ContainerCreateNotFoundException;
|
||||
use Docker\API\Model\ContainersCreatePostBody;
|
||||
use Docker\API\Model\ContainersIdExecPostBody;
|
||||
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\Port;
|
||||
use Docker\API\Model\PortBinding;
|
||||
use Docker\Docker;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Testcontainers\Exception\ContainerNotReadyException;
|
||||
use Testcontainers\ContainerRuntime\ContainerRuntimeClient;
|
||||
use Testcontainers\Registry;
|
||||
use Testcontainers\Wait\WaitForContainerRunning;
|
||||
use Testcontainers\Wait\WaitInterface;
|
||||
|
||||
/**
|
||||
@@ -46,7 +47,7 @@ class GenericContainer
|
||||
|
||||
protected WaitInterface $wait;
|
||||
|
||||
protected bool $privileged = false;
|
||||
protected bool $isPrivileged = false;
|
||||
protected ?string $networkName = null;
|
||||
|
||||
/**
|
||||
@@ -54,17 +55,19 @@ class GenericContainer
|
||||
*/
|
||||
protected array $mounts = [];
|
||||
|
||||
/**
|
||||
* @var array<Port>
|
||||
*/
|
||||
protected array $ports = [];
|
||||
/** @var array<string> List of exposed ports in the format ['8080/tcp'] */
|
||||
protected array $exposedPorts = [];
|
||||
|
||||
protected function __construct(string $image)
|
||||
public function __construct(string $image)
|
||||
{
|
||||
$this->image = $image;
|
||||
$this->dockerClient = Docker::create();
|
||||
$this->dockerClient = ContainerRuntimeClient::getDockerClient();
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use constructor instead
|
||||
* Left for backward compatibility
|
||||
*/
|
||||
public static function make(string $image): self
|
||||
{
|
||||
return new GenericContainer($image);
|
||||
@@ -120,16 +123,67 @@ class GenericContainer
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use `withExposedPorts` instead
|
||||
*/
|
||||
public function withPort(string $localPort, string $containerPort): self
|
||||
{
|
||||
$this->ports[] = new Port(['privatePort' => (int) $containerPort, 'publicPort' => (int) $localPort]);
|
||||
return $this->withExposedPorts($containerPort);
|
||||
}
|
||||
|
||||
/**
|
||||
* @psalm-param string|int|array<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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a port specification to ensure it includes a protocol.
|
||||
* Defaults to 'tcp' if no protocol is specified.
|
||||
*
|
||||
* @param string|int $port Port to normalize.
|
||||
* @return string Normalized port string.
|
||||
*
|
||||
* TODO: move this to a utility class
|
||||
*/
|
||||
private function normalizePort(string|int $port): string
|
||||
{
|
||||
if (is_int($port)) {
|
||||
// Direct integer ports default to tcp
|
||||
return "{$port}/tcp";
|
||||
}
|
||||
|
||||
// Check if the port specification already includes a protocol
|
||||
if (is_string($port) && !str_contains($port, '/')) {
|
||||
return "{$port}/tcp";
|
||||
}
|
||||
|
||||
return $port;
|
||||
}
|
||||
|
||||
public function withPrivileged(bool $privileged = true): self
|
||||
{
|
||||
$this->privileged = $privileged;
|
||||
$this->isPrivileged = $privileged;
|
||||
|
||||
return $this;
|
||||
}
|
||||
@@ -141,72 +195,12 @@ class GenericContainer
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function run(bool $wait = true): self
|
||||
public function wait(): self
|
||||
{
|
||||
$this->containerName = uniqid('testcontainer', true);
|
||||
|
||||
$this->containerConfig = new ContainersCreatePostBody();
|
||||
$this->containerConfig->setImage($this->image);
|
||||
|
||||
$envs = [];
|
||||
foreach ($this->env as $name => $value) {
|
||||
$envs[] = $name . '=' . $value;
|
||||
}
|
||||
|
||||
$this->containerConfig->setEnv($envs);
|
||||
|
||||
if ($this->healthConfig !== null) {
|
||||
$this->containerConfig->setHealthcheck($this->healthConfig);
|
||||
}
|
||||
|
||||
if ($this->networkName !== null) {
|
||||
$this->containerConfig->setNetworkingConfig(new NetworkingConfig([
|
||||
'endpointsConfig' => [
|
||||
$this->networkName => new EndpointSettings([
|
||||
'aliases' => [$this->containerName],
|
||||
'networkID' => $this->networkName,
|
||||
]),
|
||||
]]));
|
||||
}
|
||||
|
||||
if ($this->entryPoint !== null) {
|
||||
$this->containerConfig->setEntrypoint([$this->entryPoint]);
|
||||
}
|
||||
|
||||
if ($this->privileged) {
|
||||
//TODO: Implement privileged mode
|
||||
}
|
||||
|
||||
$containerCreateResponse = $this->dockerClient->containerCreate($this->containerConfig, ['name' => $this->containerName]);
|
||||
|
||||
$this->id = $containerCreateResponse->getId();
|
||||
|
||||
Registry::add($this);
|
||||
|
||||
if ($wait) {
|
||||
$this->wait();
|
||||
}
|
||||
|
||||
$this->wait->wait($this->id);
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function wait(int $wait = 100): self
|
||||
{
|
||||
usleep(500000);
|
||||
return $this;
|
||||
|
||||
// for ($i = 0; $i < $wait; $i++) {
|
||||
// try {
|
||||
// $this->dockerClient->containerWait($this->id);
|
||||
// return $this;
|
||||
// } catch (ContainerNotReadyException $e) {
|
||||
// usleep(500000);
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// throw new ContainerNotReadyException($this->id);
|
||||
}
|
||||
|
||||
public function stop(): self
|
||||
{
|
||||
$this->dockerClient->containerStop($this->id);
|
||||
@@ -216,8 +210,41 @@ class GenericContainer
|
||||
|
||||
public function start(): self
|
||||
{
|
||||
try {
|
||||
$containerCreatePostBody = new ContainersCreatePostBody();
|
||||
$portMap = new \ArrayObject();
|
||||
|
||||
foreach ($this->exposedPorts as $port) {
|
||||
$portBinding = new PortBinding();
|
||||
$portBinding->setHostPort(explode('/', $port)[0]);
|
||||
$portBinding->setHostIp('0.0.0.0');
|
||||
$portMap[$port] = [$portBinding];
|
||||
}
|
||||
|
||||
$hostConfig = new HostConfig();
|
||||
$hostConfig->setPortBindings($portMap);
|
||||
$containerCreatePostBody->setHostConfig($hostConfig);
|
||||
$containerCreatePostBody->setImage($this->image);
|
||||
//$containerCreatePostBody->setEnv($this->env);
|
||||
|
||||
$containerCreateResponse = $this->dockerClient->containerCreate($containerCreatePostBody);
|
||||
$this->id = $containerCreateResponse?->getId() ?? '';
|
||||
} catch (ContainerCreateNotFoundException) {
|
||||
$this->dockerClient->imageCreate(null, [
|
||||
'fromImage' => explode(':', $this->image)[0],
|
||||
'tag' => explode(':', $this->image)[1] ?? 'latest',
|
||||
]);
|
||||
return $this->start();
|
||||
}
|
||||
|
||||
Registry::add($this);
|
||||
|
||||
$this->dockerClient->containerStart($this->id);
|
||||
|
||||
if(!isset($this->wait)) {
|
||||
$this->withWait(new WaitForContainerRunning());
|
||||
}
|
||||
$this->wait();
|
||||
return $this;
|
||||
}
|
||||
|
||||
@@ -230,6 +257,7 @@ class GenericContainer
|
||||
|
||||
public function remove(): self
|
||||
{
|
||||
$this->dockerClient->containerStop($this->id);
|
||||
$this->dockerClient->containerDelete($this->id);
|
||||
|
||||
Registry::remove($this);
|
||||
@@ -244,6 +272,15 @@ class GenericContainer
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use `start` instead
|
||||
* Left for backward compatibility
|
||||
*/
|
||||
public function run(): self
|
||||
{
|
||||
return $this->start();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string> $commandAsArray
|
||||
*/
|
||||
@@ -261,15 +298,39 @@ class GenericContainer
|
||||
|
||||
public function getAddress(): string
|
||||
{
|
||||
$containerNetworks = $this->dockerClient->containerInspect($this->id)
|
||||
->getNetworkSettings()->getNetworks();
|
||||
$containerAddress = '';
|
||||
foreach ($containerNetworks as $network) {
|
||||
if($network->getNetworkID() === $this->id) {
|
||||
$containerAddress = $network->getIpAddress();
|
||||
break;
|
||||
$inspection = $this->inspect();
|
||||
return $inspection['gateway'];
|
||||
// foreach ($containerNetworks as $network) {
|
||||
// var_dump($network->getNetworkID(), $this->id, $network->getIPAddress());
|
||||
// if($network->getNetworkID() === $this->id) {
|
||||
// $containerAddress = $network->getIpAddress();
|
||||
// break;
|
||||
// }
|
||||
// }
|
||||
// return $containerAddress;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{gateway: string, ports: array<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
@@ -32,8 +32,8 @@ class Registry
|
||||
|
||||
public static function cleanup(): void
|
||||
{
|
||||
foreach (self::$registry as $container) {
|
||||
$container->remove();
|
||||
}
|
||||
// foreach (self::$registry as $container) {
|
||||
// $container->remove();
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,24 +5,48 @@ declare(strict_types=1);
|
||||
namespace Testcontainers\Wait;
|
||||
|
||||
use Docker\Docker;
|
||||
use Docker\DockerClientFactory;
|
||||
use Http\Client\Socket\Exception\TimeoutException;
|
||||
use Testcontainers\ContainerRuntime\ContainerRuntimeClient;
|
||||
use Testcontainers\Exception\ContainerNotReadyException;
|
||||
|
||||
class WaitForHealthCheck implements WaitInterface
|
||||
{
|
||||
protected Docker $dockerClient;
|
||||
protected int $timeout;
|
||||
protected int $pollInterval;
|
||||
|
||||
public function __construct()
|
||||
public function __construct(int $timeout = 5000, int $pollInterval = 1000)
|
||||
{
|
||||
$this->dockerClient = Docker::create();
|
||||
$this->dockerClient = ContainerRuntimeClient::getDockerClient();
|
||||
$this->timeout = $timeout;
|
||||
$this->pollInterval = $pollInterval;
|
||||
}
|
||||
|
||||
public function wait(string $id): void
|
||||
{
|
||||
$containerInspect = $this->dockerClient->containerInspect($id);
|
||||
$containerInspect->getBody()->getContents();
|
||||
dd($containerInspect->getStatusCode());
|
||||
$startTime = microtime(true) * 1000;
|
||||
|
||||
if ($status !== 'healthy') {
|
||||
throw new ContainerNotReadyException($id);
|
||||
while (true) {
|
||||
$elapsedTime = (microtime(true) * 1000) - $startTime;
|
||||
|
||||
if ($elapsedTime > $this->timeout) {
|
||||
throw new TimeoutException(sprintf("Health check not healthy after %d ms", $this->timeout));
|
||||
}
|
||||
|
||||
$containerInspect = $this->dockerClient->containerInspect($id, [], Docker::FETCH_RESPONSE);
|
||||
//$containerStatus = $containerInspect?->getArrayCopy() ?? null;
|
||||
var_dump($containerInspect->getBody()->getContents());
|
||||
$containerStatus='';
|
||||
if ($containerStatus === 'healthy') {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($containerStatus === 'unhealthy') {
|
||||
throw new ContainerNotReadyException(sprintf("Health check failed: %s", $containerStatus));
|
||||
}
|
||||
|
||||
usleep($this->pollInterval * 1000); // Sleep for the polling interval
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ class ContainerTest extends TestCase
|
||||
$container->withMySQLUser('bar', 'baz');
|
||||
|
||||
$container->run();
|
||||
die(123);
|
||||
|
||||
$pdo = new \PDO(
|
||||
sprintf('mysql:host=%s;port=3306', $container->getAddress()),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user