diff --git a/src/Container/Container.php b/src/Container/Container.php index 010d7b0..3e67d71 100644 --- a/src/Container/Container.php +++ b/src/Container/Container.php @@ -7,14 +7,20 @@ use Symfony\Component\Process\Process; use Testcontainer\Exception\ContainerNotReadyException; use Testcontainer\Registry; +use Testcontainer\Trait\DockerContainerAwareTrait; use Testcontainer\Wait\WaitForNothing; use Testcontainer\Wait\WaitInterface; /** - * @phpstan-type ContainerInspect array{0: array{NetworkSettings: array{IPAddress: string}}} + * @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 { + use DockerContainerAwareTrait; + private string $id; private ?string $entryPoint = null; @@ -57,6 +63,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; @@ -169,13 +180,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 ContainerInspect $inspectedData */ - $inspectedData = json_decode($inspect->getOutput(), true, 512, JSON_THROW_ON_ERROR); - - $this->inspectedData = $inspectedData; + $this->inspectedData = self::dockerContainerInspect($this->id); Registry::add($this); @@ -263,10 +268,10 @@ 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']; - } - - return $this->inspectedData[0]['NetworkSettings']['IPAddress']; + return self::dockerContainerAddress( + 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..274d91a --- /dev/null +++ b/src/Trait/DockerContainerAwareTrait.php @@ -0,0 +1,108 @@ +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/WaitForHttp.php b/src/Wait/WaitForHttp.php index 23a9a99..7de3f1e 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 = self::dockerContainerAddress(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 new file mode 100644 index 0000000..52e7d72 --- /dev/null +++ b/src/Wait/WaitForTcpPortOpen.php @@ -0,0 +1,34 @@ +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 2424196..551e531 100644 --- a/tests/Integration/WaitStrategyTest.php +++ b/tests/Integration/WaitStrategyTest.php @@ -9,13 +9,26 @@ use Predis\Connection\ConnectionException; 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; use Testcontainer\Wait\WaitForLog; +use Testcontainer\Wait\WaitForTcpPortOpen; class WaitStrategyTest extends TestCase { + use DockerContainerAwareTrait; + + public static function tearDownAfterClass(): void + { + parent::tearDownAfterClass(); + + Registry::cleanup(); + } + public function testWaitForExec(): void { $called = false; @@ -90,6 +103,42 @@ public function testWaitForHTTP(): void $this->assertNotEmpty($response); } + /** + * @dataProvider provideWaitForTcpPortOpen + */ + public function testWaitForTcpPortOpen(bool $wait): void + { + $container = Container::make('nginx:alpine'); + + if ($wait) { + $container->withWait(WaitForTcpPortOpen::make(80)); + } + + $container->run(); + + if ($wait) { + 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')