Skip to content

Commit 9512f5c

Browse files
authored
Release/2.4.0 (#20)
1 parent ac45796 commit 9512f5c

6 files changed

Lines changed: 185 additions & 13 deletions

File tree

README.md

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,15 @@ Maps a port from the host to the container.
130130
$container->withPortMapping(portOnHost: 8080, portOnContainer: 80);
131131
```
132132

133+
After the container starts, both ports are available through the `Address`:
134+
135+
```php
136+
$ports = $started->getAddress()->getPorts();
137+
138+
$ports->firstExposedPort(); // 80 (container-internal)
139+
$ports->firstHostPort(); // 8080 (host-accessible)
140+
```
141+
133142
### Setting volume mappings
134143

135144
Mounts a directory from the host into the container.
@@ -288,9 +297,12 @@ After the MySQL container starts, connection details are available through the `
288297
```php
289298
$address = $mySQLContainer->getAddress();
290299
$ip = $address->getIp();
291-
$port = $address->getPorts()->firstExposedPort();
292300
$hostname = $address->getHostname();
293301

302+
$ports = $address->getPorts();
303+
$containerPort = $ports->firstExposedPort(); // e.g. 3306 (container-internal)
304+
$hostPort = $ports->firstHostPort(); // e.g. 49153 (host-accessible)
305+
294306
$environmentVariables = $mySQLContainer->getEnvironmentVariables();
295307
$database = $environmentVariables->getValueBy(key: 'MYSQL_DATABASE');
296308
$username = $environmentVariables->getValueBy(key: 'MYSQL_USER');
@@ -299,6 +311,9 @@ $password = $environmentVariables->getValueBy(key: 'MYSQL_PASSWORD');
299311
$jdbcUrl = $mySQLContainer->getJdbcUrl();
300312
```
301313

314+
Use `firstExposedPort()` when connecting from another container in the same network.
315+
Use `firstHostPort()` when connecting from the host machine (e.g., tests running outside Docker).
316+
302317
## Flyway container
303318

304319
`FlywayDockerContainer` provides a specialized container for running Flyway database migrations. It encapsulates

src/Contracts/Ports.php

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,21 +5,35 @@
55
namespace TinyBlocks\DockerContainer\Contracts;
66

77
/**
8-
* Represents the port mappings exposed by a Docker container.
8+
* Represents the port mappings of a Docker container.
99
*/
1010
interface Ports
1111
{
1212
/**
13-
* Returns all exposed ports mapped to the host.
13+
* Returns all container-internal exposed ports.
1414
*
1515
* @return array<int, int> The list of exposed port numbers.
1616
*/
1717
public function exposedPorts(): array;
1818

1919
/**
20-
* Returns the first exposed port, or null if no ports are exposed.
20+
* Returns all host-mapped ports. These are the ports accessible from the host machine.
21+
*
22+
* @return array<int, int> The list of host-mapped port numbers.
23+
*/
24+
public function hostPorts(): array;
25+
26+
/**
27+
* Returns the first container-internal exposed port, or null if no ports are exposed.
2128
*
2229
* @return int|null The first exposed port number, or null if none.
2330
*/
2431
public function firstExposedPort(): ?int;
32+
33+
/**
34+
* Returns the first host-mapped port, or null if no ports are mapped.
35+
*
36+
* @return int|null The first host-mapped port number, or null if none.
37+
*/
38+
public function firstHostPort(): ?int;
2539
}

src/Internal/Containers/Address/Ports.php

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,23 +10,38 @@
1010

1111
final readonly class Ports implements ContainerPorts
1212
{
13-
private function __construct(private Collection $ports)
13+
private function __construct(private Collection $exposedPorts, private Collection $hostMappedPorts)
1414
{
1515
}
1616

17-
public static function from(Collection $ports): Ports
17+
public static function from(Collection $exposedPorts, Collection $hostMappedPorts): Ports
1818
{
19-
return new Ports(ports: $ports->filter());
19+
return new Ports(
20+
exposedPorts: $exposedPorts->filter(),
21+
hostMappedPorts: $hostMappedPorts->filter()
22+
);
23+
}
24+
25+
public function hostPorts(): array
26+
{
27+
return $this->hostMappedPorts->toArray(keyPreservation: KeyPreservation::DISCARD);
2028
}
2129

2230
public function exposedPorts(): array
2331
{
24-
return $this->ports->toArray(keyPreservation: KeyPreservation::DISCARD);
32+
return $this->exposedPorts->toArray(keyPreservation: KeyPreservation::DISCARD);
33+
}
34+
35+
public function firstHostPort(): ?int
36+
{
37+
$port = $this->hostMappedPorts->first();
38+
39+
return empty($port) ? null : (int)$port;
2540
}
2641

2742
public function firstExposedPort(): ?int
2843
{
29-
$port = $this->ports->first();
44+
$port = $this->exposedPorts->first();
3045

3146
return empty($port) ? null : (int)$port;
3247
}

src/Internal/Containers/ContainerInspection.php

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,19 +29,46 @@ public function toAddress(): Address
2929
{
3030
$networks = $this->inspectResult['NetworkSettings']['Networks'] ?? [];
3131
$configuration = $this->inspectResult['Config'] ?? [];
32-
$rawPorts = $configuration['ExposedPorts'] ?? [];
32+
$rawExposedPorts = $configuration['ExposedPorts'] ?? [];
33+
$rawHostPorts = $this->inspectResult['NetworkSettings']['Ports'] ?? [];
3334

3435
$ip = IP::from(value: !empty($networks) ? ($networks[key($networks)]['IPAddress'] ?? '') : '');
3536
$hostname = Hostname::from(value: $configuration['Hostname'] ?? '');
3637

3738
$exposedPorts = Collection::createFrom(
3839
elements: array_map(
3940
static fn(string $port): int => (int)explode('/', $port)[0],
40-
array_keys($rawPorts)
41+
array_keys($rawExposedPorts)
4142
)
4243
);
4344

44-
return Address::from(ip: $ip, ports: Ports::from(ports: $exposedPorts), hostname: $hostname);
45+
$hostMappedPorts = Collection::createFrom(
46+
elements: array_reduce(
47+
array_values($rawHostPorts),
48+
static function (array $ports, ?array $bindings): array {
49+
if (is_null($bindings)) {
50+
return $ports;
51+
}
52+
53+
foreach ($bindings as $binding) {
54+
$hostPort = (int)($binding['HostPort'] ?? 0);
55+
56+
if ($hostPort > 0) {
57+
$ports[] = $hostPort;
58+
}
59+
}
60+
61+
return $ports;
62+
},
63+
[]
64+
)
65+
);
66+
67+
return Address::from(
68+
ip: $ip,
69+
ports: Ports::from(exposedPorts: $exposedPorts, hostMappedPorts: $hostMappedPorts),
70+
hostname: $hostname
71+
);
4572
}
4673

4774
public function toEnvironmentVariables(): EnvironmentVariables

tests/Unit/GenericDockerContainerTest.php

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -402,6 +402,105 @@ public function testContainerWithNoExposedPortsReturnsNull(): void
402402
/** @Then firstExposedPort should return null */
403403
self::assertNull($started->getAddress()->getPorts()->firstExposedPort());
404404
self::assertEmpty($started->getAddress()->getPorts()->exposedPorts());
405+
406+
/** @And firstHostPort should return null */
407+
self::assertNull($started->getAddress()->getPorts()->firstHostPort());
408+
self::assertEmpty($started->getAddress()->getPorts()->hostPorts());
409+
}
410+
411+
public function testContainerWithHostPortMapping(): void
412+
{
413+
/** @Given a container with a host port mapping */
414+
$container = TestableGenericDockerContainer::createWith(
415+
image: 'mysql:8.4',
416+
name: 'host-port',
417+
client: $this->client
418+
)->withPortMapping(portOnHost: 33060, portOnContainer: 3306);
419+
420+
/** @And the Docker daemon returns a response with host port bindings */
421+
$this->client->withDockerRunResponse(output: InspectResponseFixture::containerId());
422+
$this->client->withDockerInspectResponse(
423+
inspectResult: InspectResponseFixture::build(
424+
hostname: 'host-port',
425+
exposedPorts: ['3306/tcp' => (object)[]],
426+
hostPortBindings: [
427+
'3306/tcp' => [['HostIp' => '0.0.0.0', 'HostPort' => '33060']]
428+
]
429+
)
430+
);
431+
432+
/** @When the container is started */
433+
$started = $container->run();
434+
435+
/** @Then the exposed port should be the container-internal port */
436+
self::assertSame(expected: 3306, actual: $started->getAddress()->getPorts()->firstExposedPort());
437+
438+
/** @And the host port should be the host-mapped port */
439+
self::assertSame(expected: 33060, actual: $started->getAddress()->getPorts()->firstHostPort());
440+
self::assertSame(expected: [33060], actual: $started->getAddress()->getPorts()->hostPorts());
441+
}
442+
443+
public function testContainerWithMultipleHostPortMappings(): void
444+
{
445+
/** @Given a container with multiple host port mappings */
446+
$container = TestableGenericDockerContainer::createWith(
447+
image: 'nginx:latest',
448+
name: 'multi-host-port',
449+
client: $this->client
450+
)
451+
->withPortMapping(portOnHost: 8080, portOnContainer: 80)
452+
->withPortMapping(portOnHost: 8443, portOnContainer: 443);
453+
454+
/** @And the Docker daemon returns a response with multiple host port bindings */
455+
$this->client->withDockerRunResponse(output: InspectResponseFixture::containerId());
456+
$this->client->withDockerInspectResponse(
457+
inspectResult: InspectResponseFixture::build(
458+
hostname: 'multi-host-port',
459+
exposedPorts: ['80/tcp' => (object)[], '443/tcp' => (object)[]],
460+
hostPortBindings: [
461+
'80/tcp' => [['HostIp' => '0.0.0.0', 'HostPort' => '8080']],
462+
'443/tcp' => [['HostIp' => '0.0.0.0', 'HostPort' => '8443']]
463+
]
464+
)
465+
);
466+
467+
/** @When the container is started */
468+
$started = $container->run();
469+
470+
/** @Then both exposed and host ports should be available */
471+
self::assertSame(expected: [80, 443], actual: $started->getAddress()->getPorts()->exposedPorts());
472+
self::assertSame(expected: [8080, 8443], actual: $started->getAddress()->getPorts()->hostPorts());
473+
self::assertSame(expected: 8080, actual: $started->getAddress()->getPorts()->firstHostPort());
474+
}
475+
476+
public function testContainerWithExposedPortButNoHostBinding(): void
477+
{
478+
/** @Given a container with an exposed port but no host binding */
479+
$container = TestableGenericDockerContainer::createWith(
480+
image: 'redis:latest',
481+
name: 'no-host-bind',
482+
client: $this->client
483+
);
484+
485+
/** @And the Docker daemon returns a response with exposed port but null host bindings */
486+
$this->client->withDockerRunResponse(output: InspectResponseFixture::containerId());
487+
$this->client->withDockerInspectResponse(
488+
inspectResult: InspectResponseFixture::build(
489+
hostname: 'no-host-bind',
490+
exposedPorts: ['6379/tcp' => (object)[]],
491+
hostPortBindings: ['6379/tcp' => null]
492+
)
493+
);
494+
495+
/** @When the container is started */
496+
$started = $container->run();
497+
498+
/** @Then the exposed port should be available */
499+
self::assertSame(expected: 6379, actual: $started->getAddress()->getPorts()->firstExposedPort());
500+
501+
/** @And the host port should be null since there is no binding */
502+
self::assertNull($started->getAddress()->getPorts()->firstHostPort());
503+
self::assertEmpty($started->getAddress()->getPorts()->hostPorts());
405504
}
406505

407506
public function testEnvironmentVariableReturnsEmptyStringForMissingKey(): void

tests/Unit/Mocks/InspectResponseFixture.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@ public static function build(
2222
string $ipAddress = '172.22.0.2',
2323
array $environment = [],
2424
string $networkName = 'bridge',
25-
array $exposedPorts = []
25+
array $exposedPorts = [],
26+
array $hostPortBindings = []
2627
): array {
2728
return [
2829
'Id' => $id,
@@ -33,6 +34,7 @@ public static function build(
3334
'Env' => $environment
3435
],
3536
'NetworkSettings' => [
37+
'Ports' => $hostPortBindings,
3638
'Networks' => [
3739
$networkName => [
3840
'IPAddress' => $ipAddress

0 commit comments

Comments
 (0)