From b7a274b1d92f7e7fbab9d290821fef44f612b1fa Mon Sep 17 00:00:00 2001 From: Sergei Shitikov Date: Thu, 29 Aug 2024 18:01:04 +0200 Subject: [PATCH] first working version with simple WaitForContainerRunning implementation --- src/Container/Container.php | 14 ++ src/Container/GenericContainer.php | 227 ++++++++++++++--------- src/Registry.php | 6 +- src/Trait/DockerContainerAwareTrait.php | 108 ----------- src/Wait/WaitForContainerRunning.php | 47 +++++ src/Wait/WaitForHealthCheck.php | 38 +++- tests/Integration/ContainerTest.php | 1 + tests/Integration/RedisContainerTest.php | 36 ++++ 8 files changed, 276 insertions(+), 201 deletions(-) create mode 100644 src/Container/Container.php delete mode 100644 src/Trait/DockerContainerAwareTrait.php create mode 100644 src/Wait/WaitForContainerRunning.php create mode 100644 tests/Integration/RedisContainerTest.php diff --git a/src/Container/Container.php b/src/Container/Container.php new file mode 100644 index 0000000..0134203 --- /dev/null +++ b/src/Container/Container.php @@ -0,0 +1,14 @@ + - */ - protected array $ports = []; + /** @var array 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 $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 $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 $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} + */ + 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, + ]; } } diff --git a/src/Registry.php b/src/Registry.php index 07a6cbe..7ca9837 100644 --- a/src/Registry.php +++ b/src/Registry.php @@ -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(); +// } } } diff --git a/src/Trait/DockerContainerAwareTrait.php b/src/Trait/DockerContainerAwareTrait.php deleted file mode 100644 index f96209d..0000000 --- a/src/Trait/DockerContainerAwareTrait.php +++ /dev/null @@ -1,108 +0,0 @@ -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 $output */ - $output = json_decode($json, true, 512, JSON_THROW_ON_ERROR); - - /** @var array $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(); - } -} diff --git a/src/Wait/WaitForContainerRunning.php b/src/Wait/WaitForContainerRunning.php new file mode 100644 index 0000000..fd394ca --- /dev/null +++ b/src/Wait/WaitForContainerRunning.php @@ -0,0 +1,47 @@ +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); + } + } +} diff --git a/src/Wait/WaitForHealthCheck.php b/src/Wait/WaitForHealthCheck.php index f658c10..995b99d 100644 --- a/src/Wait/WaitForHealthCheck.php +++ b/src/Wait/WaitForHealthCheck.php @@ -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 } } } diff --git a/tests/Integration/ContainerTest.php b/tests/Integration/ContainerTest.php index 30de7b2..994fa33 100644 --- a/tests/Integration/ContainerTest.php +++ b/tests/Integration/ContainerTest.php @@ -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()), diff --git a/tests/Integration/RedisContainerTest.php b/tests/Integration/RedisContainerTest.php new file mode 100644 index 0000000..c154c25 --- /dev/null +++ b/tests/Integration/RedisContainerTest.php @@ -0,0 +1,36 @@ +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(); + } +}