From 6c3c74c81721f2e9c0cc89b758efd72dc875f12a Mon Sep 17 00:00:00 2001 From: Nikola Petkanski Date: Tue, 16 Jul 2024 01:19:02 +0300 Subject: [PATCH 1/5] Support for waiting on tcp port to open --- src/Container/Container.php | 5 +++ src/Wait/WaitForTcpPortOpen.php | 55 ++++++++++++++++++++++++++ tests/Integration/WaitStrategyTest.php | 38 ++++++++++++++++++ 3 files changed, 98 insertions(+) create mode 100644 src/Wait/WaitForTcpPortOpen.php diff --git a/src/Container/Container.php b/src/Container/Container.php index 1317383..46776c1 100644 --- a/src/Container/Container.php +++ b/src/Container/Container.php @@ -56,6 +56,11 @@ public static function make(string $image): self return new Container($image); } + public function getId(): string + { + return $this->id; + } + public function withEntryPoint(string $entryPoint): self { $this->entryPoint = $entryPoint; diff --git a/src/Wait/WaitForTcpPortOpen.php b/src/Wait/WaitForTcpPortOpen.php new file mode 100644 index 0000000..14946ba --- /dev/null +++ b/src/Wait/WaitForTcpPortOpen.php @@ -0,0 +1,55 @@ + + */ +final class WaitForTcpPortOpen implements WaitInterface +{ + public function __construct(private readonly int $port) + { + } + + public static function make(int $port): self + { + return new self($port); + } + + /** + * @throws JsonException + */ + public function wait(string $id): void + { + if (@fsockopen($this->findContainerAddress($id), $this->port) === false) { + throw new ContainerNotReadyException($id, new RuntimeException('Unable to connect to container TCP port')); + } + } + + /** + * @throws JsonException + */ + private function findContainerAddress(string $id): string + { + $process = new Process(['docker', 'inspect', $id]); + $process->mustRun(); + + /** @var ContainerInspect $data */ + $data = json_decode($process->getOutput(), true, 512, JSON_THROW_ON_ERROR); + + $containerAddress = $data[0]['NetworkSettings']['IPAddress'] ?? null; + + if (! is_string($containerAddress)) { + throw new ContainerNotReadyException($id, new RuntimeException('Unable to find container IP address')); + } + + return $containerAddress; + } +} diff --git a/tests/Integration/WaitStrategyTest.php b/tests/Integration/WaitStrategyTest.php index 2424196..455e07b 100644 --- a/tests/Integration/WaitStrategyTest.php +++ b/tests/Integration/WaitStrategyTest.php @@ -9,10 +9,12 @@ use Predis\Connection\ConnectionException; use Symfony\Component\Process\Process; use Testcontainer\Container\Container; +use Testcontainer\Exception\ContainerNotReadyException; use Testcontainer\Wait\WaitForExec; use Testcontainer\Wait\WaitForHealthCheck; use Testcontainer\Wait\WaitForHttp; use Testcontainer\Wait\WaitForLog; +use Testcontainer\Wait\WaitForTcpPortOpen; class WaitStrategyTest extends TestCase { @@ -90,6 +92,42 @@ public function testWaitForHTTP(): void $this->assertNotEmpty($response); } + /** + * @dataProvider provideWaitForTcpPortOpen + */ + public function testWaitForTcpPortOpen(bool $canConnect): void + { + $container = Container::make('nginx:alpine'); + + if ($canConnect) { + $container->withWait(WaitForTcpPortOpen::make(80)); + } + + $container->run(); + + if ($canConnect) { + 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> + */ + public function provideWaitForTcpPortOpen(): array + { + return [ + 'Can connect to container' => [true], + 'Cannot connect to container' => [false], + ]; + } + public function testWaitForHealthCheck(): void { $container = Container::make('nginx') From 0edcaeec72b06c8a420be1b8be0837b3cd5858fa Mon Sep 17 00:00:00 2001 From: Nikola Petkanski Date: Tue, 16 Jul 2024 02:35:51 +0300 Subject: [PATCH 2/5] Make phpstan happy again --- src/Container/Container.php | 23 ++++++++++++++++++----- src/Wait/WaitForTcpPortOpen.php | 2 +- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/src/Container/Container.php b/src/Container/Container.php index 46776c1..634e159 100644 --- a/src/Container/Container.php +++ b/src/Container/Container.php @@ -9,9 +9,12 @@ use Testcontainer\Registry; use Testcontainer\Wait\WaitForNothing; use Testcontainer\Wait\WaitInterface; +use UnexpectedValueException; /** - * @phpstan-type ContainerInspect array{0: array{NetworkSettings: array{IPAddress: string}}} + * @phpstan-type ContainerInspectSingleNetwork array + * @phpstan-type ContainerInspectMultipleNetworks array}}> + * @phpstan-type ContainerInspect ContainerInspectSingleNetwork|ContainerInspectMultipleNetworks */ class Container { @@ -165,7 +168,7 @@ public function run(bool $wait = true): self $inspect = new Process(['docker', 'inspect', $this->id]); $inspect->mustRun(); - /** @var ContainerInspect $inspectedData */ + /** @var ContainerInspectSingleNetwork|ContainerInspectMultipleNetworks $inspectedData */ $inspectedData = json_decode($inspect->getOutput(), true, 512, JSON_THROW_ON_ERROR); $this->inspectedData = $inspectedData; @@ -256,10 +259,20 @@ public function logs(): string public function getAddress(): string { - if ($this->network !== null && !empty($this->inspectedData[0]['NetworkSettings']['Networks'][$this->network]['IPAddress'])) { - return $this->inspectedData[0]['NetworkSettings']['Networks'][$this->network]['IPAddress']; + if (is_string($this->network)) { + $containerAddress = $this->inspectedData[0]['NetworkSettings']['Networks'][$this->network]['IPAddress'] ?? null; + + if (is_string($containerAddress)) { + return $containerAddress; + } + } + + $containerAddress = $this->inspectedData[0]['NetworkSettings']['IPAddress'] ?? null; + + if (is_string($containerAddress)) { + return $containerAddress; } - return $this->inspectedData[0]['NetworkSettings']['IPAddress']; + throw new UnexpectedValueException('Unable to find container IP address'); } } diff --git a/src/Wait/WaitForTcpPortOpen.php b/src/Wait/WaitForTcpPortOpen.php index 14946ba..3c574cf 100644 --- a/src/Wait/WaitForTcpPortOpen.php +++ b/src/Wait/WaitForTcpPortOpen.php @@ -10,7 +10,7 @@ use Testcontainer\Exception\ContainerNotReadyException; /** - * @phpstan-type ContainerInspect array + * @phpstan-import-type ContainerInspect from \Testcontainer\Container\Container */ final class WaitForTcpPortOpen implements WaitInterface { From de59891641e3674e86ac09061b20d6f9d86aaec2 Mon Sep 17 00:00:00 2001 From: Nikola Petkanski Date: Tue, 16 Jul 2024 03:25:23 +0300 Subject: [PATCH 3/5] Code reuse on determining docker container address --- src/Container/Container.php | 24 ++++-------- src/Trait/DockerContainerAwareTrait.php | 50 +++++++++++++++++++++++++ src/Wait/WaitForHttp.php | 17 +++------ src/Wait/WaitForTcpPortOpen.php | 29 ++------------ 4 files changed, 67 insertions(+), 53 deletions(-) create mode 100644 src/Trait/DockerContainerAwareTrait.php diff --git a/src/Container/Container.php b/src/Container/Container.php index 634e159..de71085 100644 --- a/src/Container/Container.php +++ b/src/Container/Container.php @@ -7,9 +7,9 @@ use Symfony\Component\Process\Process; use Testcontainer\Exception\ContainerNotReadyException; use Testcontainer\Registry; +use Testcontainer\Trait\DockerContainerAwareTrait; use Testcontainer\Wait\WaitForNothing; use Testcontainer\Wait\WaitInterface; -use UnexpectedValueException; /** * @phpstan-type ContainerInspectSingleNetwork array @@ -18,6 +18,8 @@ */ class Container { + use DockerContainerAwareTrait; + private string $id; private ?string $entryPoint = null; @@ -259,20 +261,10 @@ public function logs(): string public function getAddress(): string { - if (is_string($this->network)) { - $containerAddress = $this->inspectedData[0]['NetworkSettings']['Networks'][$this->network]['IPAddress'] ?? null; - - if (is_string($containerAddress)) { - return $containerAddress; - } - } - - $containerAddress = $this->inspectedData[0]['NetworkSettings']['IPAddress'] ?? null; - - if (is_string($containerAddress)) { - return $containerAddress; - } - - throw new UnexpectedValueException('Unable to find container IP address'); + return $this->getContainerAddress( + containerId: $this->id, + networkName: $this->network, + inspectedData: $this->inspectedData + ); } } diff --git a/src/Trait/DockerContainerAwareTrait.php b/src/Trait/DockerContainerAwareTrait.php new file mode 100644 index 0000000..1d68db7 --- /dev/null +++ b/src/Trait/DockerContainerAwareTrait.php @@ -0,0 +1,50 @@ +mustRun(); + + /** @var ContainerInspect $inspectedData */ + $inspectedData = json_decode($process->getOutput(), true, 512, JSON_THROW_ON_ERROR); + } + + 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'); + } +} diff --git a/src/Wait/WaitForHttp.php b/src/Wait/WaitForHttp.php index 23a9a99..889bc61 100644 --- a/src/Wait/WaitForHttp.php +++ b/src/Wait/WaitForHttp.php @@ -4,14 +4,13 @@ namespace Testcontainer\Wait; -use Symfony\Component\Process\Process; use Testcontainer\Exception\ContainerNotReadyException; +use Testcontainer\Trait\DockerContainerAwareTrait; -/** - * @phpstan-import-type ContainerInspect from \Testcontainer\Container\Container - */ class WaitForHttp implements WaitInterface { + use DockerContainerAwareTrait; + public const METHOD_GET = 'GET'; public const METHOD_POST = 'POST'; public const METHOD_PUT = 'PUT'; @@ -59,16 +58,10 @@ public function withStatusCode(int $statusCode): self public function wait(string $id): void { - $process = new Process(['docker', 'inspect', $id]); - $process->mustRun(); - - /** @var ContainerInspect $data */ - $data = json_decode($process->getOutput(), true); - - $ip = $data[0]['NetworkSettings']['IPAddress']; + $containerAddress = $this->getContainerAddress(containerId: $id); $ch = curl_init(); - curl_setopt($ch, CURLOPT_URL, sprintf('http://%s:%d%s', $ip, $this->port, $this->path)); + curl_setopt($ch, CURLOPT_URL, sprintf('http://%s:%d%s', $containerAddress, $this->port, $this->path)); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $this->method); curl_setopt($ch, CURLOPT_HEADER, true); diff --git a/src/Wait/WaitForTcpPortOpen.php b/src/Wait/WaitForTcpPortOpen.php index 3c574cf..d55473d 100644 --- a/src/Wait/WaitForTcpPortOpen.php +++ b/src/Wait/WaitForTcpPortOpen.php @@ -6,14 +6,13 @@ use JsonException; use RuntimeException; -use Symfony\Component\Process\Process; use Testcontainer\Exception\ContainerNotReadyException; +use Testcontainer\Trait\DockerContainerAwareTrait; -/** - * @phpstan-import-type ContainerInspect from \Testcontainer\Container\Container - */ final class WaitForTcpPortOpen implements WaitInterface { + use DockerContainerAwareTrait; + public function __construct(private readonly int $port) { } @@ -28,28 +27,8 @@ public static function make(int $port): self */ public function wait(string $id): void { - if (@fsockopen($this->findContainerAddress($id), $this->port) === false) { + if (@fsockopen($this->getContainerAddress($id), $this->port) === false) { throw new ContainerNotReadyException($id, new RuntimeException('Unable to connect to container TCP port')); } } - - /** - * @throws JsonException - */ - private function findContainerAddress(string $id): string - { - $process = new Process(['docker', 'inspect', $id]); - $process->mustRun(); - - /** @var ContainerInspect $data */ - $data = json_decode($process->getOutput(), true, 512, JSON_THROW_ON_ERROR); - - $containerAddress = $data[0]['NetworkSettings']['IPAddress'] ?? null; - - if (! is_string($containerAddress)) { - throw new ContainerNotReadyException($id, new RuntimeException('Unable to find container IP address')); - } - - return $containerAddress; - } } From ed18c0b8f8777a42016cecb20d2ddf32f87b7de9 Mon Sep 17 00:00:00 2001 From: Nikola Petkanski Date: Tue, 16 Jul 2024 03:34:23 +0300 Subject: [PATCH 4/5] Code reuse on inspecting a docker container --- src/Container/Container.php | 8 +------- src/Trait/DockerContainerAwareTrait.php | 21 ++++++++++++++++----- 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/src/Container/Container.php b/src/Container/Container.php index de71085..f8d0470 100644 --- a/src/Container/Container.php +++ b/src/Container/Container.php @@ -167,13 +167,7 @@ public function run(bool $wait = true): self $this->process = new Process($params); $this->process->mustRun(); - $inspect = new Process(['docker', 'inspect', $this->id]); - $inspect->mustRun(); - - /** @var ContainerInspectSingleNetwork|ContainerInspectMultipleNetworks $inspectedData */ - $inspectedData = json_decode($inspect->getOutput(), true, 512, JSON_THROW_ON_ERROR); - - $this->inspectedData = $inspectedData; + $this->inspectedData = $this->getContainerInspect($this->id); Registry::add($this); diff --git a/src/Trait/DockerContainerAwareTrait.php b/src/Trait/DockerContainerAwareTrait.php index 1d68db7..f5d2c61 100644 --- a/src/Trait/DockerContainerAwareTrait.php +++ b/src/Trait/DockerContainerAwareTrait.php @@ -24,11 +24,7 @@ trait DockerContainerAwareTrait protected function getContainerAddress(string $containerId, ?string $networkName = null, ?array $inspectedData = null): string { if (! is_array($inspectedData)) { - $process = new Process(['docker', 'inspect', $containerId]); - $process->mustRun(); - - /** @var ContainerInspect $inspectedData */ - $inspectedData = json_decode($process->getOutput(), true, 512, JSON_THROW_ON_ERROR); + $inspectedData = $this->getContainerInspect($containerId); } if (is_string($networkName)) { @@ -47,4 +43,19 @@ protected function getContainerAddress(string $containerId, ?string $networkName throw new UnexpectedValueException('Unable to find container IP address'); } + + /** + * @param string $containerId + * @return ContainerInspect + * + * @throws JsonException + */ + protected function getContainerInspect(string $containerId): array + { + $process = new Process(['docker', 'inspect', $containerId]); + $process->mustRun(); + + /** @var ContainerInspect */ + return json_decode($process->getOutput(), true, 512, JSON_THROW_ON_ERROR); + } } From 11c3e21e92bc02aca3ee741cb50f34031050308b Mon Sep 17 00:00:00 2001 From: Nikola Petkanski Date: Tue, 16 Jul 2024 04:39:29 +0300 Subject: [PATCH 5/5] Code reuse on working with docker networks --- src/Container/Container.php | 5 ++- src/Trait/DockerContainerAwareTrait.php | 55 +++++++++++++++++++++++-- src/Wait/WaitForHttp.php | 2 +- src/Wait/WaitForTcpPortOpen.php | 8 ++-- tests/Integration/WaitStrategyTest.php | 17 ++++++-- 5 files changed, 73 insertions(+), 14 deletions(-) diff --git a/src/Container/Container.php b/src/Container/Container.php index f8d0470..d9f392b 100644 --- a/src/Container/Container.php +++ b/src/Container/Container.php @@ -15,6 +15,7 @@ * @phpstan-type ContainerInspectSingleNetwork array * @phpstan-type ContainerInspectMultipleNetworks array}}> * @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 { @@ -167,7 +168,7 @@ public function run(bool $wait = true): self $this->process = new Process($params); $this->process->mustRun(); - $this->inspectedData = $this->getContainerInspect($this->id); + $this->inspectedData = self::dockerContainerInspect($this->id); Registry::add($this); @@ -255,7 +256,7 @@ public function logs(): string public function getAddress(): string { - return $this->getContainerAddress( + return self::dockerContainerAddress( containerId: $this->id, networkName: $this->network, inspectedData: $this->inspectedData diff --git a/src/Trait/DockerContainerAwareTrait.php b/src/Trait/DockerContainerAwareTrait.php index f5d2c61..274d91a 100644 --- a/src/Trait/DockerContainerAwareTrait.php +++ b/src/Trait/DockerContainerAwareTrait.php @@ -6,10 +6,12 @@ use JsonException; use Symfony\Component\Process\Process; +use Testcontainer\Container\Container; use UnexpectedValueException; /** - * @phpstan-import-type ContainerInspect from \Testcontainer\Container\Container + * @phpstan-import-type ContainerInspect from Container + * @phpstan-import-type DockerNetwork from Container */ trait DockerContainerAwareTrait { @@ -21,10 +23,10 @@ trait DockerContainerAwareTrait * * @throws JsonException */ - protected function getContainerAddress(string $containerId, ?string $networkName = null, ?array $inspectedData = null): string + private static function dockerContainerAddress(string $containerId, ?string $networkName = null, ?array $inspectedData = null): string { if (! is_array($inspectedData)) { - $inspectedData = $this->getContainerInspect($containerId); + $inspectedData = self::dockerContainerInspect($containerId); } if (is_string($networkName)) { @@ -50,7 +52,7 @@ protected function getContainerAddress(string $containerId, ?string $networkName * * @throws JsonException */ - protected function getContainerInspect(string $containerId): array + private static function dockerContainerInspect(string $containerId): array { $process = new Process(['docker', 'inspect', $containerId]); $process->mustRun(); @@ -58,4 +60,49 @@ protected function getContainerInspect(string $containerId): array /** @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/WaitForHttp.php b/src/Wait/WaitForHttp.php index 889bc61..7de3f1e 100644 --- a/src/Wait/WaitForHttp.php +++ b/src/Wait/WaitForHttp.php @@ -58,7 +58,7 @@ public function withStatusCode(int $statusCode): self public function wait(string $id): void { - $containerAddress = $this->getContainerAddress(containerId: $id); + $containerAddress = self::dockerContainerAddress(containerId: $id); $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, sprintf('http://%s:%d%s', $containerAddress, $this->port, $this->path)); diff --git a/src/Wait/WaitForTcpPortOpen.php b/src/Wait/WaitForTcpPortOpen.php index d55473d..52e7d72 100644 --- a/src/Wait/WaitForTcpPortOpen.php +++ b/src/Wait/WaitForTcpPortOpen.php @@ -13,13 +13,13 @@ final class WaitForTcpPortOpen implements WaitInterface { use DockerContainerAwareTrait; - public function __construct(private readonly int $port) + public function __construct(private readonly int $port, private readonly ?string $network = null) { } - public static function make(int $port): self + public static function make(int $port, ?string $network = null): self { - return new self($port); + return new self($port, $network); } /** @@ -27,7 +27,7 @@ public static function make(int $port): self */ public function wait(string $id): void { - if (@fsockopen($this->getContainerAddress($id), $this->port) === false) { + 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')); } } diff --git a/tests/Integration/WaitStrategyTest.php b/tests/Integration/WaitStrategyTest.php index 455e07b..551e531 100644 --- a/tests/Integration/WaitStrategyTest.php +++ b/tests/Integration/WaitStrategyTest.php @@ -10,6 +10,8 @@ use Symfony\Component\Process\Process; use Testcontainer\Container\Container; use Testcontainer\Exception\ContainerNotReadyException; +use Testcontainer\Registry; +use Testcontainer\Trait\DockerContainerAwareTrait; use Testcontainer\Wait\WaitForExec; use Testcontainer\Wait\WaitForHealthCheck; use Testcontainer\Wait\WaitForHttp; @@ -18,6 +20,15 @@ class WaitStrategyTest extends TestCase { + use DockerContainerAwareTrait; + + public static function tearDownAfterClass(): void + { + parent::tearDownAfterClass(); + + Registry::cleanup(); + } + public function testWaitForExec(): void { $called = false; @@ -95,17 +106,17 @@ public function testWaitForHTTP(): void /** * @dataProvider provideWaitForTcpPortOpen */ - public function testWaitForTcpPortOpen(bool $canConnect): void + public function testWaitForTcpPortOpen(bool $wait): void { $container = Container::make('nginx:alpine'); - if ($canConnect) { + if ($wait) { $container->withWait(WaitForTcpPortOpen::make(80)); } $container->run(); - if ($canConnect) { + if ($wait) { static::assertIsResource(fsockopen($container->getAddress(), 80), 'Failed to connect to container'); return; }