Skip to content

Commit 4ab0ee9

Browse files
authored
Release/2.5.1 (#22)
* feat: Add environment-aware connection methods to Address and Ports contracts * feat: Adds getHostForConnection() to Address contract and getPortForConnection() to Ports contract. These methods use HostEnvironment::isInsideDocker() to transparently return the correct host/port for connecting to the container regardless of whether the caller is running inside Docker or on the host. * feat: Add getPortForConnection method to Ports contract * feat: Implement getHostForConnection in Address class * docs: Add environment-aware connection section to README * feat: Added section on environment-aware connection methods for Docker. * feat: Implement getPortForConnection in Ports class * test: Add file_exists override for inside Docker simulation * test: Add AddressTest for getHostForConnection method * test: Add PortsTest for getPortForConnection method * docs: Add use imports to README code examples * docs: Update README for environment-aware methods in Address and Ports.
1 parent 1086be2 commit 4ab0ee9

8 files changed

Lines changed: 213 additions & 7 deletions

File tree

README.md

Lines changed: 56 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
* [Configuring MySQL options](#configuring-mysql-options)
2424
* [Setting readiness timeout](#setting-readiness-timeout)
2525
* [Retrieving connection data](#retrieving-connection-data)
26+
* [Environment-aware connection](#environment-aware-connection)
2627
* [Flyway container](#flyway-container)
2728
* [Setting the database source](#setting-the-database-source)
2829
* [Configuring migrations](#configuring-migrations)
@@ -54,6 +55,8 @@ composer require tiny-blocks/docker-container
5455
Creates a container from a specified image and an optional name.
5556

5657
```php
58+
use TinyBlocks\DockerContainer\GenericDockerContainer;
59+
5760
$container = GenericDockerContainer::from(image: 'php:8.5-fpm', name: 'my-container');
5861
```
5962

@@ -75,6 +78,8 @@ $container->run(commands: ['ls', '-la']);
7578
With commands and a wait strategy:
7679

7780
```php
81+
use TinyBlocks\DockerContainer\Waits\ContainerWaitForTime;
82+
7883
$container->run(commands: ['ls', '-la'], waitAfterStarted: ContainerWaitForTime::forSeconds(seconds: 5));
7984
```
8085

@@ -95,6 +100,9 @@ To pull multiple images in parallel, call `pullImage()` on all containers **befo
95100
them. This way the downloads happen concurrently:
96101

97102
```php
103+
use TinyBlocks\DockerContainer\MySQLDockerContainer;
104+
use TinyBlocks\DockerContainer\FlywayDockerContainer;
105+
98106
$mysql = MySQLDockerContainer::from(image: 'mysql:8.4', name: 'my-database')
99107
->pullImage()
100108
->withRootPassword(rootPassword: 'root');
@@ -103,11 +111,11 @@ $flyway = FlywayDockerContainer::from(image: 'flyway/flyway:12-alpine')
103111
->pullImage()
104112
->withMigrations(pathOnHost: '/path/to/migrations');
105113

106-
// Both images are downloading in the background.
107-
// MySQL pull completes here, container starts and becomes ready.
114+
# Both images are downloading in the background.
115+
# MySQL pull completes here, container starts and becomes ready.
108116
$mySQLStarted = $mysql->runIfNotExists();
109117

110-
// Flyway pull already finished while MySQL was starting.
118+
# Flyway pull already finished while MySQL was starting.
111119
$flyway->withSource(container: $mySQLStarted, username: 'root', password: 'root')
112120
->cleanAndMigrate();
113121
```
@@ -135,8 +143,8 @@ After the container starts, both ports are available through the `Address`:
135143
```php
136144
$ports = $started->getAddress()->getPorts();
137145

138-
$ports->firstExposedPort(); // 80 (container-internal)
139-
$ports->firstHostPort(); // 8080 (host-accessible)
146+
$ports->firstExposedPort(); # 80 (container-internal)
147+
$ports->firstHostPort(); # 8080 (host-accessible)
140148
```
141149

142150
### Setting volume mappings
@@ -227,6 +235,8 @@ $result->isSuccessful();
227235
Pauses execution for a specified number of seconds before or after starting a container.
228236

229237
```php
238+
use TinyBlocks\DockerContainer\Waits\ContainerWaitForTime;
239+
230240
$container->withWaitBeforeRun(wait: ContainerWaitForTime::forSeconds(seconds: 3));
231241
```
232242

@@ -236,6 +246,11 @@ Blocks until a readiness condition is satisfied, with a configurable timeout. Th
236246
depends on another being fully ready.
237247

238248
```php
249+
use TinyBlocks\DockerContainer\GenericDockerContainer;
250+
use TinyBlocks\DockerContainer\MySQLDockerContainer;
251+
use TinyBlocks\DockerContainer\Waits\ContainerWaitForDependency;
252+
use TinyBlocks\DockerContainer\Waits\Conditions\MySQLReady;
253+
239254
$mySQLStarted = MySQLDockerContainer::from(image: 'mysql:8.4')
240255
->withRootPassword(rootPassword: 'root')
241256
->run();
@@ -267,6 +282,8 @@ MySQL-specific configuration and automatic readiness detection.
267282
| `withGrantedHosts` | `$hosts` | Sets hosts granted root privileges (default: `['%', '172.%']`). |
268283

269284
```php
285+
use TinyBlocks\DockerContainer\MySQLDockerContainer;
286+
270287
$mySQLContainer = MySQLDockerContainer::from(image: 'mysql:8.4', name: 'my-database')
271288
->withTimezone(timezone: 'America/Sao_Paulo')
272289
->withUsername(user: 'app_user')
@@ -284,6 +301,8 @@ Configures how long the MySQL container waits for the database to become ready b
284301
`ContainerWaitTimeout` exception. The default timeout is 30 seconds.
285302

286303
```php
304+
use TinyBlocks\DockerContainer\MySQLDockerContainer;
305+
287306
$mySQLContainer = MySQLDockerContainer::from(image: 'mysql:8.4', name: 'my-database')
288307
->withRootPassword(rootPassword: 'root')
289308
->withReadinessTimeout(timeoutInSeconds: 60)
@@ -300,8 +319,8 @@ $ip = $address->getIp();
300319
$hostname = $address->getHostname();
301320

302321
$ports = $address->getPorts();
303-
$containerPort = $ports->firstExposedPort(); // e.g. 3306 (container-internal)
304-
$hostPort = $ports->firstHostPort(); // e.g. 49153 (host-accessible)
322+
$containerPort = $ports->firstExposedPort(); # e.g. 3306 (container-internal)
323+
$hostPort = $ports->firstHostPort(); # e.g. 49153 (host-accessible)
305324

306325
$environmentVariables = $mySQLContainer->getEnvironmentVariables();
307326
$database = $environmentVariables->getValueBy(key: 'MYSQL_DATABASE');
@@ -314,6 +333,31 @@ $jdbcUrl = $mySQLContainer->getJdbcUrl();
314333
Use `firstExposedPort()` when connecting from another container in the same network.
315334
Use `firstHostPort()` when connecting from the host machine (e.g., tests running outside Docker).
316335

336+
### Environment-aware connection
337+
338+
The `Address` and `Ports` contracts provide environment-aware methods that automatically resolve the correct host and
339+
port for connecting to a container. These methods detect whether the caller is running inside Docker or on the host
340+
machine:
341+
342+
```php
343+
use TinyBlocks\DockerContainer\MySQLDockerContainer;
344+
345+
$mySQLContainer = MySQLDockerContainer::from(image: 'mysql:8.4', name: 'my-database')
346+
->withRootPassword(rootPassword: 'root')
347+
->withDatabase(database: 'my_database')
348+
->withPortMapping(portOnHost: 3306, portOnContainer: 3306);
349+
350+
$started = $mySQLContainer->runIfNotExists();
351+
$address = $started->getAddress();
352+
353+
$host = $address->getHostForConnection(); # hostname inside Docker, 127.0.0.1 on host
354+
$port = $address->getPorts()->getPortForConnection(); # container port inside Docker, host-mapped port on host
355+
```
356+
357+
This is useful when the same test suite runs both locally (inside a Docker Compose stack) and in CI (on the host).
358+
Instead of manually checking the environment and switching between `getHostname()`/`getIp()` or `firstExposedPort()`/
359+
`firstHostPort()`, the environment-aware methods handle it transparently.
360+
317361
## Flyway container
318362

319363
`FlywayDockerContainer` provides a specialized container for running Flyway database migrations. It encapsulates
@@ -325,6 +369,8 @@ Configures the Flyway container to connect to a running MySQL container. Automat
325369
target schema from `MYSQL_DATABASE`, and sets the history table to `schema_history`.
326370

327371
```php
372+
use TinyBlocks\DockerContainer\FlywayDockerContainer;
373+
328374
$flywayContainer = FlywayDockerContainer::from(image: 'flyway/flyway:12-alpine')
329375
->withNetwork(name: 'my-network')
330376
->withMigrations(pathOnHost: '/path/to/migrations')
@@ -385,6 +431,9 @@ $flywayContainer->cleanAndMigrate();
385431
Configure both containers and start image pulls in parallel before running either one:
386432

387433
```php
434+
use TinyBlocks\DockerContainer\MySQLDockerContainer;
435+
use TinyBlocks\DockerContainer\FlywayDockerContainer;
436+
388437
$mySQLContainer = MySQLDockerContainer::from(image: 'mysql:8.4', name: 'test-database')
389438
->pullImage()
390439
->withNetwork(name: 'my-network')

src/Contracts/Address.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,4 +29,15 @@ public function getPorts(): Ports;
2929
* @return string The container's hostname.
3030
*/
3131
public function getHostname(): string;
32+
33+
/**
34+
* Returns the appropriate host address for connecting to the container.
35+
*
36+
* When running inside Docker (e.g., from another container), returns the container's hostname,
37+
* which is resolvable within the Docker network. When running on the host (e.g., in CI or local
38+
* development outside Docker), returns 127.0.0.1, since the container is accessible via port mapping.
39+
*
40+
* @return string The host address to use for connection.
41+
*/
42+
public function getHostForConnection(): string;
3243
}

src/Contracts/Ports.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,4 +36,15 @@ public function firstExposedPort(): ?int;
3636
* @return int|null The first host-mapped port number, or null if none.
3737
*/
3838
public function firstHostPort(): ?int;
39+
40+
/**
41+
* Returns the appropriate port for connecting to the container.
42+
*
43+
* When running inside Docker (e.g., from another container), returns the first exposed
44+
* (container-internal) port. When running on the host (e.g., in CI or local development
45+
* outside Docker), returns the first host-mapped port.
46+
*
47+
* @return int|null The port to use for connection, or null if unavailable.
48+
*/
49+
public function getPortForConnection(): ?int;
3950
}

src/Internal/Containers/Address/Address.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
use TinyBlocks\DockerContainer\Contracts\Address as ContainerAddress;
88
use TinyBlocks\DockerContainer\Contracts\Ports as ContainerPorts;
9+
use TinyBlocks\DockerContainer\Internal\Containers\HostEnvironment;
910

1011
final readonly class Address implements ContainerAddress
1112
{
@@ -32,4 +33,11 @@ public function getHostname(): string
3233
{
3334
return $this->hostname->value;
3435
}
36+
37+
public function getHostForConnection(): string
38+
{
39+
return HostEnvironment::isInsideDocker()
40+
? $this->hostname->value
41+
: '127.0.0.1';
42+
}
3543
}

src/Internal/Containers/Address/Ports.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
use TinyBlocks\Collection\Collection;
88
use TinyBlocks\DockerContainer\Contracts\Ports as ContainerPorts;
9+
use TinyBlocks\DockerContainer\Internal\Containers\HostEnvironment;
910
use TinyBlocks\Mapper\KeyPreservation;
1011

1112
final readonly class Ports implements ContainerPorts
@@ -45,4 +46,11 @@ public function firstExposedPort(): ?int
4546

4647
return empty($port) ? null : (int)$port;
4748
}
49+
50+
public function getPortForConnection(): ?int
51+
{
52+
return HostEnvironment::isInsideDocker()
53+
? $this->firstExposedPort()
54+
: $this->firstHostPort();
55+
}
4856
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Test\Unit\Internal\Containers\Address;
6+
7+
use PHPUnit\Framework\Attributes\RunInSeparateProcess;
8+
use PHPUnit\Framework\TestCase;
9+
use TinyBlocks\Collection\Collection;
10+
use TinyBlocks\DockerContainer\Internal\Containers\Address\Address;
11+
use TinyBlocks\DockerContainer\Internal\Containers\Address\Hostname;
12+
use TinyBlocks\DockerContainer\Internal\Containers\Address\IP;
13+
use TinyBlocks\DockerContainer\Internal\Containers\Address\Ports;
14+
15+
final class AddressTest extends TestCase
16+
{
17+
#[RunInSeparateProcess]
18+
public function testGetHostForConnectionReturnsLocalhostWhenOutsideDocker(): void
19+
{
20+
require_once __DIR__ . '/../Overrides/file_exists_outside_docker.php';
21+
22+
/** @Given an Address with a known hostname */
23+
$address = Address::from(
24+
ip: IP::from(value: '172.17.0.2'),
25+
ports: Ports::from(
26+
exposedPorts: Collection::createFrom(elements: [3306]),
27+
hostMappedPorts: Collection::createFrom(elements: [49153])
28+
),
29+
hostname: Hostname::from(value: 'my-container')
30+
);
31+
32+
/** @When getHostForConnection is called outside Docker */
33+
$host = $address->getHostForConnection();
34+
35+
/** @Then it should return 127.0.0.1 */
36+
self::assertSame('127.0.0.1', $host);
37+
}
38+
39+
#[RunInSeparateProcess]
40+
public function testGetHostForConnectionReturnsHostnameWhenInsideDocker(): void
41+
{
42+
require_once __DIR__ . '/../Overrides/file_exists_inside_docker.php';
43+
44+
/** @Given an Address with a known hostname */
45+
$address = Address::from(
46+
ip: IP::from(value: '172.17.0.2'),
47+
ports: Ports::from(
48+
exposedPorts: Collection::createFrom(elements: [3306]),
49+
hostMappedPorts: Collection::createFrom(elements: [49153])
50+
),
51+
hostname: Hostname::from(value: 'my-container')
52+
);
53+
54+
/** @When getHostForConnection is called inside Docker */
55+
$host = $address->getHostForConnection();
56+
57+
/** @Then it should return the container hostname */
58+
self::assertSame('my-container', $host);
59+
}
60+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Test\Unit\Internal\Containers\Address;
6+
7+
use PHPUnit\Framework\Attributes\RunInSeparateProcess;
8+
use PHPUnit\Framework\TestCase;
9+
use TinyBlocks\Collection\Collection;
10+
use TinyBlocks\DockerContainer\Internal\Containers\Address\Ports;
11+
12+
final class PortsTest extends TestCase
13+
{
14+
#[RunInSeparateProcess]
15+
public function testGetPortForConnectionReturnsHostPortWhenOutsideDocker(): void
16+
{
17+
require_once __DIR__ . '/../Overrides/file_exists_outside_docker.php';
18+
19+
/** @Given Ports with known exposed and host-mapped ports */
20+
$ports = Ports::from(
21+
exposedPorts: Collection::createFrom(elements: [3306]),
22+
hostMappedPorts: Collection::createFrom(elements: [49153])
23+
);
24+
25+
/** @When getPortForConnection is called outside Docker */
26+
$port = $ports->getPortForConnection();
27+
28+
/** @Then it should return the host-mapped port */
29+
self::assertSame(49153, $port);
30+
}
31+
32+
#[RunInSeparateProcess]
33+
public function testGetPortForConnectionReturnsExposedPortWhenInsideDocker(): void
34+
{
35+
require_once __DIR__ . '/../Overrides/file_exists_inside_docker.php';
36+
37+
/** @Given Ports with known exposed and host-mapped ports */
38+
$ports = Ports::from(
39+
exposedPorts: Collection::createFrom(elements: [3306]),
40+
hostMappedPorts: Collection::createFrom(elements: [49153])
41+
);
42+
43+
/** @When getPortForConnection is called inside Docker */
44+
$port = $ports->getPortForConnection();
45+
46+
/** @Then it should return the container-internal exposed port */
47+
self::assertSame(3306, $port);
48+
}
49+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TinyBlocks\DockerContainer\Internal\Containers;
6+
7+
function file_exists(string $filename): bool
8+
{
9+
return true;
10+
}

0 commit comments

Comments
 (0)