From c82e974ab9ab7a52de5781f33bef72b6f448bc22 Mon Sep 17 00:00:00 2001 From: Sergei Shitikov Date: Thu, 22 Aug 2024 16:41:51 +0200 Subject: [PATCH] added beluga-php/docker-php client and some basic updates to the base Container class --- composer.json | 6 +- src/Container/Container.php | 188 ++++++++++++------------- src/Wait/WaitForExec.php | 30 ++-- src/Wait/WaitForHealthCheck.php | 22 ++- src/Wait/WaitForHttp.php | 15 +- src/Wait/WaitForLog.php | 9 +- src/Wait/WaitForTcpPortOpen.php | 16 ++- tests/Integration/WaitStrategyTest.php | 1 - 8 files changed, 154 insertions(+), 133 deletions(-) diff --git a/composer.json b/composer.json index dc070bd..1925555 100644 --- a/composer.json +++ b/composer.json @@ -15,7 +15,8 @@ ], "require": { "php": ">= 8.1", - "symfony/process": "^5.0|^6.0|^7.0" + "beluga-php/docker-php": "^1.45", + "symfony/http-client": "^7.1" }, "require-dev": { "phpunit/phpunit": "^9.5", @@ -44,7 +45,8 @@ }, "config": { "allow-plugins": { - "phpstan/extension-installer": true + "phpstan/extension-installer": true, + "php-http/discovery": true } } } diff --git a/src/Container/Container.php b/src/Container/Container.php index 0ea8ebf..c7fac17 100644 --- a/src/Container/Container.php +++ b/src/Container/Container.php @@ -4,11 +4,17 @@ declare(strict_types=1); namespace Testcontainers\Container; -use Symfony\Component\Process\Process; +use Docker\API\Model\ContainersCreatePostBody; +use Docker\API\Model\ContainersIdExecPostBody; +use Docker\API\Model\EndpointSettings; +use Docker\API\Model\HealthConfig; +use Docker\API\Model\Mount; +use Docker\API\Model\NetworkingConfig; +use Docker\API\Model\Port; +use Docker\Docker; +use Psr\Http\Message\ResponseInterface; use Testcontainers\Exception\ContainerNotReadyException; use Testcontainers\Registry; -use Testcontainers\Trait\DockerContainerAwareTrait; -use Testcontainers\Wait\WaitForNothing; use Testcontainers\Wait\WaitInterface; /** @@ -19,43 +25,44 @@ use Testcontainers\Wait\WaitInterface; */ class Container { - use DockerContainerAwareTrait; + protected Docker $dockerClient; - private string $id; + protected ContainersCreatePostBody $containerConfig; - private ?string $entryPoint = null; + protected string $image; + + protected string $containerName; + + protected string $id; + + protected ?string $entryPoint = null; + + protected ?HealthConfig $healthConfig = null; /** * @var array */ - private array $env = []; + protected array $env = []; - private Process $process; - private WaitInterface $wait; + protected WaitInterface $wait; - private bool $privileged = false; - private ?string $network = null; - private ?string $healthCheckCommand = null; - private int $healthCheckIntervalInMS; + protected bool $privileged = false; + protected ?string $networkName = null; /** - * @var ContainerInspect + * @var array */ - private array $inspectedData; + protected array $mounts = []; /** - * @var array + * @var array */ - private array $mounts = []; + protected array $ports = []; - /** - * @var array - */ - private array $ports = []; - - protected function __construct(private string $image) + protected function __construct(string $image) { - $this->wait = new WaitForNothing(); + $this->image = $image; + $this->dockerClient = Docker::create(); } public static function make(string $image): self @@ -98,24 +105,24 @@ class Container public function withHealthCheckCommand(string $command, int $healthCheckIntervalInMS = 1000): self { - $this->healthCheckCommand = $command; - $this->healthCheckIntervalInMS = $healthCheckIntervalInMS; + $this->healthConfig = new HealthConfig([ + 'Test' => ['CMD', $command], + 'Interval' => $healthCheckIntervalInMS, + ]); return $this; } public function withMount(string $localPath, string $containerPath): self { - $this->mounts[] = '-v'; - $this->mounts[] = sprintf('%s:%s', $localPath, $containerPath); + $this->mounts[] = new Mount(['type' => 'bind', 'source' => $localPath, 'target' => $containerPath]); return $this; } public function withPort(string $localPort, string $containerPort): self { - $this->ports[] = '-p'; - $this->ports[] = sprintf('%s:%s', $localPort, $containerPort); + $this->ports[] = new Port(['privatePort' => (int) $containerPort, 'publicPort' => (int) $localPort]); return $this; } @@ -127,60 +134,52 @@ class Container return $this; } - public function withNetwork(string $network): self + public function withNetwork(string $networkName): self { - $this->network = $network; + $this->networkName = $networkName; return $this; } public function run(bool $wait = true): self { - $this->id = uniqid('testcontainer', true); + $this->containerName = uniqid('testcontainer', true); - $params = [ - 'docker', - 'run', - '--rm', - '--detach', - '--name', - $this->id, - ...$this->mounts, - ...$this->ports, - ]; + $this->containerConfig = new ContainersCreatePostBody(); + $this->containerConfig->setImage($this->image); + $envs = []; foreach ($this->env as $name => $value) { - $params[] = '--env'; - $params[] = $name . '=' . $value; + $envs[] = $name . '=' . $value; } - if ($this->healthCheckCommand !== null) { - $params[] = '--health-cmd'; - $params[] = $this->healthCheckCommand; - $params[] = '--health-interval'; - $params[] = $this->healthCheckIntervalInMS . 'ms'; + $this->containerConfig->setEnv($envs); + + if ($this->healthConfig !== null) { + $this->containerConfig->setHealthcheck($this->healthConfig); } - if ($this->network !== null) { - $params[] = '--network'; - $params[] = $this->network; + if ($this->networkName !== null) { + $this->containerConfig->setNetworkingConfig(new NetworkingConfig([ + 'endpointsConfig' => [ + $this->networkName => new EndpointSettings([ + 'aliases' => [$this->containerName], + 'networkID' => $this->networkName, + ]), + ]])); } if ($this->entryPoint !== null) { - $params[] = '--entrypoint'; - $params[] = $this->entryPoint; + $this->containerConfig->setEntrypoint([$this->entryPoint]); } if ($this->privileged) { - $params[] = '--privileged'; + //TODO: Implement privileged mode } - $params[] = $this->image; + $containerCreateResponse = $this->dockerClient->containerCreate($this->containerConfig, ['name' => $this->containerName]); - $this->process = new Process($params); - $this->process->mustRun(); - - $this->inspectedData = self::dockerContainerInspect($this->id); + $this->id = $containerCreateResponse->getId(); Registry::add($this); @@ -193,46 +192,45 @@ class Container 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); - } - } + usleep(500000); + return $this; - throw new ContainerNotReadyException($this->id); +// 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 { - $stop = new Process(['docker', 'stop', $this->id]); - $stop->mustRun(); + $this->dockerClient->containerStop($this->id); return $this; } public function start(): self { - $start = new Process(['docker', 'start', $this->id]); - $start->mustRun(); + $this->dockerClient->containerStart($this->id); return $this; } public function restart(): self { - $restart = new Process(['docker', 'restart', $this->id]); - $restart->mustRun(); + $this->dockerClient->containerRestart($this->id); return $this; } public function remove(): self { - $remove = new Process(['docker', 'rm', '-f', $this->id]); - $remove->mustRun(); + $this->dockerClient->containerDelete($this->id); Registry::remove($this); @@ -241,37 +239,37 @@ class Container public function kill(): self { - $kill = new Process(['docker', 'kill', $this->id]); - $kill->mustRun(); + $this->dockerClient->containerKill($this->id); return $this; } /** - * @param array $command + * @param array $commandAsArray */ - public function execute(array $command): Process + public function execute(array $commandAsArray): ResponseInterface { - $process = new Process(['docker', 'exec', $this->id, ...$command]); - $process->mustRun(); - - return $process; + $command = new ContainersIdExecPostBody(); + $command->setCmd($commandAsArray); + return $this->dockerClient->containerExec($this->id, $command); } public function logs(): string { - $logs = new Process(['docker', 'logs', $this->id]); - $logs->mustRun(); - - return $logs->getOutput(); + return $this->dockerClient->containerLogs($this->id)?->getBody()?->getContents() ?? ''; } public function getAddress(): string { - return self::dockerContainerAddress( - containerId: $this->id, - networkName: $this->network, - inspectedData: $this->inspectedData - ); + $containerNetworks = $this->dockerClient->containerInspect($this->id) + ->getNetworkSettings()->getNetworks(); + $containerAddress = ''; + foreach ($containerNetworks as $network) { + if($network->getNetworkID() === $this->id) { + $containerAddress = $network->getIpAddress(); + break; + } + } + return $containerAddress; } } diff --git a/src/Wait/WaitForExec.php b/src/Wait/WaitForExec.php index 0c5c5c9..f4c0f0f 100644 --- a/src/Wait/WaitForExec.php +++ b/src/Wait/WaitForExec.php @@ -5,31 +5,35 @@ declare(strict_types=1); namespace Testcontainers\Wait; use Closure; -use Symfony\Component\Process\Process; +use Docker\API\Model\ContainersIdExecPostBody; +use Docker\API\Model\ExecIdStartPostBody; +use Docker\Docker; use Testcontainers\Exception\ContainerNotReadyException; class WaitForExec implements WaitInterface { + protected Docker $dockerClient; + + protected ContainersIdExecPostBody $execConfig; + /** * @param array $command */ public function __construct(private array $command, private ?Closure $checkFunction = null) { + $this->dockerClient = Docker::create(); + $execConfig = new ContainersIdExecPostBody(); + $execConfig->setTty(true); + $execConfig->setAttachStdout(true); + $execConfig->setAttachStderr(true); + $execConfig->setCmd($this->command); } public function wait(string $id): void { - $process = new Process(['docker', 'exec', $id, ...$this->command]); - - try { - $process->mustRun(); - } catch (\Exception $e) { - throw new ContainerNotReadyException($id, $e); - } - - if ($this->checkFunction !== null) { - $func = $this->checkFunction; - $func($process); - } + $execid = $this->dockerClient->containerExec($id, $this->execConfig)->getId() ?? ''; + $execStartConfig = new ExecIdStartPostBody(); + $execStartConfig->setDetach(false); + $this->dockerClient->execStart($execid, $execStartConfig); } } diff --git a/src/Wait/WaitForHealthCheck.php b/src/Wait/WaitForHealthCheck.php index 2836346..f658c10 100644 --- a/src/Wait/WaitForHealthCheck.php +++ b/src/Wait/WaitForHealthCheck.php @@ -4,24 +4,22 @@ declare(strict_types=1); namespace Testcontainers\Wait; -use RuntimeException; -use Symfony\Component\Process\Process; +use Docker\Docker; use Testcontainers\Exception\ContainerNotReadyException; class WaitForHealthCheck implements WaitInterface { + protected Docker $dockerClient; + + public function __construct() + { + $this->dockerClient = Docker::create(); + } public function wait(string $id): void { - $process = new Process(['docker', 'inspect', '--format', '{{json .State.Health.Status}}', $id]); - $process->mustRun(); - - $status = json_decode($process->getOutput(), true, 512, JSON_THROW_ON_ERROR); - - if (!is_string($status)) { - throw new ContainerNotReadyException($id, new RuntimeException('Invalid json output')); - } - - $status = trim($status, '"'); + $containerInspect = $this->dockerClient->containerInspect($id); + $containerInspect->getBody()->getContents(); + dd($containerInspect->getStatusCode()); if ($status !== 'healthy') { throw new ContainerNotReadyException($id); diff --git a/src/Wait/WaitForHttp.php b/src/Wait/WaitForHttp.php index d65ff04..f8927e9 100644 --- a/src/Wait/WaitForHttp.php +++ b/src/Wait/WaitForHttp.php @@ -4,13 +4,11 @@ declare(strict_types=1); namespace Testcontainers\Wait; +use Docker\Docker; use Testcontainers\Exception\ContainerNotReadyException; -use Testcontainers\Trait\DockerContainerAwareTrait; class WaitForHttp implements WaitInterface { - use DockerContainerAwareTrait; - public const METHOD_GET = 'GET'; public const METHOD_POST = 'POST'; public const METHOD_PUT = 'PUT'; @@ -22,9 +20,11 @@ class WaitForHttp implements WaitInterface private string $method = 'GET'; private string $path = '/'; private int $statusCode = 200; + private Docker $dockerClient; public function __construct(private int $port) { + $this->dockerClient = Docker::create(); } public static function make(int $port): self @@ -58,7 +58,14 @@ class WaitForHttp implements WaitInterface public function wait(string $id): void { - $containerAddress = self::dockerContainerAddress(containerId: $id); + $containerNetworks = $this->dockerClient->containerInspect($id)->getNetworkSettings()->getNetworks(); + $containerAddress = null; + foreach ($containerNetworks as $network) { + if($network->getNetworkID() === $id) { + $containerAddress = $network->getIpAddress(); + break; + } + } $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, sprintf('http://%s:%d%s', $containerAddress, $this->port, $this->path)); diff --git a/src/Wait/WaitForLog.php b/src/Wait/WaitForLog.php index 63f5ace..8d70b15 100644 --- a/src/Wait/WaitForLog.php +++ b/src/Wait/WaitForLog.php @@ -4,21 +4,24 @@ declare(strict_types=1); namespace Testcontainers\Wait; +use Docker\Docker; use Symfony\Component\Process\Process; use Testcontainers\Exception\ContainerNotReadyException; class WaitForLog implements WaitInterface { + protected Docker $dockerClient; + public function __construct(private string $message, private bool $enableRegex = false) { + $this->dockerClient = Docker::create(); } public function wait(string $id): void { - $process = new Process(['docker', 'logs', $id]); - $process->mustRun(); + $logs = $this->dockerClient->containerLogs($id); - $output = $process->getOutput() . PHP_EOL . $process->getErrorOutput(); + $output = $logs->getBody()->getContents(); if ($this->enableRegex) { if (!preg_match($this->message, $output)) { diff --git a/src/Wait/WaitForTcpPortOpen.php b/src/Wait/WaitForTcpPortOpen.php index 4c89828..6fea945 100644 --- a/src/Wait/WaitForTcpPortOpen.php +++ b/src/Wait/WaitForTcpPortOpen.php @@ -4,17 +4,18 @@ declare(strict_types=1); namespace Testcontainers\Wait; +use Docker\Docker; use JsonException; use RuntimeException; use Testcontainers\Exception\ContainerNotReadyException; -use Testcontainers\Trait\DockerContainerAwareTrait; final class WaitForTcpPortOpen implements WaitInterface { - use DockerContainerAwareTrait; + private Docker $dockerClient; public function __construct(private readonly int $port, private readonly ?string $network = null) { + $this->dockerClient = Docker::create(); } public static function make(int $port, ?string $network = null): self @@ -27,7 +28,16 @@ final class WaitForTcpPortOpen implements WaitInterface */ public function wait(string $id): void { - if (@fsockopen(self::dockerContainerAddress(containerId: $id, networkName: $this->network), $this->port) === false) { + $containerInspectResult = $this->dockerClient->containerInspect($id); + $dockerContainerNetworks = $containerInspectResult->getNetworkSettings()->getNetworks(); + $dockerContainerAddress = ''; + foreach ($dockerContainerNetworks as $network) { + if ($network->getNetworkID() === $this->network) { + $dockerContainerAddress = $network->getIPAddress(); + break; + } + } + if (@fsockopen($dockerContainerAddress, $this->port) === false) { throw new ContainerNotReadyException($id, new RuntimeException('Unable to connect to container TCP port')); } } diff --git a/tests/Integration/WaitStrategyTest.php b/tests/Integration/WaitStrategyTest.php index 09abdf7..8bf14ca 100644 --- a/tests/Integration/WaitStrategyTest.php +++ b/tests/Integration/WaitStrategyTest.php @@ -20,7 +20,6 @@ use Testcontainers\Wait\WaitForTcpPortOpen; class WaitStrategyTest extends TestCase { - use DockerContainerAwareTrait; public static function tearDownAfterClass(): void {