From a1d29e4dac94f4d161cb66f95ccbb48f2429cb02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20W=C3=BCnsche?= Date: Wed, 13 Nov 2024 10:33:46 +0100 Subject: [PATCH 1/5] Implements missed methods in StartedGenericContainer.php --- src/Container/StartedGenericContainer.php | 106 +++++++++++++--------- src/Modules/MySQLContainer.php | 2 + 2 files changed, 67 insertions(+), 41 deletions(-) diff --git a/src/Container/StartedGenericContainer.php b/src/Container/StartedGenericContainer.php index ec81e71..5604eb2 100644 --- a/src/Container/StartedGenericContainer.php +++ b/src/Container/StartedGenericContainer.php @@ -10,7 +10,9 @@ use Docker\API\Model\IdResponse; use Docker\API\Runtime\Client\Client as DockerRuntimeClient; use Docker\Docker; use Psr\Http\Message\ResponseInterface; +use RuntimeException; use Testcontainers\ContainerClient\DockerContainerClient; +use Throwable; class StartedGenericContainer implements StartedTestContainer { @@ -54,7 +56,7 @@ class StartedGenericContainer implements StartedTestContainer $exec = $this->dockerClient->containerExec($this->id, $execConfig); if ($exec === null || $exec->getId() === null) { - throw new \RuntimeException('Failed to create exec command'); + throw new RuntimeException('Failed to create exec command'); } $this->lastExecId = $exec->getId(); @@ -96,45 +98,24 @@ class StartedGenericContainer implements StartedTestContainer return preg_replace('/[\x00-\x1F\x7F]/u', '', mb_convert_encoding($output, 'UTF-8', 'UTF-8')) ?? ''; } - //TODO: replace with the proper implementation public function getHost(): string { - return '127.0.0.1'; + return $this->inspect()['NetworkSettings']['Gateway'] ?? '127.0.0.1'; } - //TODO: not ready yet public function getMappedPort(int $port): int { - return $this->inspect()->ports[$port]; + $ports = $this->ports(); + if (isset($ports["{$port}/tcp"][0]['HostPort'])) { + return (int) $ports["{$port}/tcp"][0]['HostPort']; + } + + throw new RuntimeException("Failed to get mapped port $port for container"); } - /** - * @throws \JsonException - */ public function getFirstMappedPort(): int { - //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'); - } - - $containerInspectResponseAsArray = json_decode( - $containerInspectResponse->getBody()->getContents(), - true, - 512, - JSON_THROW_ON_ERROR - ); - - /** @var array>> $ports */ - $ports = $containerInspectResponseAsArray['NetworkSettings']['Ports'] ?? []; - - if ($ports === []) { - throw new \RuntimeException('Failed to get ports from container'); - } - + $ports = $this->ports(); $port = array_key_first($ports); return (int) $ports[$port][0]['HostPort']; @@ -142,32 +123,75 @@ class StartedGenericContainer implements StartedTestContainer public function getName(): string { - // TODO: Implement getName() method. - return ''; + return trim($this->inspect()['Name'], '/ '); } + /** + * @return string[] + */ public function getLabels(): array { - // TODO: Implement getLabels() method. - return []; + return $this->inspect()['Config']['Labels'] ?? []; } - + /** + * @return string[] + */ public function getNetworkNames(): array { - // TODO: Implement getNetworkNames() method. - return []; + $networks = $this->inspect()['NetworkSettings']['Networks'] ?? []; + return array_keys($networks); } public function getNetworkId(string $networkName): string { - // TODO: Implement getNetworkId() method. - return ''; + $networks = $this->inspect()['NetworkSettings']['Networks']; + if (isset($networks[$networkName])) { + return $networks[$networkName]['NetworkID']; + } + throw new RuntimeException("Network with name {$networkName} not exists"); } public function getIpAddress(string $networkName): string { - // TODO: Implement getIpAddress() method. - return ''; + $networks = $this->inspect()['NetworkSettings']['Networks']; + if (isset($networks[$networkName])) { + return $networks[$networkName]['IPAddress']; + } + throw new RuntimeException("Network with name {$networkName} not exists"); + } + + private 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( + $containerInspectResponse->getBody()->getContents(), + true, + 512, + JSON_THROW_ON_ERROR + ); + } catch (Throwable $exception) { + throw new RuntimeException('Failed to inspect container', 0, $exception); + } + } + + private function ports(): array + { + /** @var array>> $ports */ + $ports = $this->inspect()['NetworkSettings']['Ports'] ?? []; + + if ($ports === []) { + throw new RuntimeException('Failed to get ports from container'); + } + + return $ports; } } diff --git a/src/Modules/MySQLContainer.php b/src/Modules/MySQLContainer.php index 00c6a90..82b673b 100644 --- a/src/Modules/MySQLContainer.php +++ b/src/Modules/MySQLContainer.php @@ -17,6 +17,8 @@ class MySQLContainer extends GenericContainer $this->withWait(new WaitForExec([ "mysqladmin", "ping", + "-u", "root", + "-p{$mysqlRootPassword}", "-h", "127.0.0.1", ])); } From 1c928cc7677b96f24cafc28731286f7545a8b4ac Mon Sep 17 00:00:00 2001 From: Sergei Shitikov Date: Mon, 3 Feb 2025 20:47:02 +0100 Subject: [PATCH 2/5] 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'); + } +} From 345cdb40d0f20e049feeabc0f5dacffa408141cf Mon Sep 17 00:00:00 2001 From: Sergei Shitikov Date: Mon, 3 Feb 2025 23:57:59 +0100 Subject: [PATCH 3/5] Use inspect Model instead of plain Response after fix in beluga-php/docker-php. Update resolving host logic. --- src/Utils/HostResolver.php | 138 +++++++++++++++++++++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 src/Utils/HostResolver.php diff --git a/src/Utils/HostResolver.php b/src/Utils/HostResolver.php new file mode 100644 index 0000000..6c74dc2 --- /dev/null +++ b/src/Utils/HostResolver.php @@ -0,0 +1,138 @@ +dockerClient = $dockerClient ?? DockerContainerClient::getDockerClient(); + } + + /** + * Resolves the host address for connecting to a container. + * + * The resolution process is as follows: + * 1. If user overrides are allowed and TESTCONTAINERS_HOST_OVERRIDE is set, its value is returned. + * 2. Otherwise, the DOCKER_HOST environment variable is parsed. + * - If the scheme is one of http, https, or tcp, the hostname is used. + * - If the scheme is unix or npipe and the process is running in a container, the network gateway + * is determined by inspecting the relevant Docker network or running a temporary container. + * 3. If no other value can be determined, "localhost" is returned. + * + * @return string + * @throws RuntimeException If the DOCKER_HOST scheme is unsupported. + */ + public function resolveHost(): string + { + if ($this->allowUserOverrides() && ($override = getenv('TESTCONTAINERS_HOST_OVERRIDE')) !== false) { + return $override; + } + + // Get DOCKER_HOST URI, defaulting to a TCP endpoint if not set. + $dockerHostUri = getenv('DOCKER_HOST') ?: 'tcp://127.0.0.1:2375'; + $parts = parse_url($dockerHostUri); + if ($parts === false || !isset($parts['scheme'])) { + return 'localhost'; + } + + $scheme = $parts['scheme']; + + switch ($scheme) { + case 'http': + case 'https': + case 'tcp': + return $parts['host'] ?? 'localhost'; + + case 'unix': + case 'npipe': + if ($this->isInContainer()) { + // If using podman, choose "podman" network; otherwise, use "bridge" + $networkName = (str_contains($dockerHostUri, 'podman.sock')) ? 'podman' : 'bridge'; + if ($gateway = $this->findGateway($networkName)) { + return $gateway; + } + if ($defaultGateway = $this->findDefaultGateway()) { + return $defaultGateway; + } + } + return 'localhost'; + + default: + throw new RuntimeException("Unsupported Docker host scheme: {$scheme}"); + } + } + + protected function allowUserOverrides(): bool + { + return true; + } + + /** + * Determines if the code is running inside a container. + */ + protected function isInContainer(): bool + { + return file_exists('/.dockerenv'); + } + + /** + * Inspects the given network and returns its gateway IP address if found. + * + * @param string $networkName + * @return string|null + */ + protected function findGateway(string $networkName): ?string + { + try { + /** @var Network|null $networkInspect */ + $networkInspect = $this->dockerClient?->networkInspect($networkName); + $ipamConfig = $networkInspect?->getIPAM()?->getConfig(); + if ($ipamConfig !== null) { + foreach ($ipamConfig as $config) { + if ($config->getGateway() !== null) { + return $config->getGateway(); + } + } + } + } catch (\Throwable) { + return null; + } + return null; + } + + /** + * Runs a temporary container to determine the default gateway. + */ + protected function findDefaultGateway(): ?string + { + $tmpContainer = null; + try { + // Create a temporary container using a lightweight Alpine image. + $tmpContainer = (new GenericContainer('alpine:3.14')) + ->withCommand(['tail', '-f', '/dev/null']) + ->start(); + $result = $tmpContainer->exec(['sh', '-c', "ip route | awk '/default/ { print $3 }'"]); + $tmpContainer->stop(); + return $result; + } catch (\Throwable) { + return null; + } finally { + if ($tmpContainer !== null) { + try { + $tmpContainer->stop(); + } catch (\Throwable) { + // + } + } + } + } +} From 3194c8725fb97967c225368f5f76b5d7a662e654 Mon Sep 17 00:00:00 2001 From: Sergei Shitikov Date: Mon, 3 Feb 2025 23:59:25 +0100 Subject: [PATCH 4/5] add updates from StartedGenericContainer --- src/Container/StartedGenericContainer.php | 149 +++++++++------------- 1 file changed, 61 insertions(+), 88 deletions(-) diff --git a/src/Container/StartedGenericContainer.php b/src/Container/StartedGenericContainer.php index d1c32f2..259abd6 100644 --- a/src/Container/StartedGenericContainer.php +++ b/src/Container/StartedGenericContainer.php @@ -7,24 +7,26 @@ namespace Testcontainers\Container; use Docker\API\Client; use Docker\API\Model\ContainersIdExecPostBody; use Docker\API\Model\ContainersIdJsonGetResponse200; +use Docker\API\Model\EndpointSettings; use Docker\API\Model\IdResponse; +use Docker\API\Model\PortBinding; use Docker\API\Runtime\Client\Client as DockerRuntimeClient; use Docker\Docker; -use JsonException; -use Psr\Http\Message\ResponseInterface; use RuntimeException; use Testcontainers\ContainerClient\DockerContainerClient; -use Throwable; +use Testcontainers\Utils\HostResolver; class StartedGenericContainer implements StartedTestContainer { protected Docker $dockerClient; + protected ?ContainersIdJsonGetResponse200 $inspectResponse = null; + protected ?string $lastExecId = null; - public function __construct(protected readonly string $id) + public function __construct(protected readonly string $id, ?Docker $dockerClient = null) { - $this->dockerClient = DockerContainerClient::getDockerClient(); + $this->dockerClient = $dockerClient ?? DockerContainerClient::getDockerClient(); } public function getId(): string @@ -67,7 +69,7 @@ class StartedGenericContainer implements StartedTestContainer ?->getBody() ->getContents() ?? ''; - return preg_replace('/[\x00-\x1F\x7F]/u', '', $contents) ?? ''; + return $this->sanitizeOutput($contents); } public function stop(): StoppedTestContainer @@ -96,43 +98,52 @@ class StartedGenericContainer implements StartedTestContainer ?->getBody() ->getContents() ?? ''; - return preg_replace('/[\x00-\x1F\x7F]/u', '', mb_convert_encoding($output, 'UTF-8', 'UTF-8')) ?? ''; + return $this->sanitizeOutput(mb_convert_encoding($output, 'UTF-8', 'UTF-8')); } public function getHost(): string { - return '127.0.0.1'; + return (new HostResolver($this->dockerClient))->resolveHost(); } public function getMappedPort(int $port): int { - $ports = $this->ports(); - if (isset($ports["{$port}/tcp"][0]['HostPort'])) { - return (int) $ports["{$port}/tcp"][0]['HostPort']; + $ports = (array) $this->ports(); + /** @var PortBinding | null $portBinding */ + $portBinding = $ports["{$port}/tcp"][0] ?? null; + $mappedPort = $portBinding?->getHostPort(); + if ($mappedPort !== null) { + return (int) $mappedPort; } - throw new RuntimeException("Failed to get mapped port $port for container"); + throw new RuntimeException("Failed to get mapped port ‘{$mappedPort}’ for container"); } public function getFirstMappedPort(): int { - $ports = $this->ports(); + $ports = (array) $this->ports(); $port = array_key_first($ports); + /** @var PortBinding | null $firstPortBinding */ + $firstPortBinding = $ports[$port][0] ?? null; + $firstMappedPort = $firstPortBinding?->getHostPort(); + if ($firstMappedPort !== null) { + return (int) $firstMappedPort; + } - return (int) $ports[$port][0]['HostPort']; + throw new RuntimeException('Failed to get first mapped port for container'); } public function getName(): string { - return trim($this->inspect()['Name'], '/ '); + return trim($this->inspect()?->getName() ?? '', '/ '); } /** - * @return string[] + * @return array */ public function getLabels(): array { - return $this->inspect()['Config']['Labels'] ?? []; + return (array) $this->inspect()?->getConfig()?->getLabels(); } /** @@ -140,102 +151,64 @@ class StartedGenericContainer implements StartedTestContainer */ public function getNetworkNames(): array { - /** @var array{NetworkSettings?: array{Networks?: array}} $inspectData */ - $inspectData = $this->inspect(); - $networks = $inspectData['NetworkSettings']['Networks'] ?? []; + $networks = (array) $this->inspect()?->getNetworkSettings()?->getNetworks(); return array_keys($networks); } public function getNetworkId(string $networkName): string { - $networks = $this->inspect()['NetworkSettings']['Networks']; - if (isset($networks[$networkName])) { - return $networks[$networkName]['NetworkID']; + $networks = (array) $this->inspect()?->getNetworkSettings()?->getNetworks(); + /** @var EndpointSettings | null $endpointSettings */ + $endpointSettings = $networks[$networkName] ?? null; + $networkID = $endpointSettings?->getNetworkID(); + if ($networkID !== null) { + return $networkID; } - throw new RuntimeException("Network with name {$networkName} not exists"); + + throw new RuntimeException("Network with name ‘{$networkName}’ does not exist"); } public function getIpAddress(string $networkName): string { - $networks = $this->inspect()['NetworkSettings']['Networks']; - if (isset($networks[$networkName])) { - return $networks[$networkName]['IPAddress']; + $networks = (array) $this->inspect()?->getNetworkSettings()?->getNetworks(); + /** @var EndpointSettings | null $endpointSettings */ + $endpointSettings = $networks[$networkName] ?? null; + $ipAddress = $endpointSettings?->getIPAddress(); + if ($ipAddress !== null) { + return $ipAddress; } - throw new RuntimeException("Network with name {$networkName} not exists"); + + throw new RuntimeException("Network with name ‘{$networkName}’ does not exist"); } - /** - * @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 + protected function inspect(): ContainersIdJsonGetResponse200 | null { - try { - /** - * 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 - ); - - 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 - ); + if ($this->inspectResponse === null) { + /** @var ContainersIdJsonGetResponse200 | null $inspectResponse */ + $inspectResponse = $this->dockerClient->containerInspect($this->id); + $this->inspectResponse = $inspectResponse; } + + return $this->inspectResponse; } /** - * @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>> + * @return array> * @throws RuntimeException */ - protected function ports(): array + protected function ports(): iterable { - /** @var array>> $ports */ - $ports = $this->networkSettings()['Ports'] ?? []; + $ports = $this->inspect()?->getNetworkSettings()?->getPorts(); - if ($ports === []) { + if ($ports === null) { throw new RuntimeException('Failed to get ports from container'); } return $ports; } + + protected function sanitizeOutput(string $output): string + { + return preg_replace('/[\x00-\x1F\x7F]/u', '', $output) ?? ''; + } } From f899c413119a64f4dc94dd3d0392749339c68667 Mon Sep 17 00:00:00 2001 From: Sergei Shitikov Date: Tue, 4 Feb 2025 00:25:24 +0100 Subject: [PATCH 5/5] adjust phpstan memory, small phpstan fixes added HostResolverTest --- composer.json | 2 +- src/Container/StartedTestContainer.php | 6 + tests/Unit/Utils/HostResolverTest.php | 253 +++++++++++++++++++++++++ tests/Unit/Utils/TarBuilderTest.php | 4 +- 4 files changed, 262 insertions(+), 3 deletions(-) create mode 100644 tests/Unit/Utils/HostResolverTest.php diff --git a/composer.json b/composer.json index 773c728..42cc19a 100644 --- a/composer.json +++ b/composer.json @@ -45,7 +45,7 @@ "integration:old": "phpunit tests/Integration/OldTests --bootstrap vendor/autoload.php", "cs": "php-cs-fixer fix --dry-run", "cs:fix": "php-cs-fixer fix", - "phpstan": "phpstan analyse" + "phpstan": "phpstan analyse --memory-limit=256M" }, "config": { "allow-plugins": { diff --git a/src/Container/StartedTestContainer.php b/src/Container/StartedTestContainer.php index 3a75b0b..8d1acd3 100644 --- a/src/Container/StartedTestContainer.php +++ b/src/Container/StartedTestContainer.php @@ -23,6 +23,9 @@ interface StartedTestContainer public function getIpAddress(string $networkName): string; + /** + * @return array + */ public function getLabels(): array; public function logs(): string; @@ -35,6 +38,9 @@ interface StartedTestContainer public function getNetworkId(string $networkName): string; + /** + * @return string[] + */ public function getNetworkNames(): array; public function restart(): self; diff --git a/tests/Unit/Utils/HostResolverTest.php b/tests/Unit/Utils/HostResolverTest.php new file mode 100644 index 0000000..f428b94 --- /dev/null +++ b/tests/Unit/Utils/HostResolverTest.php @@ -0,0 +1,253 @@ +createMock(Docker::class); + $resolver = new HostResolver($dummyClient); + $host = $resolver->resolveHost(); + $this->assertEquals('tcp://another:2375', $host); + } + + public function testReturnsHostnameForTcpProtocols(): void + { + $protocols = ['tcp', 'http', 'https']; + foreach ($protocols as $protocol) { + putenv('DOCKER_HOST=' . $protocol . '://docker:2375'); + // Clear any override. + putenv('TESTCONTAINERS_HOST_OVERRIDE'); + $dummyClient = $this->createMock(Docker::class); + $resolver = new HostResolver($dummyClient); + $host = $resolver->resolveHost(); + $this->assertEquals('docker', $host, "Protocol {$protocol} did not return expected hostname."); + } + } + + public function testDoesNotReturnOverrideWhenAllowUserOverridesIsFalse(): void + { + $dummyClient = $this->createMock(Docker::class); + $resolver = new class ($dummyClient) extends HostResolver { + protected function allowUserOverrides(): bool + { + return false; + } + }; + + putenv('TESTCONTAINERS_HOST_OVERRIDE=tcp://another:2375'); + putenv('DOCKER_HOST=tcp://docker:2375'); + $host = $resolver->resolveHost(); + $this->assertEquals('docker', $host); + } + + public function testReturnsLocalhostForUnixAndNpipeProtocolsWhenNotInContainer(): void + { + $dummyClient = $this->createMock(Docker::class); + $resolver = new class ($dummyClient) extends HostResolver { + protected function isInContainer(): bool + { + return false; + } + }; + + foreach (['unix://docker:2375', 'npipe://docker:2375'] as $uri) { + putenv('DOCKER_HOST=' . $uri); + putenv('TESTCONTAINERS_HOST_OVERRIDE'); + $host = $resolver->resolveHost(); + $this->assertEquals('localhost', $host, "URI {$uri} should return 'localhost' when not in a container."); + } + } + + public function testReturnsHostFromGatewayWhenRunningInContainer(): void + { + // For this test we simulate that we are in a container and the Docker client returns a gateway. + $dockerClient = $this->getMockBuilder(Docker::class) + ->disableOriginalConstructor() + ->getMock(); + + // Build a fake network inspection response: + $fakeConfig = new class () { + public function getGateway(): ?string + { + return '172.0.0.1'; + } + }; + $fakeIPAM = new class ($fakeConfig) { + /** @var object[] */ + private array $config; + public function __construct(object $config) + { + $this->config = [$config]; + } + /** @return object[] */ + public function getConfig(): array + { + return $this->config; + } + }; + $fakeNetwork = new class ($fakeIPAM) { + private object $ipam; + public function __construct(object $ipam) + { + $this->ipam = $ipam; + } + public function getIPAM(): object + { + return $this->ipam; + } + }; + + // Expect that networkInspect will be called with "bridge" (since DOCKER_HOST does not contain "podman.sock") + $dockerClient->expects($this->once()) + ->method('networkInspect') + ->with($this->equalTo('bridge')) + ->willReturn($fakeNetwork); + + // Override isInContainer() to simulate being inside a container. + $resolver = new class ($dockerClient) extends HostResolver { + protected function isInContainer(): bool + { + return true; + } + }; + + putenv('DOCKER_HOST=unix://docker:2375'); + putenv('TESTCONTAINERS_HOST_OVERRIDE'); + $host = $resolver->resolveHost(); + $this->assertEquals('172.0.0.1', $host); + } + + public function testUsesBridgeNetworkAsGatewayForDockerProvider(): void + { + // For Docker provider (non-Podman) the network used should be "bridge". + $dockerClient = $this->getMockBuilder(Docker::class) + ->disableOriginalConstructor() + ->getMock(); + // Expect networkInspect to be called with "bridge" + $dockerClient->expects($this->once()) + ->method('networkInspect') + ->with($this->equalTo('bridge')) + ->willReturn(null); // Simulate not finding a gateway + + $resolver = new class ($dockerClient) extends HostResolver { + protected function isInContainer(): bool + { + return true; + } + }; + + putenv('DOCKER_HOST=unix://docker:2375'); + $host = $resolver->resolveHost(); + // Since no gateway is found, fallback is "localhost" + $this->assertEquals('localhost', $host); + } + + public function testUsesPodmanNetworkAsGatewayForPodmanProvider(): void + { + // For Podman, DOCKER_HOST contains "podman.sock" so the network should be "podman". + $dockerClient = $this->getMockBuilder(Docker::class) + ->disableOriginalConstructor() + ->getMock(); + // Expect networkInspect to be called with "podman" + $dockerClient->expects($this->once()) + ->method('networkInspect') + ->with($this->equalTo('podman')) + ->willReturn(null); // Simulate not finding a gateway + + $resolver = new class ($dockerClient) extends HostResolver { + protected function isInContainer(): bool + { + return true; + } + }; + + putenv('DOCKER_HOST=unix://podman.sock'); + $host = $resolver->resolveHost(); + $this->assertEquals('localhost', $host); + } + + public function testReturnsHostFromDefaultGatewayWhenRunningInContainer(): void + { + // Override both findGateway() and findDefaultGateway() to simulate a missing network gateway and a default gateway result. + $dummyClient = $this->createMock(Docker::class); + $resolver = new class ($dummyClient) extends HostResolver { + protected function isInContainer(): bool + { + return true; + } + protected function findGateway(string $networkName): ?string + { + return null; + } + protected function findDefaultGateway(): ?string + { + return '172.0.0.2'; + } + }; + + putenv('DOCKER_HOST=unix://docker:2375'); + $host = $resolver->resolveHost(); + $this->assertEquals('172.0.0.2', $host); + } + + public function testReturnsLocalhostIfUnableToFindGateway(): void + { + // Override to simulate that neither network inspection nor default gateway yield a result. + $dummyClient = $this->createMock(Docker::class); + $resolver = new class ($dummyClient) extends HostResolver { + protected function isInContainer(): bool + { + return true; + } + protected function findGateway(string $networkName): ?string + { + return null; + } + protected function findDefaultGateway(): ?string + { + return null; + } + }; + + putenv('DOCKER_HOST=unix://docker:2375'); + $host = $resolver->resolveHost(); + $this->assertEquals('localhost', $host); + } + + public function testThrowsForUnsupportedProtocol(): void + { + putenv('DOCKER_HOST=invalid://unknown'); + $dummyClient = $this->createMock(Docker::class); + $resolver = new HostResolver($dummyClient); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage("Unsupported Docker host scheme: invalid"); + + $resolver->resolveHost(); + } +} diff --git a/tests/Unit/Utils/TarBuilderTest.php b/tests/Unit/Utils/TarBuilderTest.php index b2f326f..965b9de 100644 --- a/tests/Unit/Utils/TarBuilderTest.php +++ b/tests/Unit/Utils/TarBuilderTest.php @@ -146,7 +146,7 @@ class TarBuilderTest extends TestCase mkdir($extractDir); $this->extractTar($tarPath, $extractDir); - $scanned = array_diff(scandir($extractDir), ['.', '..']); + $scanned = array_diff(scandir($extractDir) ?: [], ['.', '..']); $this->assertCount(0, $scanned, 'Expected empty directory'); } @@ -166,7 +166,7 @@ class TarBuilderTest extends TestCase mkdir($extractDir); $this->extractTar($tarPath, $extractDir); - $scanned = array_diff(scandir($extractDir), ['.', '..']); + $scanned = array_diff(scandir($extractDir) ?: [], ['.', '..']); $this->assertCount(0, $scanned, 'Expected no files after clear()'); }