From cb2c6d1a377cd9a0c5da47ff4c371834f87a5cda Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Mon, 13 Apr 2026 22:31:06 -0300 Subject: [PATCH] feat: Enhance port handling in Address and Ports classes. --- README.md | 17 +++- src/Contracts/Ports.php | 20 +++- src/Internal/Containers/Address/Ports.php | 25 ++++- .../Containers/ContainerInspection.php | 33 ++++++- tests/Unit/GenericDockerContainerTest.php | 99 +++++++++++++++++++ tests/Unit/Mocks/InspectResponseFixture.php | 4 +- 6 files changed, 185 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 03e7a42..8a0200a 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,15 @@ Maps a port from the host to the container. $container->withPortMapping(portOnHost: 8080, portOnContainer: 80); ``` +After the container starts, both ports are available through the `Address`: + +```php +$ports = $started->getAddress()->getPorts(); + +$ports->firstExposedPort(); // 80 (container-internal) +$ports->firstHostPort(); // 8080 (host-accessible) +``` + ### Setting volume mappings Mounts a directory from the host into the container. @@ -288,9 +297,12 @@ After the MySQL container starts, connection details are available through the ` ```php $address = $mySQLContainer->getAddress(); $ip = $address->getIp(); -$port = $address->getPorts()->firstExposedPort(); $hostname = $address->getHostname(); +$ports = $address->getPorts(); +$containerPort = $ports->firstExposedPort(); // e.g. 3306 (container-internal) +$hostPort = $ports->firstHostPort(); // e.g. 49153 (host-accessible) + $environmentVariables = $mySQLContainer->getEnvironmentVariables(); $database = $environmentVariables->getValueBy(key: 'MYSQL_DATABASE'); $username = $environmentVariables->getValueBy(key: 'MYSQL_USER'); @@ -299,6 +311,9 @@ $password = $environmentVariables->getValueBy(key: 'MYSQL_PASSWORD'); $jdbcUrl = $mySQLContainer->getJdbcUrl(); ``` +Use `firstExposedPort()` when connecting from another container in the same network. +Use `firstHostPort()` when connecting from the host machine (e.g., tests running outside Docker). + ## Flyway container `FlywayDockerContainer` provides a specialized container for running Flyway database migrations. It encapsulates diff --git a/src/Contracts/Ports.php b/src/Contracts/Ports.php index cf20432..62d97f2 100644 --- a/src/Contracts/Ports.php +++ b/src/Contracts/Ports.php @@ -5,21 +5,35 @@ namespace TinyBlocks\DockerContainer\Contracts; /** - * Represents the port mappings exposed by a Docker container. + * Represents the port mappings of a Docker container. */ interface Ports { /** - * Returns all exposed ports mapped to the host. + * Returns all container-internal exposed ports. * * @return array The list of exposed port numbers. */ public function exposedPorts(): array; /** - * Returns the first exposed port, or null if no ports are exposed. + * Returns all host-mapped ports. These are the ports accessible from the host machine. + * + * @return array The list of host-mapped port numbers. + */ + public function hostPorts(): array; + + /** + * Returns the first container-internal exposed port, or null if no ports are exposed. * * @return int|null The first exposed port number, or null if none. */ public function firstExposedPort(): ?int; + + /** + * Returns the first host-mapped port, or null if no ports are mapped. + * + * @return int|null The first host-mapped port number, or null if none. + */ + public function firstHostPort(): ?int; } diff --git a/src/Internal/Containers/Address/Ports.php b/src/Internal/Containers/Address/Ports.php index 29c0364..1227588 100644 --- a/src/Internal/Containers/Address/Ports.php +++ b/src/Internal/Containers/Address/Ports.php @@ -10,23 +10,38 @@ final readonly class Ports implements ContainerPorts { - private function __construct(private Collection $ports) + private function __construct(private Collection $exposedPorts, private Collection $hostMappedPorts) { } - public static function from(Collection $ports): Ports + public static function from(Collection $exposedPorts, Collection $hostMappedPorts): Ports { - return new Ports(ports: $ports->filter()); + return new Ports( + exposedPorts: $exposedPorts->filter(), + hostMappedPorts: $hostMappedPorts->filter() + ); + } + + public function hostPorts(): array + { + return $this->hostMappedPorts->toArray(keyPreservation: KeyPreservation::DISCARD); } public function exposedPorts(): array { - return $this->ports->toArray(keyPreservation: KeyPreservation::DISCARD); + return $this->exposedPorts->toArray(keyPreservation: KeyPreservation::DISCARD); + } + + public function firstHostPort(): ?int + { + $port = $this->hostMappedPorts->first(); + + return empty($port) ? null : (int)$port; } public function firstExposedPort(): ?int { - $port = $this->ports->first(); + $port = $this->exposedPorts->first(); return empty($port) ? null : (int)$port; } diff --git a/src/Internal/Containers/ContainerInspection.php b/src/Internal/Containers/ContainerInspection.php index 96c075e..15d2f3b 100644 --- a/src/Internal/Containers/ContainerInspection.php +++ b/src/Internal/Containers/ContainerInspection.php @@ -29,7 +29,8 @@ public function toAddress(): Address { $networks = $this->inspectResult['NetworkSettings']['Networks'] ?? []; $configuration = $this->inspectResult['Config'] ?? []; - $rawPorts = $configuration['ExposedPorts'] ?? []; + $rawExposedPorts = $configuration['ExposedPorts'] ?? []; + $rawHostPorts = $this->inspectResult['NetworkSettings']['Ports'] ?? []; $ip = IP::from(value: !empty($networks) ? ($networks[key($networks)]['IPAddress'] ?? '') : ''); $hostname = Hostname::from(value: $configuration['Hostname'] ?? ''); @@ -37,11 +38,37 @@ public function toAddress(): Address $exposedPorts = Collection::createFrom( elements: array_map( static fn(string $port): int => (int)explode('/', $port)[0], - array_keys($rawPorts) + array_keys($rawExposedPorts) ) ); - return Address::from(ip: $ip, ports: Ports::from(ports: $exposedPorts), hostname: $hostname); + $hostMappedPorts = Collection::createFrom( + elements: array_reduce( + array_values($rawHostPorts), + static function (array $ports, ?array $bindings): array { + if (is_null($bindings)) { + return $ports; + } + + foreach ($bindings as $binding) { + $hostPort = (int)($binding['HostPort'] ?? 0); + + if ($hostPort > 0) { + $ports[] = $hostPort; + } + } + + return $ports; + }, + [] + ) + ); + + return Address::from( + ip: $ip, + ports: Ports::from(exposedPorts: $exposedPorts, hostMappedPorts: $hostMappedPorts), + hostname: $hostname + ); } public function toEnvironmentVariables(): EnvironmentVariables diff --git a/tests/Unit/GenericDockerContainerTest.php b/tests/Unit/GenericDockerContainerTest.php index 1e90d31..4789847 100644 --- a/tests/Unit/GenericDockerContainerTest.php +++ b/tests/Unit/GenericDockerContainerTest.php @@ -402,6 +402,105 @@ public function testContainerWithNoExposedPortsReturnsNull(): void /** @Then firstExposedPort should return null */ self::assertNull($started->getAddress()->getPorts()->firstExposedPort()); self::assertEmpty($started->getAddress()->getPorts()->exposedPorts()); + + /** @And firstHostPort should return null */ + self::assertNull($started->getAddress()->getPorts()->firstHostPort()); + self::assertEmpty($started->getAddress()->getPorts()->hostPorts()); + } + + public function testContainerWithHostPortMapping(): void + { + /** @Given a container with a host port mapping */ + $container = TestableGenericDockerContainer::createWith( + image: 'mysql:8.4', + name: 'host-port', + client: $this->client + )->withPortMapping(portOnHost: 33060, portOnContainer: 3306); + + /** @And the Docker daemon returns a response with host port bindings */ + $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerInspectResponse( + inspectResult: InspectResponseFixture::build( + hostname: 'host-port', + exposedPorts: ['3306/tcp' => (object)[]], + hostPortBindings: [ + '3306/tcp' => [['HostIp' => '0.0.0.0', 'HostPort' => '33060']] + ] + ) + ); + + /** @When the container is started */ + $started = $container->run(); + + /** @Then the exposed port should be the container-internal port */ + self::assertSame(expected: 3306, actual: $started->getAddress()->getPorts()->firstExposedPort()); + + /** @And the host port should be the host-mapped port */ + self::assertSame(expected: 33060, actual: $started->getAddress()->getPorts()->firstHostPort()); + self::assertSame(expected: [33060], actual: $started->getAddress()->getPorts()->hostPorts()); + } + + public function testContainerWithMultipleHostPortMappings(): void + { + /** @Given a container with multiple host port mappings */ + $container = TestableGenericDockerContainer::createWith( + image: 'nginx:latest', + name: 'multi-host-port', + client: $this->client + ) + ->withPortMapping(portOnHost: 8080, portOnContainer: 80) + ->withPortMapping(portOnHost: 8443, portOnContainer: 443); + + /** @And the Docker daemon returns a response with multiple host port bindings */ + $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerInspectResponse( + inspectResult: InspectResponseFixture::build( + hostname: 'multi-host-port', + exposedPorts: ['80/tcp' => (object)[], '443/tcp' => (object)[]], + hostPortBindings: [ + '80/tcp' => [['HostIp' => '0.0.0.0', 'HostPort' => '8080']], + '443/tcp' => [['HostIp' => '0.0.0.0', 'HostPort' => '8443']] + ] + ) + ); + + /** @When the container is started */ + $started = $container->run(); + + /** @Then both exposed and host ports should be available */ + self::assertSame(expected: [80, 443], actual: $started->getAddress()->getPorts()->exposedPorts()); + self::assertSame(expected: [8080, 8443], actual: $started->getAddress()->getPorts()->hostPorts()); + self::assertSame(expected: 8080, actual: $started->getAddress()->getPorts()->firstHostPort()); + } + + public function testContainerWithExposedPortButNoHostBinding(): void + { + /** @Given a container with an exposed port but no host binding */ + $container = TestableGenericDockerContainer::createWith( + image: 'redis:latest', + name: 'no-host-bind', + client: $this->client + ); + + /** @And the Docker daemon returns a response with exposed port but null host bindings */ + $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerInspectResponse( + inspectResult: InspectResponseFixture::build( + hostname: 'no-host-bind', + exposedPorts: ['6379/tcp' => (object)[]], + hostPortBindings: ['6379/tcp' => null] + ) + ); + + /** @When the container is started */ + $started = $container->run(); + + /** @Then the exposed port should be available */ + self::assertSame(expected: 6379, actual: $started->getAddress()->getPorts()->firstExposedPort()); + + /** @And the host port should be null since there is no binding */ + self::assertNull($started->getAddress()->getPorts()->firstHostPort()); + self::assertEmpty($started->getAddress()->getPorts()->hostPorts()); } public function testEnvironmentVariableReturnsEmptyStringForMissingKey(): void diff --git a/tests/Unit/Mocks/InspectResponseFixture.php b/tests/Unit/Mocks/InspectResponseFixture.php index 58f1803..3b4582e 100644 --- a/tests/Unit/Mocks/InspectResponseFixture.php +++ b/tests/Unit/Mocks/InspectResponseFixture.php @@ -22,7 +22,8 @@ public static function build( string $ipAddress = '172.22.0.2', array $environment = [], string $networkName = 'bridge', - array $exposedPorts = [] + array $exposedPorts = [], + array $hostPortBindings = [] ): array { return [ 'Id' => $id, @@ -33,6 +34,7 @@ public static function build( 'Env' => $environment ], 'NetworkSettings' => [ + 'Ports' => $hostPortBindings, 'Networks' => [ $networkName => [ 'IPAddress' => $ipAddress