diff --git a/README.md b/README.md index 8a0200a..1e48365 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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'); ``` @@ -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)); ``` @@ -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'); @@ -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(); ``` @@ -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 @@ -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)); ``` @@ -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(); @@ -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') @@ -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) @@ -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'); @@ -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 @@ -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') @@ -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') diff --git a/src/Contracts/Address.php b/src/Contracts/Address.php index 349c518..92a98b2 100644 --- a/src/Contracts/Address.php +++ b/src/Contracts/Address.php @@ -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; } diff --git a/src/Contracts/Ports.php b/src/Contracts/Ports.php index 62d97f2..3ec8ef3 100644 --- a/src/Contracts/Ports.php +++ b/src/Contracts/Ports.php @@ -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; } diff --git a/src/Internal/Containers/Address/Address.php b/src/Internal/Containers/Address/Address.php index dfc3da4..68d4917 100644 --- a/src/Internal/Containers/Address/Address.php +++ b/src/Internal/Containers/Address/Address.php @@ -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 { @@ -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'; + } } diff --git a/src/Internal/Containers/Address/Ports.php b/src/Internal/Containers/Address/Ports.php index 1227588..1aa4148 100644 --- a/src/Internal/Containers/Address/Ports.php +++ b/src/Internal/Containers/Address/Ports.php @@ -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 @@ -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(); + } } diff --git a/tests/Unit/Internal/Containers/Address/AddressTest.php b/tests/Unit/Internal/Containers/Address/AddressTest.php new file mode 100644 index 0000000..7877322 --- /dev/null +++ b/tests/Unit/Internal/Containers/Address/AddressTest.php @@ -0,0 +1,60 @@ +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); + } +} diff --git a/tests/Unit/Internal/Containers/Address/PortsTest.php b/tests/Unit/Internal/Containers/Address/PortsTest.php new file mode 100644 index 0000000..75a056a --- /dev/null +++ b/tests/Unit/Internal/Containers/Address/PortsTest.php @@ -0,0 +1,49 @@ +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); + } +} diff --git a/tests/Unit/Internal/Containers/Overrides/file_exists_inside_docker.php b/tests/Unit/Internal/Containers/Overrides/file_exists_inside_docker.php new file mode 100644 index 0000000..5b308c1 --- /dev/null +++ b/tests/Unit/Internal/Containers/Overrides/file_exists_inside_docker.php @@ -0,0 +1,10 @@ +