Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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');
Expand All @@ -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
Expand Down
20 changes: 17 additions & 3 deletions src/Contracts/Ports.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<int, int> 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<int, int> 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;
Comment thread
gustavofreze marked this conversation as resolved.

/**
* 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;
}
25 changes: 20 additions & 5 deletions src/Internal/Containers/Address/Ports.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
33 changes: 30 additions & 3 deletions src/Internal/Containers/ContainerInspection.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,19 +29,46 @@ 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'] ?? '');

$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;
},
[]
Comment thread
gustavofreze marked this conversation as resolved.
)
);

return Address::from(
ip: $ip,
ports: Ports::from(exposedPorts: $exposedPorts, hostMappedPorts: $hostMappedPorts),
hostname: $hostname
);
}

public function toEnvironmentVariables(): EnvironmentVariables
Expand Down
99 changes: 99 additions & 0 deletions tests/Unit/GenericDockerContainerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion tests/Unit/Mocks/InspectResponseFixture.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -33,6 +34,7 @@ public static function build(
'Env' => $environment
],
'NetworkSettings' => [
'Ports' => $hostPortBindings,
'Networks' => [
$networkName => [
'IPAddress' => $ipAddress
Expand Down
Loading