From 1cbfe638b99c4e419d9e3507d039b846b781d67a Mon Sep 17 00:00:00 2001 From: Sergei Shitikov Date: Thu, 24 Oct 2024 19:36:48 +0200 Subject: [PATCH 1/4] implement WaitForHttp and WaitForHostPort strategies --- src/Wait/BaseWaitStrategy.php | 12 ++ src/Wait/WaitForHostPort.php | 51 ++++++ src/Wait/WaitForHttp.php | 155 ++++++++++++------ src/Wait/WaitForTcpPortOpen.php | 43 ++--- .../Integration/OldTests/WaitStrategyTest.php | 48 ++---- 5 files changed, 195 insertions(+), 114 deletions(-) create mode 100644 src/Wait/WaitForHostPort.php diff --git a/src/Wait/BaseWaitStrategy.php b/src/Wait/BaseWaitStrategy.php index 80ff748..df4da26 100644 --- a/src/Wait/BaseWaitStrategy.php +++ b/src/Wait/BaseWaitStrategy.php @@ -13,4 +13,16 @@ abstract class BaseWaitStrategy implements WaitStrategy } abstract public function wait(StartedTestContainer $container): void; + + public function withTimeout(int $timeout): static + { + $this->timeout = $timeout; + return $this; + } + + public function withPollInterval(int $pollInterval): static + { + $this->pollInterval = $pollInterval; + return $this; + } } diff --git a/src/Wait/WaitForHostPort.php b/src/Wait/WaitForHostPort.php new file mode 100644 index 0000000..d9f7f86 --- /dev/null +++ b/src/Wait/WaitForHostPort.php @@ -0,0 +1,51 @@ +getHost(); + + while (true) { + $elapsedTime = (microtime(true) * 1000) - $startTime; + + if ($elapsedTime > $this->timeout) { + throw new ContainerWaitingTimeoutException($container->getId()); + } + + if ($this->isPortOpen($containerAddress, $this->port)) { + return; // Port is open, container is ready + } + + usleep($this->pollInterval * 1000); // Wait for the next polling interval + } + } + + private function isPortOpen(string $ipAddress, int $port): bool + { + $connection = @fsockopen($ipAddress, $port, $errno, $errstr, 2); + + if ($connection !== false) { + fclose($connection); + return true; + } + + return false; + } +} diff --git a/src/Wait/WaitForHttp.php b/src/Wait/WaitForHttp.php index 87a840b..dac2b0c 100644 --- a/src/Wait/WaitForHttp.php +++ b/src/Wait/WaitForHttp.php @@ -4,83 +4,146 @@ declare(strict_types=1); namespace Testcontainers\Wait; -use Docker\Docker; -use Testcontainers\Exception\ContainerNotReadyException; +use Testcontainers\Container\HttpMethod; +use Testcontainers\Container\StartedTestContainer; +use Testcontainers\Exception\ContainerWaitingTimeoutException; -//TODO: not ready yet -class WaitForHttp implements WaitStrategy +class WaitForHttp extends BaseWaitStrategy { - public const METHOD_GET = 'GET'; - public const METHOD_POST = 'POST'; - public const METHOD_PUT = 'PUT'; - public const METHOD_DELETE = 'DELETE'; - public const METHOD_HEAD = 'HEAD'; - public const METHOD_OPTIONS = 'OPTIONS'; + protected HttpMethod $method = HttpMethod::GET; + protected string $path = '/'; - private string $method = 'GET'; - private string $path = '/'; - private int $statusCode = 200; - private Docker $dockerClient; + protected string $protocol = 'http'; - public function __construct(private int $port) - { - $this->dockerClient = Docker::create(); - } + protected int $expectedStatusCode = 200; - public static function make(int $port): self - { - return new WaitForHttp($port); + protected bool $allowInsecure = false; + + /** + * @var array + */ + protected array $headers = []; + + /** + * @var int Timeout in milliseconds for reading the response + */ + protected int $readTimeout = 1000; + + public function __construct( + protected int $port, + int $timeout = 10000, + int $pollInterval = 500 + ) { + parent::__construct($timeout, $pollInterval); } /** - * @param WaitForHttp::METHOD_* $method + * @deprecated Use constructor instead + * Kept for backward compatibility + * Should be removed in next major version */ - public function withMethod(string $method): self + public static function make(int $port): self { - $this->method = $method; + return new self($port); + } + public function withMethod(HttpMethod | string $method): self + { + if (is_string($method)) { + $method = HttpMethod::fromString($method); + } + $this->method = $method; return $this; } public function withPath(string $path): self { $this->path = $path; - return $this; } - public function withStatusCode(int $statusCode): self + public function withExpectedStatusCode(int $statusCode): self { - $this->statusCode = $statusCode; - + $this->expectedStatusCode = $statusCode; return $this; } - public function wait(string $id): void + public function usingHttps(): self { - $containerNetworks = $this->dockerClient->containerInspect($id)->getNetworkSettings()->getNetworks(); - $containerAddress = null; - foreach ($containerNetworks as $network) { - if ($network->getNetworkID() === $id) { - $containerAddress = $network->getIpAddress(); - break; + $this->protocol = 'https'; + return $this; + } + + public function allowInsecure(): self + { + $this->allowInsecure = true; + return $this; + } + + public function withReadTimeout(int $timeout): self + { + $this->readTimeout = $timeout; + return $this; + } + + /** + * @param array $headers + */ + public function withHeaders(array $headers): self + { + $this->headers = $headers; + return $this; + } + + public function wait(StartedTestContainer $container): void + { + $startTime = microtime(true) * 1000; + + while (true) { + $elapsedTime = (microtime(true) * 1000) - $startTime; + + if ($elapsedTime > $this->timeout) { + throw new ContainerWaitingTimeoutException($container->getId()); } + + $containerAddress = $container->getHost(); + + $url = sprintf('%s://%s:%d%s', $this->protocol, $containerAddress, $this->port, $this->path); + $responseCode = $this->makeHttpRequest($url); + + if ($responseCode === $this->expectedStatusCode) { + return; // Container is ready + } + + usleep($this->pollInterval * 1000); + } + } + + private function makeHttpRequest(string $url): int + { + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, $url); + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $this->method->value); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_HEADER, true); + curl_setopt($ch, CURLOPT_NOBODY, true); // No need for response body, just headers + curl_setopt($ch, CURLOPT_TIMEOUT_MS, $this->readTimeout); + + // Allow insecure connections if requested + if ($this->allowInsecure) { + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); } - $ch = curl_init(); - 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); - curl_setopt($ch, CURLOPT_NOBODY, true); + // Add custom headers + if (!empty($this->headers)) { + curl_setopt($ch, CURLOPT_HTTPHEADER, array_map(static fn ($k, $v) => "$k: $v", array_keys($this->headers), $this->headers)); + } curl_exec($ch); - - if (curl_getinfo($ch, CURLINFO_HTTP_CODE) !== $this->statusCode) { - throw new ContainerNotReadyException($id, new \RuntimeException('HTTP status code does not match')); - } - + $responseCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); + + return $responseCode; } } diff --git a/src/Wait/WaitForTcpPortOpen.php b/src/Wait/WaitForTcpPortOpen.php index 9545a88..a65cebe 100644 --- a/src/Wait/WaitForTcpPortOpen.php +++ b/src/Wait/WaitForTcpPortOpen.php @@ -4,42 +4,23 @@ declare(strict_types=1); namespace Testcontainers\Wait; -use Docker\Docker; -use JsonException; -use RuntimeException; -use Testcontainers\Exception\ContainerNotReadyException; - -//TODO: not ready yet -final class WaitForTcpPortOpen implements WaitStrategy +/** + * @deprecated Use WaitForHostPort instead + * Kept for backward compatibility + * Should be removed in next major version + */ +final class WaitForTcpPortOpen extends WaitForHostPort { - private Docker $dockerClient; - - public function __construct(private readonly int $port, private readonly ?string $network = null) + /** + * @phpstan-ignore-next-line + */ + public function __construct(int $port, string $network = null) { - $this->dockerClient = Docker::create(); + parent::__construct($port); } public static function make(int $port, ?string $network = null): self { - return new self($port, $network); - } - - /** - * @throws JsonException - */ - public function wait(string $id): void - { - $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')); - } + return new self($port); } } diff --git a/tests/Integration/OldTests/WaitStrategyTest.php b/tests/Integration/OldTests/WaitStrategyTest.php index 52a6d47..25ff3ec 100644 --- a/tests/Integration/OldTests/WaitStrategyTest.php +++ b/tests/Integration/OldTests/WaitStrategyTest.php @@ -21,12 +21,6 @@ use Testcontainers\Wait\WaitForTcpPortOpen; */ class WaitStrategyTest extends TestCase { - //TODO: remove after check - protected function setUp(): void - { - $this->markTestIncomplete(); - } - public function testWaitForExec(): void { $container = MySQLContainer::make() @@ -86,12 +80,13 @@ class WaitStrategyTest extends TestCase public function testWaitForHTTP(): void { $container = Container::make('nginx:alpine') - ->withWait(WaitForHttp::make(80)); + ->withWait(WaitForHttp::make(3000)) + ->withPort('3000', '80'); $container->run(); $ch = curl_init(); - curl_setopt($ch, CURLOPT_URL, sprintf('http://%s:%d', $container->getAddress(), 80)); + curl_setopt($ch, CURLOPT_URL, sprintf('http://%s:%d', $container->getAddress(), $container->getPort())); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); $response = (string) curl_exec($ch); @@ -99,42 +94,21 @@ class WaitStrategyTest extends TestCase curl_close($ch); $this->assertNotEmpty($response); + + $container->stop(); } - /** - * @dataProvider provideWaitForTcpPortOpen - */ - public function testWaitForTcpPortOpen(bool $wait): void + public function testWaitForTcpPortOpen(): void { - $container = Container::make('nginx:alpine'); - - if ($wait) { - $container->withWait(WaitForTcpPortOpen::make(80)); - } + $container = Container::make('nginx:alpine') + ->withWait(WaitForTcpPortOpen::make(80)) + ->withPort('80', '80'); $container->run(); - if ($wait) { - static::assertIsResource(fsockopen($container->getAddress(), 80), 'Failed to connect to container'); - return; - } + static::assertIsResource(fsockopen($container->getAddress(), 80), 'Failed to connect to container'); - $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], - ]; + $container->stop(); } public function testWaitForHealthCheck(): void From 5b42abf081c4aa34336490532a14691fe90d3cc6 Mon Sep 17 00:00:00 2001 From: Sergei Shitikov Date: Thu, 24 Oct 2024 19:55:06 +0200 Subject: [PATCH 2/4] Added missing HttpMethod enum --- src/Container/HttpMethod.php | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 src/Container/HttpMethod.php diff --git a/src/Container/HttpMethod.php b/src/Container/HttpMethod.php new file mode 100644 index 0000000..628f5d4 --- /dev/null +++ b/src/Container/HttpMethod.php @@ -0,0 +1,28 @@ + self::GET, + 'POST' => self::POST, + 'PUT' => self::PUT, + 'DELETE' => self::DELETE, + 'HEAD' => self::HEAD, + 'OPTIONS' => self::OPTIONS, + default => throw new \InvalidArgumentException("Invalid HTTP method: $method"), + }; + } +} From 662acfefa102edb63cbe9e2cf3b754b63edc7584 Mon Sep 17 00:00:00 2001 From: Sergei Shitikov Date: Sun, 27 Oct 2024 21:32:47 +0100 Subject: [PATCH 3/4] Update src/Container/HttpMethod.php improve fromString Co-authored-by: Jacob Dreesen --- src/Container/HttpMethod.php | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/src/Container/HttpMethod.php b/src/Container/HttpMethod.php index 628f5d4..4950c16 100644 --- a/src/Container/HttpMethod.php +++ b/src/Container/HttpMethod.php @@ -15,14 +15,6 @@ enum HttpMethod: string public static function fromString(string $method): self { - return match (strtoupper($method)) { - 'GET' => self::GET, - 'POST' => self::POST, - 'PUT' => self::PUT, - 'DELETE' => self::DELETE, - 'HEAD' => self::HEAD, - 'OPTIONS' => self::OPTIONS, - default => throw new \InvalidArgumentException("Invalid HTTP method: $method"), - }; + return self::tryFrom(strtoupper($method)) ?? throw new \InvalidArgumentException("Invalid HTTP method: $method"); } } From 7d5f52b6d52f8ac21c20ebb1b45b53cf419fd6a7 Mon Sep 17 00:00:00 2001 From: Sergei Shitikov Date: Sun, 27 Oct 2024 21:57:24 +0100 Subject: [PATCH 4/4] restore the doc-block for withMethod() in WaitForHttp --- src/Wait/WaitForHttp.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Wait/WaitForHttp.php b/src/Wait/WaitForHttp.php index dac2b0c..3ae06c2 100644 --- a/src/Wait/WaitForHttp.php +++ b/src/Wait/WaitForHttp.php @@ -48,6 +48,9 @@ class WaitForHttp extends BaseWaitStrategy return new self($port); } + /** + * @param HttpMethod|value-of $method + */ public function withMethod(HttpMethod | string $method): self { if (is_string($method)) {