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
63 changes: 56 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
* [Configuring MySQL options](#configuring-mysql-options)
* [Setting readiness timeout](#setting-readiness-timeout)
* [Retrieving connection data](#retrieving-connection-data)
* [Environment-aware connection](#environment-aware-connection)
* [Flyway container](#flyway-container)
* [Setting the database source](#setting-the-database-source)
* [Configuring migrations](#configuring-migrations)
Expand Down Expand Up @@ -54,6 +55,8 @@ composer require tiny-blocks/docker-container
Creates a container from a specified image and an optional name.

```php
use TinyBlocks\DockerContainer\GenericDockerContainer;

$container = GenericDockerContainer::from(image: 'php:8.5-fpm', name: 'my-container');
```

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

```php
use TinyBlocks\DockerContainer\Waits\ContainerWaitForTime;

$container->run(commands: ['ls', '-la'], waitAfterStarted: ContainerWaitForTime::forSeconds(seconds: 5));
```

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

```php
use TinyBlocks\DockerContainer\MySQLDockerContainer;
use TinyBlocks\DockerContainer\FlywayDockerContainer;

$mysql = MySQLDockerContainer::from(image: 'mysql:8.4', name: 'my-database')
->pullImage()
->withRootPassword(rootPassword: 'root');
Expand All @@ -103,11 +111,11 @@ $flyway = FlywayDockerContainer::from(image: 'flyway/flyway:12-alpine')
->pullImage()
->withMigrations(pathOnHost: '/path/to/migrations');

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

// Flyway pull already finished while MySQL was starting.
# Flyway pull already finished while MySQL was starting.
$flyway->withSource(container: $mySQLStarted, username: 'root', password: 'root')
->cleanAndMigrate();
```
Expand Down Expand Up @@ -135,8 +143,8 @@ 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)
$ports->firstExposedPort(); # 80 (container-internal)
$ports->firstHostPort(); # 8080 (host-accessible)
```

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

```php
use TinyBlocks\DockerContainer\Waits\ContainerWaitForTime;

$container->withWaitBeforeRun(wait: ContainerWaitForTime::forSeconds(seconds: 3));
```

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

```php
use TinyBlocks\DockerContainer\GenericDockerContainer;
use TinyBlocks\DockerContainer\MySQLDockerContainer;
use TinyBlocks\DockerContainer\Waits\ContainerWaitForDependency;
use TinyBlocks\DockerContainer\Waits\Conditions\MySQLReady;

$mySQLStarted = MySQLDockerContainer::from(image: 'mysql:8.4')
->withRootPassword(rootPassword: 'root')
->run();
Expand Down Expand Up @@ -267,6 +282,8 @@ MySQL-specific configuration and automatic readiness detection.
| `withGrantedHosts` | `$hosts` | Sets hosts granted root privileges (default: `['%', '172.%']`). |

```php
use TinyBlocks\DockerContainer\MySQLDockerContainer;

$mySQLContainer = MySQLDockerContainer::from(image: 'mysql:8.4', name: 'my-database')
->withTimezone(timezone: 'America/Sao_Paulo')
->withUsername(user: 'app_user')
Expand All @@ -284,6 +301,8 @@ Configures how long the MySQL container waits for the database to become ready b
`ContainerWaitTimeout` exception. The default timeout is 30 seconds.

```php
use TinyBlocks\DockerContainer\MySQLDockerContainer;

$mySQLContainer = MySQLDockerContainer::from(image: 'mysql:8.4', name: 'my-database')
->withRootPassword(rootPassword: 'root')
->withReadinessTimeout(timeoutInSeconds: 60)
Expand All @@ -300,8 +319,8 @@ $ip = $address->getIp();
$hostname = $address->getHostname();

$ports = $address->getPorts();
$containerPort = $ports->firstExposedPort(); // e.g. 3306 (container-internal)
$hostPort = $ports->firstHostPort(); // e.g. 49153 (host-accessible)
$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');
Expand All @@ -314,6 +333,31 @@ $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).

### Environment-aware connection

The `Address` and `Ports` contracts provide environment-aware methods that automatically resolve the correct host and
port for connecting to a container. These methods detect whether the caller is running inside Docker or on the host
machine:

```php
use TinyBlocks\DockerContainer\MySQLDockerContainer;

$mySQLContainer = MySQLDockerContainer::from(image: 'mysql:8.4', name: 'my-database')
->withRootPassword(rootPassword: 'root')
->withDatabase(database: 'my_database')
->withPortMapping(portOnHost: 3306, portOnContainer: 3306);

$started = $mySQLContainer->runIfNotExists();
$address = $started->getAddress();

$host = $address->getHostForConnection(); # hostname inside Docker, 127.0.0.1 on host
$port = $address->getPorts()->getPortForConnection(); # container port inside Docker, host-mapped port on host
```

This is useful when the same test suite runs both locally (inside a Docker Compose stack) and in CI (on the host).
Instead of manually checking the environment and switching between `getHostname()`/`getIp()` or `firstExposedPort()`/
`firstHostPort()`, the environment-aware methods handle it transparently.

## Flyway container

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

```php
use TinyBlocks\DockerContainer\FlywayDockerContainer;

$flywayContainer = FlywayDockerContainer::from(image: 'flyway/flyway:12-alpine')
->withNetwork(name: 'my-network')
->withMigrations(pathOnHost: '/path/to/migrations')
Expand Down Expand Up @@ -385,6 +431,9 @@ $flywayContainer->cleanAndMigrate();
Configure both containers and start image pulls in parallel before running either one:

```php
use TinyBlocks\DockerContainer\MySQLDockerContainer;
use TinyBlocks\DockerContainer\FlywayDockerContainer;

$mySQLContainer = MySQLDockerContainer::from(image: 'mysql:8.4', name: 'test-database')
->pullImage()
->withNetwork(name: 'my-network')
Expand Down
11 changes: 11 additions & 0 deletions src/Contracts/Address.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,15 @@ public function getPorts(): Ports;
* @return string The container's hostname.
*/
public function getHostname(): string;

/**
* Returns the appropriate host address for connecting to the container.
*
* When running inside Docker (e.g., from another container), returns the container's hostname,
* which is resolvable within the Docker network. When running on the host (e.g., in CI or local
* development outside Docker), returns 127.0.0.1, since the container is accessible via port mapping.
*
* @return string The host address to use for connection.
*/
public function getHostForConnection(): string;
Comment thread
gustavofreze marked this conversation as resolved.
}
11 changes: 11 additions & 0 deletions src/Contracts/Ports.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,15 @@ public function firstExposedPort(): ?int;
* @return int|null The first host-mapped port number, or null if none.
*/
public function firstHostPort(): ?int;

/**
* Returns the appropriate port for connecting to the container.
*
* When running inside Docker (e.g., from another container), returns the first exposed
* (container-internal) port. When running on the host (e.g., in CI or local development
* outside Docker), returns the first host-mapped port.
*
* @return int|null The port to use for connection, or null if unavailable.
*/
public function getPortForConnection(): ?int;
}
8 changes: 8 additions & 0 deletions src/Internal/Containers/Address/Address.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use TinyBlocks\DockerContainer\Contracts\Address as ContainerAddress;
use TinyBlocks\DockerContainer\Contracts\Ports as ContainerPorts;
use TinyBlocks\DockerContainer\Internal\Containers\HostEnvironment;

final readonly class Address implements ContainerAddress
{
Expand All @@ -32,4 +33,11 @@ public function getHostname(): string
{
return $this->hostname->value;
}

public function getHostForConnection(): string
{
return HostEnvironment::isInsideDocker()
? $this->hostname->value
: '127.0.0.1';
}
}
8 changes: 8 additions & 0 deletions src/Internal/Containers/Address/Ports.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use TinyBlocks\Collection\Collection;
use TinyBlocks\DockerContainer\Contracts\Ports as ContainerPorts;
use TinyBlocks\DockerContainer\Internal\Containers\HostEnvironment;
use TinyBlocks\Mapper\KeyPreservation;

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

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

public function getPortForConnection(): ?int
{
return HostEnvironment::isInsideDocker()
? $this->firstExposedPort()
: $this->firstHostPort();
}
}
60 changes: 60 additions & 0 deletions tests/Unit/Internal/Containers/Address/AddressTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<?php

declare(strict_types=1);

namespace Test\Unit\Internal\Containers\Address;

use PHPUnit\Framework\Attributes\RunInSeparateProcess;
use PHPUnit\Framework\TestCase;
use TinyBlocks\Collection\Collection;
use TinyBlocks\DockerContainer\Internal\Containers\Address\Address;
use TinyBlocks\DockerContainer\Internal\Containers\Address\Hostname;
use TinyBlocks\DockerContainer\Internal\Containers\Address\IP;
use TinyBlocks\DockerContainer\Internal\Containers\Address\Ports;

final class AddressTest extends TestCase
{
#[RunInSeparateProcess]
public function testGetHostForConnectionReturnsLocalhostWhenOutsideDocker(): void
{
require_once __DIR__ . '/../Overrides/file_exists_outside_docker.php';

/** @Given an Address with a known hostname */
$address = Address::from(
ip: IP::from(value: '172.17.0.2'),
ports: Ports::from(
exposedPorts: Collection::createFrom(elements: [3306]),
hostMappedPorts: Collection::createFrom(elements: [49153])
),
hostname: Hostname::from(value: 'my-container')
);

/** @When getHostForConnection is called outside Docker */
$host = $address->getHostForConnection();

/** @Then it should return 127.0.0.1 */
self::assertSame('127.0.0.1', $host);
}

#[RunInSeparateProcess]
public function testGetHostForConnectionReturnsHostnameWhenInsideDocker(): void
{
require_once __DIR__ . '/../Overrides/file_exists_inside_docker.php';

/** @Given an Address with a known hostname */
$address = Address::from(
ip: IP::from(value: '172.17.0.2'),
ports: Ports::from(
exposedPorts: Collection::createFrom(elements: [3306]),
hostMappedPorts: Collection::createFrom(elements: [49153])
),
hostname: Hostname::from(value: 'my-container')
);

/** @When getHostForConnection is called inside Docker */
$host = $address->getHostForConnection();

/** @Then it should return the container hostname */
self::assertSame('my-container', $host);
}
}
49 changes: 49 additions & 0 deletions tests/Unit/Internal/Containers/Address/PortsTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php

declare(strict_types=1);

namespace Test\Unit\Internal\Containers\Address;

use PHPUnit\Framework\Attributes\RunInSeparateProcess;
use PHPUnit\Framework\TestCase;
use TinyBlocks\Collection\Collection;
use TinyBlocks\DockerContainer\Internal\Containers\Address\Ports;

final class PortsTest extends TestCase
{
#[RunInSeparateProcess]
public function testGetPortForConnectionReturnsHostPortWhenOutsideDocker(): void
{
require_once __DIR__ . '/../Overrides/file_exists_outside_docker.php';

/** @Given Ports with known exposed and host-mapped ports */
$ports = Ports::from(
exposedPorts: Collection::createFrom(elements: [3306]),
hostMappedPorts: Collection::createFrom(elements: [49153])
);

/** @When getPortForConnection is called outside Docker */
$port = $ports->getPortForConnection();

/** @Then it should return the host-mapped port */
self::assertSame(49153, $port);
}

#[RunInSeparateProcess]
public function testGetPortForConnectionReturnsExposedPortWhenInsideDocker(): void
{
require_once __DIR__ . '/../Overrides/file_exists_inside_docker.php';

/** @Given Ports with known exposed and host-mapped ports */
$ports = Ports::from(
exposedPorts: Collection::createFrom(elements: [3306]),
hostMappedPorts: Collection::createFrom(elements: [49153])
);

/** @When getPortForConnection is called inside Docker */
$port = $ports->getPortForConnection();

/** @Then it should return the container-internal exposed port */
self::assertSame(3306, $port);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

declare(strict_types=1);

namespace TinyBlocks\DockerContainer\Internal\Containers;

function file_exists(string $filename): bool
{
return true;
}
Comment thread
gustavofreze marked this conversation as resolved.
Loading