From 1c928cc7677b96f24cafc28731286f7545a8b4ac Mon Sep 17 00:00:00 2001 From: Sergei Shitikov Date: Mon, 3 Feb 2025 20:47:02 +0100 Subject: [PATCH] Adjust StartedGenericContainer --- src/Container/StartedGenericContainer.php | 77 +++++-- src/Modules/MySQLContainer.php | 2 - tests/Integration/ContainerTestCase.php | 4 +- .../StartedGenericContainerTest.php | 207 ++++++++++++++++++ 4 files changed, 271 insertions(+), 19 deletions(-) create mode 100644 tests/Integration/StartedGenericContainerTest.php diff --git a/src/Container/StartedGenericContainer.php b/src/Container/StartedGenericContainer.php index 242e65f..d1c32f2 100644 --- a/src/Container/StartedGenericContainer.php +++ b/src/Container/StartedGenericContainer.php @@ -6,9 +6,11 @@ namespace Testcontainers\Container; use Docker\API\Client; use Docker\API\Model\ContainersIdExecPostBody; +use Docker\API\Model\ContainersIdJsonGetResponse200; use Docker\API\Model\IdResponse; use Docker\API\Runtime\Client\Client as DockerRuntimeClient; use Docker\Docker; +use JsonException; use Psr\Http\Message\ResponseInterface; use RuntimeException; use Testcontainers\ContainerClient\DockerContainerClient; @@ -99,7 +101,7 @@ class StartedGenericContainer implements StartedTestContainer public function getHost(): string { - return $this->inspect()['NetworkSettings']['Gateway'] ?? '127.0.0.1'; + return '127.0.0.1'; } public function getMappedPort(int $port): int @@ -138,7 +140,9 @@ class StartedGenericContainer implements StartedTestContainer */ public function getNetworkNames(): array { - $networks = $this->inspect()['NetworkSettings']['Networks'] ?? []; + /** @var array{NetworkSettings?: array{Networks?: array}} $inspectData */ + $inspectData = $this->inspect(); + $networks = $inspectData['NetworkSettings']['Networks'] ?? []; return array_keys($networks); } @@ -160,32 +164,73 @@ class StartedGenericContainer implements StartedTestContainer throw new RuntimeException("Network with name {$networkName} not exists"); } - private function inspect(): array + /** + * @return array The container details. + * @throws RuntimeException If the container inspection fails or the response format is invalid. + * TODO: refactor with object after beluga-php/docker-php client library is fixed + */ + protected function inspect(): array { - //For some reason, containerInspect can crash when using FETCH_OBJECT option (e.g. with OpenSearch) - //should be checked within beluga-php/docker-php client library - /** @var ResponseInterface | null $containerInspectResponse */ - $containerInspectResponse = $this->dockerClient->containerInspect($this->id, [], Docker::FETCH_RESPONSE); - if ($containerInspectResponse === null) { - throw new RuntimeException('Failed to inspect container'); - } - try { - return json_decode( + /** + * For some reason, containerInspect can crash when using FETCH_OBJECT option (e.g. with OpenSearch) + * This is a workaround until the issue is fixed (should be checked within beluga-php/docker-php client library) + */ + /** @var ResponseInterface | null $containerInspectResponse */ + $containerInspectResponse = $this->dockerClient->containerInspect($this->id, [], $this->dockerClient::FETCH_RESPONSE); + if ($containerInspectResponse === null) { + throw new RuntimeException('Failed to inspect container: response is null'); + } + + // Decode the JSON response as an associative array + $decodedResponse = json_decode( $containerInspectResponse->getBody()->getContents(), true, 512, JSON_THROW_ON_ERROR ); - } catch (Throwable $exception) { - throw new RuntimeException('Failed to inspect container', 0, $exception); + + if (!is_array($decodedResponse)) { + throw new RuntimeException('Failed to inspect container: response is not a valid JSON object'); + } + + return $decodedResponse; + } catch (JsonException $e) { + throw new RuntimeException( + sprintf('Failed to decode container inspect response: %s', $e->getMessage()), + previous: $e + ); + } catch (Throwable $e) { + throw new RuntimeException( + sprintf('Unexpected error while inspecting container: %s', $e->getMessage()), + previous: $e + ); } } - private function ports(): array + /** + * @return array An associative array containing the `NetworkSettings` details. + * @throws RuntimeException If the container inspection is missing the `NetworkSettings` key. + */ + protected function networkSettings(): array + { + $inspectData = $this->inspect(); + + if (!isset($inspectData['NetworkSettings']) || !is_array($inspectData['NetworkSettings'])) { + throw new RuntimeException('Missing or invalid NetworkSettings in container inspection'); + } + + return $inspectData['NetworkSettings']; + } + + /** + * @return array>> + * @throws RuntimeException + */ + protected function ports(): array { /** @var array>> $ports */ - $ports = $this->inspect()['NetworkSettings']['Ports'] ?? []; + $ports = $this->networkSettings()['Ports'] ?? []; if ($ports === []) { throw new RuntimeException('Failed to get ports from container'); diff --git a/src/Modules/MySQLContainer.php b/src/Modules/MySQLContainer.php index 82b673b..00c6a90 100644 --- a/src/Modules/MySQLContainer.php +++ b/src/Modules/MySQLContainer.php @@ -17,8 +17,6 @@ class MySQLContainer extends GenericContainer $this->withWait(new WaitForExec([ "mysqladmin", "ping", - "-u", "root", - "-p{$mysqlRootPassword}", "-h", "127.0.0.1", ])); } diff --git a/tests/Integration/ContainerTestCase.php b/tests/Integration/ContainerTestCase.php index bbe9d84..9a3966f 100644 --- a/tests/Integration/ContainerTestCase.php +++ b/tests/Integration/ContainerTestCase.php @@ -13,7 +13,9 @@ abstract class ContainerTestCase extends TestCase protected function tearDown(): void { - $this->container->stop(); + if (isset($this->container)) { + $this->container->stop(); + } parent::tearDown(); } } diff --git a/tests/Integration/StartedGenericContainerTest.php b/tests/Integration/StartedGenericContainerTest.php new file mode 100644 index 0000000..a59976c --- /dev/null +++ b/tests/Integration/StartedGenericContainerTest.php @@ -0,0 +1,207 @@ +withCommand(['tail', '-f', '/dev/null']) + ->start(); + + $this->container = $container; + + self::assertNotEmpty($container->getId(), 'Container ID should not be empty'); + } + + public function testShouldReturnLastExecId(): void + { + $container = (new GenericContainer('alpine')) + ->withCommand(['tail', '-f', '/dev/null']) + ->start(); + + $this->container = $container; + + $container->exec(['echo', 'Test Exec ID']); + + $lastExecId = $container->getLastExecId(); + + self::assertNotNull($lastExecId, 'Last exec ID should not be null'); + self::assertNotEmpty($lastExecId, 'Last exec ID should not be empty'); + self::assertMatchesRegularExpression('/^[0-9a-f]+$/', $lastExecId, 'Last exec ID should be a valid hexadecimal string'); + } + + public function testShouldExecuteCommandInContainer(): void + { + $container = (new GenericContainer('alpine')) + ->withCommand(['tail', '-f', '/dev/null']) + ->start(); + + $this->container = $container; + + $output = $container->exec(['echo', 'Hello, Testcontainers!']); + self::assertSame('Hello, Testcontainers!', $output); + } + + public function testShouldStopContainer(): void + { + $container = (new GenericContainer('alpine')) + ->withCommand(['tail', '-f', '/dev/null']) + ->start(); + + self::assertNotEmpty($container->getId(), 'Container ID should not be empty'); + + $stoppedContainer = $container->stop(); + + self::assertNotNull($stoppedContainer, 'Stopped container should not be null'); + self::assertSame( + $container->getId(), + $stoppedContainer->getId(), + 'Stopped container ID should match the original container ID' + ); + + self::assertStringContainsString( + 'No such container', + $container->logs(), + 'Expected message indicating container does not exist' + ); + } + + public function testShouldRestartContainer(): void + { + $container = (new GenericContainer('nginx')) + ->withExposedPorts(80) + ->start(); + + $this->container = $container; + + $containerIdBeforeRestart = $container->getId(); + $container->restart(); + $containerIdAfterRestart = $container->getId(); + + self::assertSame( + $containerIdBeforeRestart, + $containerIdAfterRestart, + 'Container ID should remain the same after restart' + ); + } + + public function testShouldRetrieveLogs(): void + { + $container = (new GenericContainer('alpine')) + ->withCommand(['sh', '-c', 'echo "Hello from logs!" && tail -f /dev/null']) + ->start(); + + $this->container = $container; + + $logs = $container->logs(); + self::assertStringContainsString('Hello from logs!', $logs); + } + + public function testShouldRetrieveHost(): void + { + $container = (new GenericContainer('alpine')) + ->withCommand(['tail', '-f', '/dev/null']) + ->start(); + + $this->container = $container; + + $host = $container->getHost(); + self::assertSame('127.0.0.1', $host, 'Host should be 127.0.0.1'); + } + + public function testShouldRetrieveFirstMappedPort(): void + { + $container = (new GenericContainer('nginx')) + ->withExposedPorts(80) + ->start(); + + $this->container = $container; + + $mappedPort = $container->getFirstMappedPort(); + self::assertGreaterThan(0, $mappedPort, 'Mapped port should be greater than 0'); + } + + public function testShouldRetrieveMappedPort(): void + { + $container = (new GenericContainer('nginx')) + ->withExposedPorts(80) + ->start(); + + $this->container = $container; + + $mappedPort = $container->getMappedPort(80); + self::assertGreaterThan(0, $mappedPort, 'Mapped port for 80 should be greater than 0'); + } + + public function testShouldRetrieveContainerName(): void + { + $name = 'test-container-name'; + $container = (new GenericContainer('alpine')) + ->withName($name) + ->withCommand(['tail', '-f', '/dev/null']) + ->start(); + + $this->container = $container; + + self::assertSame($name, $container->getName(), 'Container name should match'); + } + + public function testShouldRetrieveLabels(): void + { + $labels = [ + 'label-1' => 'value-1', + 'label-2' => 'value-2', + ]; + + $container = (new GenericContainer('alpine')) + ->withLabels($labels) + ->withCommand(['tail', '-f', '/dev/null']) + ->start(); + + $this->container = $container; + + $retrievedLabels = $container->getLabels(); + + self::assertArrayHasKey('label-1', $retrievedLabels); + self::assertSame('value-1', $retrievedLabels['label-1']); + self::assertArrayHasKey('label-2', $retrievedLabels); + self::assertSame('value-2', $retrievedLabels['label-2']); + } + + public function testShouldRetrieveNetworkNames(): void + { + $container = (new GenericContainer('nginx')) + ->withExposedPorts(80) + ->start(); + + $this->container = $container; + + $networks = $container->getNetworkNames(); + + self::assertNotEmpty($networks, 'Networks should not be empty'); + } + + public function testShouldRetrieveIpAddressFromNetwork(): void + { + $container = (new GenericContainer('nginx')) + ->withExposedPorts(80) + ->start(); + + $this->container = $container; + + $networks = $container->getNetworkNames(); + $networkName = $networks[0] ?? null; + + self::assertNotNull($networkName, 'Network name should not be null'); + + $ipAddress = $container->getIpAddress($networkName); + + self::assertNotEmpty($ipAddress, 'IP address should not be empty'); + } +}