diff --git a/Makefile b/Makefile
index c87ae0a..75c08aa 100644
--- a/Makefile
+++ b/Makefile
@@ -51,7 +51,7 @@ review: ## Run static code analysis
.PHONY: show-reports
show-reports: ## Open static analysis reports (e.g., coverage, lints) in the browser
- @sensible-browser report/coverage/coverage-html/index.html report/coverage/mutation-report.html
+ @sensible-browser report/coverage/coverage-html/index.html
.PHONY: clean
clean: ## Remove dependencies and generated artifacts
diff --git a/README.md b/README.md
index 0328e59..c7cb273 100644
--- a/README.md
+++ b/README.md
@@ -44,8 +44,7 @@ composer require tiny-blocks/docker-container
### Creating a container
Creates a container from a specified image and optionally a name.
-The `from` method can be used to initialize a new container instance with an image and an optional name for
-identification.
+The `from` method initializes a new container instance with an image and an optional name for identification.
```php
$container = GenericDockerContainer::from(image: 'php:8.3-fpm', name: 'my-container');
@@ -78,19 +77,11 @@ $container->run(commands: ['ls', '-la'], waitAfterStarted: ContainerWaitForTime:
### Running a container if it doesn't exist
The `runIfNotExists` method starts a container only if it doesn't already exist.
-Optionally, it allows you to execute commands within the container after it has started and define a condition to wait
-for using a `ContainerWaitAfterStarted` instance.
```php
$container->runIfNotExists();
```
-**Example with commands only:**
-
-```php
-$container->runIfNotExists(commands: ['ls', '-la']);
-```
-
**Example with commands and a wait condition:**
```php
@@ -99,8 +90,7 @@ $container->runIfNotExists(commands: ['ls', '-la'], waitAfterStarted: ContainerW
### Setting network
-The `withNetwork` method connects the container to a specified Docker network by name, allowing you to define the
-network configuration the container will use.
+The `withNetwork` method connects the container to a specified Docker network by name.
```php
$container->withNetwork(name: 'my-network');
@@ -108,17 +98,16 @@ $container->withNetwork(name: 'my-network');
### Setting port mappings
-Maps ports between the host and the container.
-The `withPortMapping` method maps a port from the host to a port inside the container.
+Maps ports between the host and the container. Multiple port mappings are supported.
```php
$container->withPortMapping(portOnHost: 9000, portOnContainer: 9000);
+$container->withPortMapping(portOnHost: 8080, portOnContainer: 80);
```
### Setting volumes mappings
Maps a volume from the host to the container.
-The `withVolumeMapping` method allows you to link a directory from the host to the container.
```php
$container->withVolumeMapping(pathOnHost: '/path/on/host', pathOnContainer: '/path/in/container');
@@ -127,7 +116,6 @@ $container->withVolumeMapping(pathOnHost: '/path/on/host', pathOnContainer: '/pa
### Setting environment variables
Sets environment variables inside the container.
-The `withEnvironmentVariable` method allows you to configure environment variables within the container.
```php
$container->withEnvironmentVariable(key: 'XPTO', value: '123');
@@ -136,9 +124,6 @@ $container->withEnvironmentVariable(key: 'XPTO', value: '123');
### Disabling auto-remove
Prevents the container from being automatically removed when stopped.
-By default, Docker removes containers after they stop.
-The `withoutAutoRemove` method disables this feature, keeping the container around even after it finishes its
-execution.
```php
$container->withoutAutoRemove();
@@ -147,7 +132,6 @@ $container->withoutAutoRemove();
### Copying files to a container
Copies files or directories from the host machine to the container.
-The `copyToContainer` method allows you to transfer files from the host system into the container’s file system.
```php
$container->copyToContainer(pathOnHost: '/path/to/files', pathOnContainer: '/path/in/container');
@@ -156,10 +140,26 @@ $container->copyToContainer(pathOnHost: '/path/to/files', pathOnContainer: '/pat
### Waiting for a condition
The `withWaitBeforeRun` method allows the container to pause its execution until a specified condition is met before
-starting.
+starting. A timeout prevents the wait from blocking indefinitely.
+
+```php
+$container->withWaitBeforeRun(
+ wait: ContainerWaitForDependency::untilReady(
+ condition: MySQLReady::from(container: $container),
+ timeoutInSeconds: 30
+ )
+);
+```
+
+### Setting readiness timeout for MySQL
+
+The `withReadinessTimeout` method configures how long the MySQL container will wait for the database to become ready
+before throwing a `ContainerWaitTimeout` exception. The default timeout is 30 seconds.
```php
-$container->withWaitBeforeRun(wait: ContainerWaitForDependency::untilReady(condition: MySQLReady::from(container: $container)));
+$container = MySQLDockerContainer::from(image: 'mysql:8.1', name: 'my-database')
+ ->withReadinessTimeout(timeoutInSeconds: 60)
+ ->run();
```
@@ -199,7 +199,7 @@ The following commands are used to prepare the environment:
-v ${PWD}:/app \
-v ${PWD}/tests/Integration/Database/Migrations:/test-adm-migrations \
-v /var/run/docker.sock:/var/run/docker.sock \
- -w /app gustavofreze/php:8.3 bash -c "composer tests"
+ -w /app gustavofreze/php:8.5-alpine bash -c "composer tests"
```
The MySQL container is configured and started:
@@ -214,7 +214,7 @@ $mySQLContainer = MySQLDockerContainer::from(image: 'mysql:8.1', name: 'test-dat
->withPortMapping(portOnHost: 3306, portOnContainer: 3306)
->withRootPassword(rootPassword: 'root')
->withGrantedHosts()
- ->withVolumeMapping(pathOnHost: '/var/lib/mysql', pathOnContainer: '/var/lib/mysql')
+ ->withReadinessTimeout(timeoutInSeconds: 60)
->withoutAutoRemove()
->runIfNotExists();
```
@@ -240,7 +240,8 @@ $flywayContainer = GenericDockerContainer::from(image: 'flyway/flyway:11.0.0')
wait: ContainerWaitForDependency::untilReady(
condition: MySQLReady::from(
container: $mySQLContainer
- )
+ ),
+ timeoutInSeconds: 30
)
)
->withEnvironmentVariable(key: 'FLYWAY_URL', value: $jdbcUrl)
diff --git a/composer.json b/composer.json
index eba884d..d4b11de 100644
--- a/composer.json
+++ b/composer.json
@@ -42,8 +42,7 @@
"php": "^8.5",
"symfony/process": "^7.4",
"tiny-blocks/ksuid": "^1.5",
- "tiny-blocks/mapper": "^2.0",
- "tiny-blocks/collection": "^1.15"
+ "tiny-blocks/collection": "^2.0"
},
"require-dev": {
"ext-pdo": "*",
diff --git a/src/Contracts/Address.php b/src/Contracts/Address.php
index 6dc68ee..349c518 100644
--- a/src/Contracts/Address.php
+++ b/src/Contracts/Address.php
@@ -5,35 +5,26 @@
namespace TinyBlocks\DockerContainer\Contracts;
/**
- * Defines the network configuration of a running Docker container.
+ * Represents the network address of a running Docker container.
*/
interface Address
{
/**
- * Returns the IP address of the running container.
- *
- * The IP address is available for containers running in the following network modes:
- * - `BRIDGE`: IP address is assigned and accessible within the bridge network.
- * - `IPVLAN`: IP address is assigned and accessible within the ipvlan network.
- * - `OVERLAY`: IP address is assigned and accessible within an overlay network.
- * - `MACVLAN`: IP address is assigned and accessible within a macvlan network.
- *
- * For containers running in the `HOST` network mode:
- * - The IP address is `127.0.0.1` (localhost) on the host machine.
+ * Returns the IP address assigned to the container.
*
* @return string The container's IP address.
*/
public function getIp(): string;
/**
- * Returns the network ports configuration for the running container.
+ * Returns the port mappings exposed by the container.
*
- * @return Ports The container's network ports.
+ * @return Ports The container's exposed ports.
*/
public function getPorts(): Ports;
/**
- * Returns the hostname of the running container.
+ * Returns the hostname of the container.
*
* @return string The container's hostname.
*/
diff --git a/src/Contracts/ContainerStarted.php b/src/Contracts/ContainerStarted.php
index 66d3404..0000e19 100644
--- a/src/Contracts/ContainerStarted.php
+++ b/src/Contracts/ContainerStarted.php
@@ -7,37 +7,40 @@
use TinyBlocks\DockerContainer\Internal\Exceptions\DockerCommandExecutionFailed;
/**
- * Defines the operations available for a Docker container that has been started.
+ * Represents a Docker container that has been started and is running.
*/
interface ContainerStarted
{
+ /**
+ * Default timeout in whole seconds used when stopping the container.
+ */
public const int DEFAULT_TIMEOUT_IN_WHOLE_SECONDS = 300;
/**
- * Returns the ID of the running container.
+ * Returns the unique identifier of the container.
*
- * @return string The container's ID.
+ * @return string The container ID.
*/
public function getId(): string;
/**
- * Returns the name of the running container.
+ * Returns the name assigned to the container.
*
- * @return string The container's name.
+ * @return string The container name.
*/
public function getName(): string;
/**
- * Returns the network address of the running container.
+ * Returns the network address of the container.
*
* @return Address The container's network address.
*/
public function getAddress(): Address;
/**
- * Returns the environment variables of the running container.
+ * Returns the environment variables configured in the container.
*
- * @return EnvironmentVariables The environment variables of the container.
+ * @return EnvironmentVariables The container's environment variables.
*/
public function getEnvironmentVariables(): EnvironmentVariables;
@@ -45,16 +48,15 @@ public function getEnvironmentVariables(): EnvironmentVariables;
* Stops the running container.
*
* @param int $timeoutInWholeSeconds The maximum time in seconds to wait for the container to stop.
- * Default is {@see DEFAULT_TIMEOUT_IN_WHOLE_SECONDS} seconds.
* @return ExecutionCompleted The result of the stop command execution.
- * @throws DockerCommandExecutionFailed If the stop command fails to execute.
+ * @throws DockerCommandExecutionFailed If the stop command fails.
*/
public function stop(int $timeoutInWholeSeconds = self::DEFAULT_TIMEOUT_IN_WHOLE_SECONDS): ExecutionCompleted;
/**
- * Executes commands inside the running container after it has been started.
+ * Executes commands inside the running container.
*
- * @param array $commands The commands to execute inside the container.
+ * @param array $commands The commands to execute inside the container.
* @return ExecutionCompleted The result of the command execution.
* @throws DockerCommandExecutionFailed If the command execution fails.
*/
diff --git a/src/Contracts/EnvironmentVariables.php b/src/Contracts/EnvironmentVariables.php
index 42da55f..e7a3d95 100644
--- a/src/Contracts/EnvironmentVariables.php
+++ b/src/Contracts/EnvironmentVariables.php
@@ -5,15 +5,15 @@
namespace TinyBlocks\DockerContainer\Contracts;
/**
- * Defines the environment variables configuration of a running Docker container.
+ * Represents the environment variables configured in a Docker container.
*/
interface EnvironmentVariables
{
/**
- * Retrieves the value of an environment variable by its key.
+ * Returns the value of an environment variable by its key.
*
- * @param string $key The key of the environment variable.
- * @return string The value of the environment variable.
+ * @param string $key The name of the environment variable.
+ * @return string The value of the environment variable, or an empty string if not found.
*/
public function getValueBy(string $key): string;
}
diff --git a/src/Contracts/ExecutionCompleted.php b/src/Contracts/ExecutionCompleted.php
index ce25288..59a07f9 100644
--- a/src/Contracts/ExecutionCompleted.php
+++ b/src/Contracts/ExecutionCompleted.php
@@ -5,21 +5,21 @@
namespace TinyBlocks\DockerContainer\Contracts;
/**
- * Represents the result of a completed command execution.
+ * Represents the result of a Docker command execution.
*/
interface ExecutionCompleted
{
/**
- * Returns the output of the executed command.
+ * Returns the output produced by the executed command.
*
- * @return string The command output.
+ * @return string The standard output on success, or the error output on failure.
*/
public function getOutput(): string;
/**
- * Returns whether the command execution was successful.
+ * Indicates whether the command execution was successful.
*
- * @return bool True if the command was successful, false otherwise.
+ * @return bool True if the execution was successful, false otherwise.
*/
public function isSuccessful(): bool;
}
diff --git a/src/Contracts/MySQL/MySQLContainerStarted.php b/src/Contracts/MySQL/MySQLContainerStarted.php
index 285482f..bd2959a 100644
--- a/src/Contracts/MySQL/MySQLContainerStarted.php
+++ b/src/Contracts/MySQL/MySQLContainerStarted.php
@@ -7,12 +7,14 @@
use TinyBlocks\DockerContainer\Contracts\ContainerStarted;
/**
- * Extends the functionality of a started container to include MySQL-specific operations.
+ * Represents a MySQL Docker container that has been started and is running.
*/
interface MySQLContainerStarted extends ContainerStarted
{
/**
- * Default JDBC options for connecting to the MySQL container.
+ * Default JDBC connection options for MySQL.
+ *
+ * @var array
*/
public const array DEFAULT_JDBC_OPTIONS = [
'useSSL' => 'false',
@@ -22,14 +24,10 @@ interface MySQLContainerStarted extends ContainerStarted
];
/**
- * Generates and returns a JDBC URL for connecting to the MySQL container.
- *
- * The URL is built using the container's hostname, port, and database name,
- * with optional query parameters for additional configurations.
+ * Returns the JDBC connection URL for the MySQL container.
*
- * @param array $options An array of key-value pairs to append to the JDBC URL.
- * Defaults to {@see DEFAULT_JDBC_OPTIONS}.
- * @return string The generated JDBC URL.
+ * @param array $options JDBC connection options appended as query parameters.
+ * @return string The fully constructed JDBC URL.
*/
public function getJdbcUrl(array $options = self::DEFAULT_JDBC_OPTIONS): string;
}
diff --git a/src/Contracts/Ports.php b/src/Contracts/Ports.php
index 6d6ba5a..cf20432 100644
--- a/src/Contracts/Ports.php
+++ b/src/Contracts/Ports.php
@@ -5,22 +5,21 @@
namespace TinyBlocks\DockerContainer\Contracts;
/**
- * Defines the port's configuration of a Docker container.
+ * Represents the port mappings exposed by a Docker container.
*/
interface Ports
{
/**
- * Returns an array of all exposed ports of the container.
+ * Returns all exposed ports mapped to the host.
*
- * @return array An associative array where keys are the container's exposed ports
- * and values are the corresponding ports on the host machine.
+ * @return array The list of exposed port numbers.
*/
public function exposedPorts(): array;
/**
- * Returns the first exposed port of the container.
+ * Returns the first exposed port, or null if no ports are exposed.
*
- * @return int|null The first exposed port of the container, or null if no ports are exposed.
+ * @return int|null The first exposed port number, or null if none.
*/
public function firstExposedPort(): ?int;
}
diff --git a/src/DockerContainer.php b/src/DockerContainer.php
index 95de188..bfe28f5 100644
--- a/src/DockerContainer.php
+++ b/src/DockerContainer.php
@@ -10,53 +10,38 @@
use TinyBlocks\DockerContainer\Waits\ContainerWaitBeforeStarted;
/**
- * Defines operations for creating and managing Docker containers.
+ * Defines the contract for building and running a Docker container.
*/
interface DockerContainer
{
/**
- * Creates an instance of a Docker container from an image and an optional name.
+ * Creates a new container instance from the given Docker image.
*
- * @param string $image The name of the Docker image to be used.
- * @param string|null $name The optional name for the container.
- * @return DockerContainer The created container instance.
+ * @param string $image The Docker image name (e.g., "mysql:8.1").
+ * @param string|null $name An optional name for the container.
+ * @return static A new container instance.
*/
-
- public static function from(string $image, ?string $name = null): DockerContainer;
+ public static function from(string $image, ?string $name = null): static;
/**
- * Starts the container and runs the provided commands.
- *
- * Optionally, wait for a condition to be met after the container is started, using a
- * `ContainerWaitAfterStarted` instance.
- * This can be useful if you need to wait for specific events (e.g., log output or readiness) before proceeding.
+ * Runs the container, optionally executing commands after startup.
*
- * @param array $commands Commands to be executed after the container is started.
- * @param ContainerWaitAfterStarted|null $waitAfterStarted A `ContainerWaitAfterStarted` instance that defines the
- * condition to wait for after the container starts.
- * Default to null if no wait is required.
- * @return ContainerStarted The started container.
- * @throws DockerCommandExecutionFailed If the execution of the Docker command fails.
+ * @param array $commands Commands to execute on container startup.
+ * @param ContainerWaitAfterStarted|null $waitAfterStarted Optional wait strategy applied after
+ * the container starts.
+ * @return ContainerStarted The started container instance.
+ * @throws DockerCommandExecutionFailed If the run command fails.
*/
public function run(array $commands = [], ?ContainerWaitAfterStarted $waitAfterStarted = null): ContainerStarted;
/**
- * Starts the container and runs the provided commands if it does not already exist.
- *
- * If the container doesn't exist, it will be created and started with the provided commands.
- * If the container already exists, no action will be taken.
- *
- * Optionally, wait for a condition to be met after the container is started, using a
- * `ContainerWaitAfterStarted` instance.
- * This can be useful if you need to wait for specific events (e.g., log output or readiness) before proceeding.
+ * Runs the container only if a container with the same name does not already exist.
*
- * @param array $commands Commands to be executed after the container is started if it doesn't
- * already exist.
- * @param ContainerWaitAfterStarted|null $waitAfterStarted A `ContainerWaitAfterStarted` instance that defines the
- * condition to wait for after the container starts.
- * Default to null if no wait is required.
- * @return ContainerStarted The started container.
- * @throws DockerCommandExecutionFailed If the execution of the Docker command fails.
+ * @param array $commands Commands to execute on container startup.
+ * @param ContainerWaitAfterStarted|null $waitAfterStarted Optional wait strategy applied after
+ * the container starts.
+ * @return ContainerStarted The started container instance (existing or newly created).
+ * @throws DockerCommandExecutionFailed If the run command fails.
*/
public function runIfNotExists(
array $commands = [],
@@ -64,61 +49,61 @@ public function runIfNotExists(
): ContainerStarted;
/**
- * Copies files or directories from the host to the container.
+ * Registers a file or directory to be copied into the container after it starts.
*
- * @param string $pathOnHost The path on the host where the files/directories are located.
- * @param string $pathOnContainer The path on the container where the files/directories will be copied.
- * @return DockerContainer The container instance with the copied files.
+ * @param string $pathOnHost The absolute path on the host.
+ * @param string $pathOnContainer The target path inside the container.
+ * @return static The current container instance for method chaining.
*/
- public function copyToContainer(string $pathOnHost, string $pathOnContainer): DockerContainer;
+ public function copyToContainer(string $pathOnHost, string $pathOnContainer): static;
/**
* Connects the container to a specific Docker network.
*
- * @param string $name The name of the Docker network to connect the container to.
- * @return DockerContainer The container instance with the network configuration applied.
+ * @param string $name The name of the Docker network.
+ * @return static The current container instance for method chaining.
*/
- public function withNetwork(string $name): DockerContainer;
+ public function withNetwork(string $name): static;
/**
- * Maps a port from the host to the container.
+ * Adds a port mapping between the host and the container.
*
- * @param int $portOnHost The port on the host to be mapped.
- * @param int $portOnContainer The port on the container for the mapping.
- * @return DockerContainer The container instance with the mapped port.
+ * @param int $portOnHost The port on the host machine.
+ * @param int $portOnContainer The port inside the container.
+ * @return static The current container instance for method chaining.
*/
- public function withPortMapping(int $portOnHost, int $portOnContainer): DockerContainer;
+ public function withPortMapping(int $portOnHost, int $portOnContainer): static;
/**
- * Sets the wait condition to be applied before running the container.
+ * Sets a wait strategy to be applied before the container runs.
*
- * @param ContainerWaitBeforeStarted $wait The wait condition to apply before running the container.
- * @return DockerContainer The container instance with the wait condition before run.
+ * @param ContainerWaitBeforeStarted $wait The wait strategy to apply before starting.
+ * @return static The current container instance for method chaining.
*/
- public function withWaitBeforeRun(ContainerWaitBeforeStarted $wait): DockerContainer;
+ public function withWaitBeforeRun(ContainerWaitBeforeStarted $wait): static;
/**
- * Sets whether the container should not be automatically removed after stopping.
+ * Disables automatic removal of the container when it stops.
*
- * @return DockerContainer The container instance with the auto-remove setting disabled.
+ * @return static The current container instance for method chaining.
*/
- public function withoutAutoRemove(): DockerContainer;
+ public function withoutAutoRemove(): static;
/**
- * Maps a volume from the host to the container.
+ * Adds a volume mapping between the host and the container.
*
- * @param string $pathOnHost The path of the volume on the host.
- * @param string $pathOnContainer The path on the container where the volume will be mapped.
- * @return DockerContainer The container instance with the mapped volume.
+ * @param string $pathOnHost The absolute path on the host.
+ * @param string $pathOnContainer The target path inside the container.
+ * @return static The current container instance for method chaining.
*/
- public function withVolumeMapping(string $pathOnHost, string $pathOnContainer): DockerContainer;
+ public function withVolumeMapping(string $pathOnHost, string $pathOnContainer): static;
/**
- * Sets an environment variable for the container.
+ * Adds an environment variable to the container.
*
- * @param string $key The key of the environment variable.
- * @param string $value The value of the environment variable.
- * @return DockerContainer The container instance with the environment variable configured.
+ * @param string $key The environment variable name.
+ * @param string $value The environment variable value.
+ * @return static The current container instance for method chaining.
*/
- public function withEnvironmentVariable(string $key, string $value): DockerContainer;
+ public function withEnvironmentVariable(string $key, string $value): static;
}
diff --git a/src/GenericDockerContainer.php b/src/GenericDockerContainer.php
index 3bb73bb..1ee1cf3 100644
--- a/src/GenericDockerContainer.php
+++ b/src/GenericDockerContainer.php
@@ -6,82 +6,42 @@
use TinyBlocks\DockerContainer\Contracts\ContainerStarted;
use TinyBlocks\DockerContainer\Internal\Client\DockerClient;
-use TinyBlocks\DockerContainer\Internal\Commands\DockerCopy;
-use TinyBlocks\DockerContainer\Internal\Commands\DockerList;
+use TinyBlocks\DockerContainer\Internal\CommandHandler\CommandHandler;
+use TinyBlocks\DockerContainer\Internal\CommandHandler\ContainerCommandHandler;
use TinyBlocks\DockerContainer\Internal\Commands\DockerRun;
-use TinyBlocks\DockerContainer\Internal\Commands\Options\CommandOptions;
-use TinyBlocks\DockerContainer\Internal\Commands\Options\EnvironmentVariableOption;
-use TinyBlocks\DockerContainer\Internal\Commands\Options\ItemToCopyOption;
-use TinyBlocks\DockerContainer\Internal\Commands\Options\NetworkOption;
-use TinyBlocks\DockerContainer\Internal\Commands\Options\PortOption;
-use TinyBlocks\DockerContainer\Internal\Commands\Options\SimpleCommandOption;
-use TinyBlocks\DockerContainer\Internal\Commands\Options\VolumeOption;
-use TinyBlocks\DockerContainer\Internal\ContainerCommandHandler;
-use TinyBlocks\DockerContainer\Internal\Containers\Models\Container;
-use TinyBlocks\DockerContainer\Internal\Containers\Started;
+use TinyBlocks\DockerContainer\Internal\Containers\Definitions\ContainerDefinition;
use TinyBlocks\DockerContainer\Waits\ContainerWaitAfterStarted;
use TinyBlocks\DockerContainer\Waits\ContainerWaitBeforeStarted;
class GenericDockerContainer implements DockerContainer
{
- private ?PortOption $port = null;
-
- private CommandOptions $items;
-
- private ?NetworkOption $network = null;
-
- private CommandOptions $volumes;
-
- private bool $autoRemove = true;
-
- private ContainerCommandHandler $commandHandler;
+ protected ContainerDefinition $definition;
private ?ContainerWaitBeforeStarted $waitBeforeStarted = null;
- private CommandOptions $environmentVariables;
+ private CommandHandler $commandHandler;
- private function __construct(private readonly Container $container)
+ protected function __construct(ContainerDefinition $definition, CommandHandler $commandHandler)
{
- $this->items = CommandOptions::createFromEmpty();
- $this->volumes = CommandOptions::createFromEmpty();
- $this->environmentVariables = CommandOptions::createFromEmpty();
-
- $this->commandHandler = new ContainerCommandHandler(client: new DockerClient());
+ $this->definition = $definition;
+ $this->commandHandler = $commandHandler;
}
public static function from(string $image, ?string $name = null): static
{
- $container = Container::create(name: $name, image: $image);
+ $definition = ContainerDefinition::create(image: $image, name: $name);
+ $commandHandler = new ContainerCommandHandler(client: new DockerClient());
- return new static(container: $container);
+ return new static(definition: $definition, commandHandler: $commandHandler);
}
public function run(array $commands = [], ?ContainerWaitAfterStarted $waitAfterStarted = null): ContainerStarted
{
$this->waitBeforeStarted?->waitBefore();
- $dockerRun = DockerRun::from(
- commands: $commands,
- container: $this->container,
- port: $this->port,
- network: $this->network,
- volumes: $this->volumes,
- detached: SimpleCommandOption::DETACH,
- autoRemove: $this->autoRemove ? SimpleCommandOption::REMOVE : null,
- environmentVariables: $this->environmentVariables
- );
+ $dockerRun = DockerRun::from(definition: $this->definition, commands: $commands);
+ $containerStarted = $this->commandHandler->run(dockerRun: $dockerRun);
- $container = $this->commandHandler->run(dockerRun: $dockerRun);
-
- $this->items->each(
- actions: function (VolumeOption $volume) use ($container) {
- $item = ItemToCopyOption::from(id: $container->id, volume: $volume);
- $dockerCopy = DockerCopy::from(item: $item);
- $this->commandHandler->execute(command: $dockerCopy);
- }
- );
-
- $containerStarted = new Started(container: $container, commandHandler: $this->commandHandler);
$waitAfterStarted?->waitAfter(containerStarted: $containerStarted);
return $containerStarted;
@@ -91,11 +51,10 @@ public function runIfNotExists(
array $commands = [],
?ContainerWaitAfterStarted $waitAfterStarted = null
): ContainerStarted {
- $dockerList = DockerList::from(container: $this->container);
- $container = $this->commandHandler->findBy(dockerList: $dockerList);
+ $existing = $this->commandHandler->findBy(definition: $this->definition);
- if ($container->hasId()) {
- return new Started(container: $container, commandHandler: $this->commandHandler);
+ if ($existing !== null) {
+ return $existing;
}
return $this->run(commands: $commands, waitAfterStarted: $waitAfterStarted);
@@ -103,22 +62,27 @@ public function runIfNotExists(
public function copyToContainer(string $pathOnHost, string $pathOnContainer): static
{
- $volume = VolumeOption::from(pathOnHost: $pathOnHost, pathOnContainer: $pathOnContainer);
- $this->items->add(elements: $volume);
+ $this->definition = $this->definition->withCopyInstruction(
+ pathOnHost: $pathOnHost,
+ pathOnContainer: $pathOnContainer
+ );
return $this;
}
public function withNetwork(string $name): static
{
- $this->network = NetworkOption::from(name: $name);
+ $this->definition = $this->definition->withNetwork(name: $name);
return $this;
}
public function withPortMapping(int $portOnHost, int $portOnContainer): static
{
- $this->port = PortOption::from(portOnHost: $portOnHost, portOnContainer: $portOnContainer);
+ $this->definition = $this->definition->withPortMapping(
+ portOnHost: $portOnHost,
+ portOnContainer: $portOnContainer
+ );
return $this;
}
@@ -132,23 +96,24 @@ public function withWaitBeforeRun(ContainerWaitBeforeStarted $wait): static
public function withoutAutoRemove(): static
{
- $this->autoRemove = false;
+ $this->definition = $this->definition->withoutAutoRemove();
return $this;
}
public function withVolumeMapping(string $pathOnHost, string $pathOnContainer): static
{
- $volume = VolumeOption::from(pathOnHost: $pathOnHost, pathOnContainer: $pathOnContainer);
- $this->volumes->add(elements: $volume);
+ $this->definition = $this->definition->withVolumeMapping(
+ pathOnHost: $pathOnHost,
+ pathOnContainer: $pathOnContainer
+ );
return $this;
}
public function withEnvironmentVariable(string $key, string $value): static
{
- $environmentVariable = EnvironmentVariableOption::from(key: $key, value: $value);
- $this->environmentVariables->add(elements: $environmentVariable);
+ $this->definition = $this->definition->withEnvironmentVariable(key: $key, value: $value);
return $this;
}
diff --git a/src/Internal/Client/Client.php b/src/Internal/Client/Client.php
index 1a84380..e9d8fe2 100644
--- a/src/Internal/Client/Client.php
+++ b/src/Internal/Client/Client.php
@@ -9,16 +9,16 @@
use TinyBlocks\DockerContainer\Internal\Exceptions\DockerCommandExecutionFailed;
/**
- * Defines the contract for a Docker client that can execute commands inside a container.
+ * Defines the contract for executing Docker commands via the system process.
*/
interface Client
{
/**
* Executes a Docker command and returns the result.
*
- * @param Command $command The command to be executed inside the Docker container.
- * @return ExecutionCompleted The result of executing the command, including any output or errors.
- * @throws DockerCommandExecutionFailed If the command execution fails in the Docker environment.
+ * @param Command $command The Docker command to execute.
+ * @return ExecutionCompleted The result of the command execution.
+ * @throws DockerCommandExecutionFailed If the command execution fails.
*/
public function execute(Command $command): ExecutionCompleted;
}
diff --git a/src/Internal/Client/DockerClient.php b/src/Internal/Client/DockerClient.php
index 61299e5..ced94a8 100644
--- a/src/Internal/Client/DockerClient.php
+++ b/src/Internal/Client/DockerClient.php
@@ -18,7 +18,7 @@ public function execute(Command $command): ExecutionCompleted
$process = Process::fromShellCommandline($command->toCommandLine());
try {
- if (is_a($command, CommandWithTimeout::class)) {
+ if ($command instanceof CommandWithTimeout) {
$process->setTimeout($command->getTimeoutInWholeSeconds());
}
diff --git a/src/Internal/CommandHandler.php b/src/Internal/CommandHandler.php
deleted file mode 100644
index 75b8249..0000000
--- a/src/Internal/CommandHandler.php
+++ /dev/null
@@ -1,45 +0,0 @@
-parser = new InspectResultParser();
+ }
+
+ public function run(DockerRun $dockerRun): ContainerStarted
+ {
+ $executionCompleted = $this->client->execute(command: $dockerRun);
+
+ if (!$executionCompleted->isSuccessful()) {
+ throw DockerCommandExecutionFailed::fromCommand(command: $dockerRun, execution: $executionCompleted);
+ }
+
+ $id = ContainerId::from(value: $executionCompleted->getOutput());
+ $definition = $dockerRun->definition;
+
+ $started = $this->inspect(id: $id, definition: $definition);
+
+ $definition->copyInstructions->each(
+ actions: function (CopyInstruction $instruction) use ($id): void {
+ $this->client->execute(command: DockerCopy::from(instruction: $instruction, id: $id));
+ }
+ );
+
+ return $started;
+ }
+
+ public function findBy(ContainerDefinition $definition): ?ContainerStarted
+ {
+ $dockerList = DockerList::from(name: $definition->name);
+ $executionCompleted = $this->client->execute(command: $dockerList);
+
+ $output = trim($executionCompleted->getOutput());
+
+ if (empty($output)) {
+ return null;
+ }
+
+ $id = ContainerId::from(value: $output);
+
+ return $this->inspect(id: $id, definition: $definition);
+ }
+
+ public function execute(Command $command): ExecutionCompleted
+ {
+ return $this->client->execute(command: $command);
+ }
+
+ private function inspect(ContainerId $id, ContainerDefinition $definition): ContainerStarted
+ {
+ $dockerInspect = DockerInspect::from(id: $id);
+ $executionCompleted = $this->client->execute(command: $dockerInspect);
+
+ $payload = (array)json_decode($executionCompleted->getOutput(), true);
+
+ if (empty(array_filter($payload))) {
+ throw new DockerContainerNotFound(name: $definition->name);
+ }
+
+ $data = $payload[0];
+
+ return new Started(
+ id: $id,
+ name: $definition->name,
+ address: $this->parser->parseAddress(data: $data),
+ environmentVariables: $this->parser->parseEnvironmentVariables(data: $data),
+ commandHandler: $this
+ );
+ }
+}
diff --git a/src/Internal/Commands/Command.php b/src/Internal/Commands/Command.php
index d26bf53..1c42c88 100644
--- a/src/Internal/Commands/Command.php
+++ b/src/Internal/Commands/Command.php
@@ -5,16 +5,14 @@
namespace TinyBlocks\DockerContainer\Internal\Commands;
/**
- * Defines a basic Docker command.
+ * Represents a Docker CLI command that can be converted to a command-line string.
*/
interface Command
{
/**
- * Converts the command to a Docker command line string.
+ * Converts the command to its command-line string representation.
*
- * This method should return a properly formatted string that can be executed in the Docker CLI.
- *
- * @return string The Docker command.
+ * @return string The full command-line string ready for execution.
*/
public function toCommandLine(): string;
}
diff --git a/src/Internal/Commands/CommandWithTimeout.php b/src/Internal/Commands/CommandWithTimeout.php
index fee6ac7..0fc623d 100644
--- a/src/Internal/Commands/CommandWithTimeout.php
+++ b/src/Internal/Commands/CommandWithTimeout.php
@@ -5,14 +5,14 @@
namespace TinyBlocks\DockerContainer\Internal\Commands;
/**
- * Defines a Docker command with a timeout.
+ * Represents a Docker CLI command that supports a configurable timeout.
*/
interface CommandWithTimeout extends Command
{
/**
- * Returns the timeout duration for executing the command.
+ * Returns the maximum time in seconds allowed for the command to complete.
*
- * @return int The timeout duration in whole seconds.
+ * @return int The timeout in whole seconds.
*/
public function getTimeoutInWholeSeconds(): int;
}
diff --git a/src/Internal/Commands/DockerCopy.php b/src/Internal/Commands/DockerCopy.php
index a8d9f5b..1ecac8b 100644
--- a/src/Internal/Commands/DockerCopy.php
+++ b/src/Internal/Commands/DockerCopy.php
@@ -4,26 +4,22 @@
namespace TinyBlocks\DockerContainer\Internal\Commands;
-use TinyBlocks\DockerContainer\Internal\Commands\Options\CommandOption;
-use TinyBlocks\DockerContainer\Internal\Commands\Options\CommandOptions;
+use TinyBlocks\DockerContainer\Internal\Containers\Definitions\CopyInstruction;
+use TinyBlocks\DockerContainer\Internal\Containers\Models\ContainerId;
final readonly class DockerCopy implements Command
{
- use LineBuilder;
-
- private function __construct(private CommandOptions $commandOptions)
+ private function __construct(private CopyInstruction $instruction, private ContainerId $id)
{
}
- public static function from(?CommandOption ...$commandOption): DockerCopy
+ public static function from(CopyInstruction $instruction, ContainerId $id): DockerCopy
{
- $commandOptions = CommandOptions::createFromOptions(...$commandOption);
-
- return new DockerCopy(commandOptions: $commandOptions);
+ return new DockerCopy(instruction: $instruction, id: $id);
}
public function toCommandLine(): string
{
- return $this->buildFrom(template: 'docker cp %s', values: [$this->commandOptions->toArguments()]);
+ return sprintf('docker cp %s', $this->instruction->toCopyArgument(id: $this->id));
}
}
diff --git a/src/Internal/Commands/DockerExecute.php b/src/Internal/Commands/DockerExecute.php
index 86abdd3..e1f46b0 100644
--- a/src/Internal/Commands/DockerExecute.php
+++ b/src/Internal/Commands/DockerExecute.php
@@ -4,31 +4,22 @@
namespace TinyBlocks\DockerContainer\Internal\Commands;
-use TinyBlocks\DockerContainer\Internal\Commands\Options\CommandOptions;
-use TinyBlocks\DockerContainer\Internal\Commands\Options\GenericCommandOption;
+use TinyBlocks\Collection\Collection;
use TinyBlocks\DockerContainer\Internal\Containers\Models\Name;
final readonly class DockerExecute implements Command
{
- use LineBuilder;
-
- private function __construct(private Name $name, private CommandOptions $commandOptions)
+ private function __construct(private Name $name, private Collection $commands)
{
}
- public static function from(Name $name, array $commandOptions): DockerExecute
+ public static function from(Name $name, array $commands): DockerExecute
{
- $commandOption = GenericCommandOption::from(commandOptions: $commandOptions);
- $commandOptions = CommandOptions::createFromOptions(commandOptions: $commandOption);
-
- return new DockerExecute(name: $name, commandOptions: $commandOptions);
+ return new DockerExecute(name: $name, commands: Collection::createFrom(elements: $commands));
}
public function toCommandLine(): string
{
- return $this->buildFrom(
- template: 'docker exec %s %s',
- values: [$this->name->value, $this->commandOptions->toArguments()]
- );
+ return trim(sprintf('docker exec %s %s', $this->name->value, $this->commands->joinToString(separator: ' ')));
}
}
diff --git a/src/Internal/Commands/DockerInspect.php b/src/Internal/Commands/DockerInspect.php
index d2c5607..03249b5 100644
--- a/src/Internal/Commands/DockerInspect.php
+++ b/src/Internal/Commands/DockerInspect.php
@@ -8,8 +8,6 @@
final readonly class DockerInspect implements Command
{
- use LineBuilder;
-
private function __construct(private ContainerId $id)
{
}
@@ -21,6 +19,6 @@ public static function from(ContainerId $id): DockerInspect
public function toCommandLine(): string
{
- return $this->buildFrom(template: 'docker inspect %s', values: [$this->id->value]);
+ return sprintf('docker inspect %s', $this->id->value);
}
}
diff --git a/src/Internal/Commands/DockerList.php b/src/Internal/Commands/DockerList.php
index 993c5df..d3913cc 100644
--- a/src/Internal/Commands/DockerList.php
+++ b/src/Internal/Commands/DockerList.php
@@ -4,39 +4,21 @@
namespace TinyBlocks\DockerContainer\Internal\Commands;
-use TinyBlocks\DockerContainer\Internal\Commands\Options\CommandOption;
-use TinyBlocks\DockerContainer\Internal\Commands\Options\CommandOptions;
-use TinyBlocks\DockerContainer\Internal\Commands\Options\SimpleCommandOption;
-use TinyBlocks\DockerContainer\Internal\Containers\Models\Container;
+use TinyBlocks\DockerContainer\Internal\Containers\Models\Name;
final readonly class DockerList implements Command
{
- use LineBuilder;
-
- private function __construct(public Container $container, public CommandOptions $commandOptions)
+ private function __construct(public Name $name)
{
}
- public static function from(Container $container, ?CommandOption ...$commandOption): DockerList
+ public static function from(Name $name): DockerList
{
- $commandOptions = CommandOptions::createFromOptions(
- SimpleCommandOption::ALL,
- SimpleCommandOption::QUIET,
- ...$commandOption
- );
-
- return new DockerList(container: $container, commandOptions: $commandOptions);
+ return new DockerList(name: $name);
}
public function toCommandLine(): string
{
- return $this->buildFrom(
- template: 'docker ps %s %s name=%s',
- values: [
- $this->commandOptions->toArguments(),
- SimpleCommandOption::FILTER->toArguments(),
- $this->container->name->value
- ]
- );
+ return sprintf('docker ps --all --quiet --filter name=%s', $this->name->value);
}
}
diff --git a/src/Internal/Commands/DockerRun.php b/src/Internal/Commands/DockerRun.php
index f2f7a01..e885b50 100644
--- a/src/Internal/Commands/DockerRun.php
+++ b/src/Internal/Commands/DockerRun.php
@@ -5,42 +5,68 @@
namespace TinyBlocks\DockerContainer\Internal\Commands;
use TinyBlocks\Collection\Collection;
-use TinyBlocks\DockerContainer\Internal\Commands\Options\CommandOption;
-use TinyBlocks\DockerContainer\Internal\Commands\Options\CommandOptions;
-use TinyBlocks\DockerContainer\Internal\Containers\Models\Container;
+use TinyBlocks\DockerContainer\Internal\Containers\Definitions\ContainerDefinition;
+use TinyBlocks\DockerContainer\Internal\Containers\Definitions\EnvironmentVariable;
+use TinyBlocks\DockerContainer\Internal\Containers\Definitions\PortMapping;
+use TinyBlocks\DockerContainer\Internal\Containers\Definitions\VolumeMapping;
final readonly class DockerRun implements Command
{
- use LineBuilder;
-
- private function __construct(
- public Collection $commands,
- public Container $container,
- public CommandOptions $commandOptions
- ) {
+ private function __construct(public ContainerDefinition $definition, private Collection $commands)
+ {
}
- public static function from(array $commands, Container $container, ?CommandOption ...$commandOption): DockerRun
+ public static function from(ContainerDefinition $definition, array $commands = []): DockerRun
{
- $commands = Collection::createFrom(elements: $commands);
- $commandOptions = CommandOptions::createFromOptions(...$commandOption);
-
- return new DockerRun(commands: $commands, container: $container, commandOptions: $commandOptions);
+ return new DockerRun(definition: $definition, commands: Collection::createFrom(elements: $commands));
}
public function toCommandLine(): string
{
- $name = $this->container->name->value;
-
- return $this->buildFrom(
- template: 'docker run --user root --name %s --hostname %s %s %s %s',
- values: [
- $name,
- $name,
- $this->commandOptions->toArguments(),
- $this->container->image->name,
- $this->commands->joinToString(separator: ' ')
- ]
+ $name = $this->definition->name->value;
+
+ $parts = Collection::createFrom(elements: [
+ 'docker run --user root',
+ sprintf('--name %s', $name),
+ sprintf('--hostname %s', $name)
+ ]);
+
+ $parts = $parts->merge(
+ other: $this->definition->portMappings->map(
+ transformations: static fn(PortMapping $port): string => $port->toArgument()
+ )
+ );
+
+ if ($this->definition->network !== null) {
+ $parts = $parts->add(sprintf('--network=%s', $this->definition->network));
+ }
+
+ $parts = $parts->merge(
+ other: $this->definition->volumeMappings->map(
+ transformations: static fn(VolumeMapping $volume): string => $volume->toArgument()
+ )
);
+
+ $parts = $parts->add('--detach');
+
+ if ($this->definition->autoRemove) {
+ $parts = $parts->add('--rm');
+ }
+
+ $parts = $parts->merge(
+ other: $this->definition->environmentVariables->map(
+ transformations: static fn(EnvironmentVariable $env): string => $env->toArgument()
+ )
+ );
+
+ $parts = $parts->add($this->definition->image->name);
+
+ $commandString = $this->commands->joinToString(separator: ' ');
+
+ if (!empty($commandString)) {
+ $parts = $parts->add($commandString);
+ }
+
+ return trim($parts->joinToString(separator: ' '));
}
}
diff --git a/src/Internal/Commands/DockerStop.php b/src/Internal/Commands/DockerStop.php
index fcd270b..f98a042 100644
--- a/src/Internal/Commands/DockerStop.php
+++ b/src/Internal/Commands/DockerStop.php
@@ -8,8 +8,6 @@
final readonly class DockerStop implements CommandWithTimeout
{
- use LineBuilder;
-
private function __construct(private ContainerId $id, private int $timeoutInWholeSeconds)
{
}
@@ -21,7 +19,7 @@ public static function from(ContainerId $id, int $timeoutInWholeSeconds): Docker
public function toCommandLine(): string
{
- return $this->buildFrom(template: 'docker stop %s', values: [$this->id->value]);
+ return sprintf('docker stop %s', $this->id->value);
}
public function getTimeoutInWholeSeconds(): int
diff --git a/src/Internal/Commands/LineBuilder.php b/src/Internal/Commands/LineBuilder.php
deleted file mode 100644
index a3ab75d..0000000
--- a/src/Internal/Commands/LineBuilder.php
+++ /dev/null
@@ -1,13 +0,0 @@
-filter()
- ->each(actions: static function (CommandOption $commandOption) use ($collection) {
- $collection->add(elements: $commandOption->toArguments());
- });
-
- return $this->buildFrom(template: '%s', values: [$collection->joinToString(separator: ' ')]);
- }
-}
diff --git a/src/Internal/Commands/Options/EnvironmentVariableOption.php b/src/Internal/Commands/Options/EnvironmentVariableOption.php
deleted file mode 100644
index dfa5a4e..0000000
--- a/src/Internal/Commands/Options/EnvironmentVariableOption.php
+++ /dev/null
@@ -1,26 +0,0 @@
-buildFrom(template: '--env %s=%s', values: [$this->key, escapeshellarg($this->value)]);
- }
-}
diff --git a/src/Internal/Commands/Options/GenericCommandOption.php b/src/Internal/Commands/Options/GenericCommandOption.php
deleted file mode 100644
index 3244378..0000000
--- a/src/Internal/Commands/Options/GenericCommandOption.php
+++ /dev/null
@@ -1,29 +0,0 @@
-buildFrom(template: '%s', values: [$this->commandOptions->joinToString(separator: ' ')]);
- }
-}
diff --git a/src/Internal/Commands/Options/ItemToCopyOption.php b/src/Internal/Commands/Options/ItemToCopyOption.php
deleted file mode 100644
index f940c2d..0000000
--- a/src/Internal/Commands/Options/ItemToCopyOption.php
+++ /dev/null
@@ -1,30 +0,0 @@
-buildFrom(
- template: '%s %s:%s',
- values: [$this->volume->pathOnHost, $this->id->value, $this->volume->pathOnContainer]
- );
- }
-}
diff --git a/src/Internal/Commands/Options/NetworkOption.php b/src/Internal/Commands/Options/NetworkOption.php
deleted file mode 100644
index 4495e21..0000000
--- a/src/Internal/Commands/Options/NetworkOption.php
+++ /dev/null
@@ -1,26 +0,0 @@
-buildFrom(template: '--network=%s', values: [$this->name]);
- }
-}
diff --git a/src/Internal/Commands/Options/PortOption.php b/src/Internal/Commands/Options/PortOption.php
deleted file mode 100644
index fe04f5f..0000000
--- a/src/Internal/Commands/Options/PortOption.php
+++ /dev/null
@@ -1,26 +0,0 @@
-buildFrom(template: '--publish %d:%d', values: [$this->portOnHost, $this->portOnContainer]);
- }
-}
diff --git a/src/Internal/Commands/Options/SimpleCommandOption.php b/src/Internal/Commands/Options/SimpleCommandOption.php
deleted file mode 100644
index 0368830..0000000
--- a/src/Internal/Commands/Options/SimpleCommandOption.php
+++ /dev/null
@@ -1,23 +0,0 @@
-buildFrom(template: '--%s', values: [$this->value]);
- }
-}
diff --git a/src/Internal/Commands/Options/VolumeOption.php b/src/Internal/Commands/Options/VolumeOption.php
deleted file mode 100644
index d6075e9..0000000
--- a/src/Internal/Commands/Options/VolumeOption.php
+++ /dev/null
@@ -1,26 +0,0 @@
-buildFrom(template: '--volume %s:%s', values: [$this->pathOnHost, $this->pathOnContainer]);
- }
-}
diff --git a/src/Internal/ContainerCommandHandler.php b/src/Internal/ContainerCommandHandler.php
deleted file mode 100644
index 37d9fc6..0000000
--- a/src/Internal/ContainerCommandHandler.php
+++ /dev/null
@@ -1,59 +0,0 @@
-containerFactory = new ContainerFactory(client: $client);
- }
-
- public function run(DockerRun $dockerRun): Container
- {
- $executionCompleted = $this->client->execute(command: $dockerRun);
-
- if (!$executionCompleted->isSuccessful()) {
- throw DockerCommandExecutionFailed::fromCommand(command: $dockerRun, execution: $executionCompleted);
- }
-
- $id = ContainerId::from(value: $executionCompleted->getOutput());
-
- return $this->containerFactory->buildFrom(id: $id, container: $dockerRun->container);
- }
-
- public function findBy(DockerList $dockerList): Container
- {
- $container = $dockerList->container;
- $executionCompleted = $this->client->execute(command: $dockerList);
-
- $output = $executionCompleted->getOutput();
-
- if (empty($output)) {
- return Container::create(name: $container->name->value, image: $container->image->name);
- }
-
- $id = ContainerId::from(value: $output);
-
- return $this->containerFactory->buildFrom(id: $id, container: $container);
- }
-
- public function execute(Command $command): ExecutionCompleted
- {
- return $this->client->execute(command: $command);
- }
-}
diff --git a/src/Internal/Containers/Models/Address/Address.php b/src/Internal/Containers/Address/Address.php
similarity index 60%
rename from src/Internal/Containers/Models/Address/Address.php
rename to src/Internal/Containers/Address/Address.php
index 581d541..dfc3da4 100644
--- a/src/Internal/Containers/Models/Address/Address.php
+++ b/src/Internal/Containers/Address/Address.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
-namespace TinyBlocks\DockerContainer\Internal\Containers\Models\Address;
+namespace TinyBlocks\DockerContainer\Internal\Containers\Address;
use TinyBlocks\DockerContainer\Contracts\Address as ContainerAddress;
use TinyBlocks\DockerContainer\Contracts\Ports as ContainerPorts;
@@ -13,17 +13,8 @@ private function __construct(private IP $ip, private ContainerPorts $ports, priv
{
}
- public static function create(): Address
+ public static function from(IP $ip, Ports $ports, Hostname $hostname): Address
{
- return new Address(ip: IP::local(), ports: Ports::createFromEmpty(), hostname: Hostname::localhost());
- }
-
- public static function from(array $data): Address
- {
- $ip = IP::from(value: $data['ip']);
- $ports = Ports::createFrom(elements: $data['ports']);
- $hostname = Hostname::from(value: $data['hostname']);
-
return new Address(ip: $ip, ports: $ports, hostname: $hostname);
}
diff --git a/src/Internal/Containers/Address/Hostname.php b/src/Internal/Containers/Address/Hostname.php
new file mode 100644
index 0000000..6f2a1cb
--- /dev/null
+++ b/src/Internal/Containers/Address/Hostname.php
@@ -0,0 +1,19 @@
+filter());
+ }
+
+ public function exposedPorts(): array
+ {
+ return $this->ports->toArray(keyPreservation: KeyPreservation::DISCARD);
+ }
+
+ public function firstExposedPort(): ?int
+ {
+ $port = $this->ports->first();
+
+ return empty($port) ? null : (int)$port;
+ }
+}
diff --git a/src/Internal/Containers/Definitions/ContainerDefinition.php b/src/Internal/Containers/Definitions/ContainerDefinition.php
new file mode 100644
index 0000000..a5d3fe5
--- /dev/null
+++ b/src/Internal/Containers/Definitions/ContainerDefinition.php
@@ -0,0 +1,130 @@
+name,
+ image: $this->image,
+ network: $name,
+ autoRemove: $this->autoRemove,
+ portMappings: $this->portMappings,
+ volumeMappings: $this->volumeMappings,
+ copyInstructions: $this->copyInstructions,
+ environmentVariables: $this->environmentVariables
+ );
+ }
+
+ public function withPortMapping(int $portOnHost, int $portOnContainer): ContainerDefinition
+ {
+ return new ContainerDefinition(
+ name: $this->name,
+ image: $this->image,
+ network: $this->network,
+ autoRemove: $this->autoRemove,
+ portMappings: $this->portMappings->add(
+ PortMapping::from(portOnHost: $portOnHost, portOnContainer: $portOnContainer)
+ ),
+ volumeMappings: $this->volumeMappings,
+ copyInstructions: $this->copyInstructions,
+ environmentVariables: $this->environmentVariables
+ );
+ }
+
+ public function withVolumeMapping(string $pathOnHost, string $pathOnContainer): ContainerDefinition
+ {
+ return new ContainerDefinition(
+ name: $this->name,
+ image: $this->image,
+ network: $this->network,
+ autoRemove: $this->autoRemove,
+ portMappings: $this->portMappings,
+ volumeMappings: $this->volumeMappings->add(
+ VolumeMapping::from(pathOnHost: $pathOnHost, pathOnContainer: $pathOnContainer)
+ ),
+ copyInstructions: $this->copyInstructions,
+ environmentVariables: $this->environmentVariables
+ );
+ }
+
+ public function withCopyInstruction(string $pathOnHost, string $pathOnContainer): ContainerDefinition
+ {
+ return new ContainerDefinition(
+ name: $this->name,
+ image: $this->image,
+ network: $this->network,
+ autoRemove: $this->autoRemove,
+ portMappings: $this->portMappings,
+ volumeMappings: $this->volumeMappings,
+ copyInstructions: $this->copyInstructions->add(
+ CopyInstruction::from(pathOnHost: $pathOnHost, pathOnContainer: $pathOnContainer)
+ ),
+ environmentVariables: $this->environmentVariables
+ );
+ }
+
+ public function withEnvironmentVariable(string $key, string $value): ContainerDefinition
+ {
+ return new ContainerDefinition(
+ name: $this->name,
+ image: $this->image,
+ network: $this->network,
+ autoRemove: $this->autoRemove,
+ portMappings: $this->portMappings,
+ volumeMappings: $this->volumeMappings,
+ copyInstructions: $this->copyInstructions,
+ environmentVariables: $this->environmentVariables->add(
+ EnvironmentVariable::from(key: $key, value: $value)
+ )
+ );
+ }
+
+ public function withoutAutoRemove(): ContainerDefinition
+ {
+ return new ContainerDefinition(
+ name: $this->name,
+ image: $this->image,
+ network: $this->network,
+ autoRemove: false,
+ portMappings: $this->portMappings,
+ volumeMappings: $this->volumeMappings,
+ copyInstructions: $this->copyInstructions,
+ environmentVariables: $this->environmentVariables
+ );
+ }
+}
diff --git a/src/Internal/Containers/Definitions/CopyInstruction.php b/src/Internal/Containers/Definitions/CopyInstruction.php
new file mode 100644
index 0000000..adb7895
--- /dev/null
+++ b/src/Internal/Containers/Definitions/CopyInstruction.php
@@ -0,0 +1,24 @@
+pathOnHost, $id->value, $this->pathOnContainer);
+ }
+}
diff --git a/src/Internal/Containers/Definitions/EnvironmentVariable.php b/src/Internal/Containers/Definitions/EnvironmentVariable.php
new file mode 100644
index 0000000..71308f1
--- /dev/null
+++ b/src/Internal/Containers/Definitions/EnvironmentVariable.php
@@ -0,0 +1,22 @@
+key, escapeshellarg($this->value));
+ }
+}
diff --git a/src/Internal/Containers/Definitions/PortMapping.php b/src/Internal/Containers/Definitions/PortMapping.php
new file mode 100644
index 0000000..869bd87
--- /dev/null
+++ b/src/Internal/Containers/Definitions/PortMapping.php
@@ -0,0 +1,22 @@
+portOnHost, $this->portOnContainer);
+ }
+}
diff --git a/src/Internal/Containers/Definitions/VolumeMapping.php b/src/Internal/Containers/Definitions/VolumeMapping.php
new file mode 100644
index 0000000..15aeea7
--- /dev/null
+++ b/src/Internal/Containers/Definitions/VolumeMapping.php
@@ -0,0 +1,22 @@
+pathOnHost, $this->pathOnContainer);
+ }
+}
diff --git a/src/Internal/Containers/Drivers/MySQL/MySQLStarted.php b/src/Internal/Containers/Drivers/MySQL/MySQLStarted.php
index 8c18de9..db295da 100644
--- a/src/Internal/Containers/Drivers/MySQL/MySQLStarted.php
+++ b/src/Internal/Containers/Drivers/MySQL/MySQLStarted.php
@@ -4,20 +4,53 @@
namespace TinyBlocks\DockerContainer\Internal\Containers\Drivers\MySQL;
+use TinyBlocks\DockerContainer\Contracts\Address;
use TinyBlocks\DockerContainer\Contracts\ContainerStarted;
+use TinyBlocks\DockerContainer\Contracts\EnvironmentVariables;
+use TinyBlocks\DockerContainer\Contracts\ExecutionCompleted;
use TinyBlocks\DockerContainer\Contracts\MySQL\MySQLContainerStarted;
-use TinyBlocks\DockerContainer\Internal\Containers\Started;
-final readonly class MySQLStarted extends Started implements MySQLContainerStarted
+final readonly class MySQLStarted implements MySQLContainerStarted
{
private const int DEFAULT_MYSQL_PORT = 3306;
+ private function __construct(private ContainerStarted $containerStarted)
+ {
+ }
+
public static function from(ContainerStarted $containerStarted): MySQLStarted
{
- return new MySQLStarted(
- container: $containerStarted->container,
- commandHandler: $containerStarted->commandHandler
- );
+ return new MySQLStarted(containerStarted: $containerStarted);
+ }
+
+ public function getId(): string
+ {
+ return $this->containerStarted->getId();
+ }
+
+ public function getName(): string
+ {
+ return $this->containerStarted->getName();
+ }
+
+ public function getAddress(): Address
+ {
+ return $this->containerStarted->getAddress();
+ }
+
+ public function getEnvironmentVariables(): EnvironmentVariables
+ {
+ return $this->containerStarted->getEnvironmentVariables();
+ }
+
+ public function stop(int $timeoutInWholeSeconds = self::DEFAULT_TIMEOUT_IN_WHOLE_SECONDS): ExecutionCompleted
+ {
+ return $this->containerStarted->stop(timeoutInWholeSeconds: $timeoutInWholeSeconds);
+ }
+
+ public function executeAfterStarted(array $commands): ExecutionCompleted
+ {
+ return $this->containerStarted->executeAfterStarted(commands: $commands);
}
public function getJdbcUrl(array $options = self::DEFAULT_JDBC_OPTIONS): string
@@ -30,8 +63,7 @@ public function getJdbcUrl(array $options = self::DEFAULT_JDBC_OPTIONS): string
$baseUrl = sprintf('jdbc:mysql://%s:%d/%s', $hostname, $port, $database);
if (!empty($options)) {
- $queryString = http_build_query($options);
- return sprintf('%s?%s', $baseUrl, $queryString);
+ return sprintf('%s?%s', $baseUrl, http_build_query(data: $options));
}
return $baseUrl;
diff --git a/src/Internal/Containers/Environment/EnvironmentVariables.php b/src/Internal/Containers/Environment/EnvironmentVariables.php
new file mode 100644
index 0000000..1ea8b7d
--- /dev/null
+++ b/src/Internal/Containers/Environment/EnvironmentVariables.php
@@ -0,0 +1,25 @@
+variables->toArray()[$key] ?? '');
+ }
+}
diff --git a/src/Internal/Containers/Factories/AddressFactory.php b/src/Internal/Containers/Factories/AddressFactory.php
deleted file mode 100644
index 3798e55..0000000
--- a/src/Internal/Containers/Factories/AddressFactory.php
+++ /dev/null
@@ -1,27 +0,0 @@
- $networks[key($networks)]['IPAddress'],
- 'ports' => [
- 'exposedPorts' => array_map(fn($port) => (int)explode('/', $port)[0], array_keys($ports))
- ],
- 'hostname' => $configuration['Hostname']
- ];
-
- return Address::from(data: $address);
- }
-}
diff --git a/src/Internal/Containers/Factories/ContainerFactory.php b/src/Internal/Containers/Factories/ContainerFactory.php
deleted file mode 100644
index c27fb1e..0000000
--- a/src/Internal/Containers/Factories/ContainerFactory.php
+++ /dev/null
@@ -1,46 +0,0 @@
-addressFactory = new AddressFactory();
- $this->variablesFactory = new EnvironmentVariablesFactory();
- }
-
- public function buildFrom(ContainerId $id, Container $container): Container
- {
- $dockerInspect = DockerInspect::from(id: $id);
- $executionCompleted = $this->client->execute(command: $dockerInspect);
-
- $payload = (array)json_decode($executionCompleted->getOutput(), true);
-
- if (empty(array_filter($payload))) {
- throw new DockerContainerNotFound(name: $container->name);
- }
-
- $data = $payload[0];
-
- return Container::from(
- id: $id,
- name: $container->name,
- image: $container->image,
- address: $this->addressFactory->buildFrom(data: $data),
- environmentVariables: $this->variablesFactory->buildFrom(data: $data)
- );
- }
-}
diff --git a/src/Internal/Containers/Factories/EnvironmentVariablesFactory.php b/src/Internal/Containers/Factories/EnvironmentVariablesFactory.php
deleted file mode 100644
index a4670f9..0000000
--- a/src/Internal/Containers/Factories/EnvironmentVariablesFactory.php
+++ /dev/null
@@ -1,26 +0,0 @@
- (int)explode('/', $port)[0],
+ array_keys($rawPorts)
+ )
+ );
+
+ return Address::from(ip: $ip, ports: Ports::from(ports: $exposedPorts), hostname: $hostname);
+ }
+
+ public function parseEnvironmentVariables(array $data): EnvironmentVariables
+ {
+ $envData = $data['Config']['Env'] ?? [];
+ $variables = [];
+
+ foreach ($envData as $variable) {
+ [$key, $value] = explode(self::SEPARATOR, $variable, self::LIMIT);
+ $variables[$key] = $value;
+ }
+
+ return EnvironmentVariables::from(variables: Collection::createFrom(elements: $variables));
+ }
+}
diff --git a/src/Internal/Containers/Models/Address/Hostname.php b/src/Internal/Containers/Models/Address/Hostname.php
deleted file mode 100644
index 6834ebc..0000000
--- a/src/Internal/Containers/Models/Address/Hostname.php
+++ /dev/null
@@ -1,26 +0,0 @@
-filter()
- ->toArray(keyPreservation: KeyPreservation::DISCARD);
-
- return new Ports(exposedPorts: $exposedPorts);
- }
-
- public static function createFromEmpty(): Ports
- {
- return new Ports(exposedPorts: []);
- }
-
- public function exposedPorts(): array
- {
- return $this->exposedPorts;
- }
-
- public function firstExposedPort(): ?int
- {
- return $this->exposedPorts()[0] ?? null;
- }
-}
diff --git a/src/Internal/Containers/Models/Container.php b/src/Internal/Containers/Models/Container.php
deleted file mode 100644
index 723064d..0000000
--- a/src/Internal/Containers/Models/Container.php
+++ /dev/null
@@ -1,56 +0,0 @@
-id !== null;
- }
-}
diff --git a/src/Internal/Containers/Models/ContainerId.php b/src/Internal/Containers/Models/ContainerId.php
index ac44919..d4180ea 100644
--- a/src/Internal/Containers/Models/ContainerId.php
+++ b/src/Internal/Containers/Models/ContainerId.php
@@ -15,17 +15,21 @@ private function __construct(public string $value)
{
}
- public static function from(string $value): self
+ public static function from(string $value): ContainerId
{
- if (empty($value)) {
+ $trimmed = trim($value);
+
+ if (empty($trimmed)) {
throw new InvalidArgumentException(message: 'Container ID cannot be empty.');
}
- if (strlen($value) < self::CONTAINER_ID_LENGTH) {
+ if (strlen($trimmed) < self::CONTAINER_ID_LENGTH) {
$template = 'Container ID <%s> is too short. Minimum length is <%d> characters.';
- throw new InvalidArgumentException(message: sprintf($template, $value, self::CONTAINER_ID_LENGTH));
+ throw new InvalidArgumentException(
+ message: sprintf($template, $trimmed, self::CONTAINER_ID_LENGTH)
+ );
}
- return new ContainerId(value: substr($value, self::CONTAINER_ID_OFFSET, self::CONTAINER_ID_LENGTH));
+ return new ContainerId(value: substr($trimmed, self::CONTAINER_ID_OFFSET, self::CONTAINER_ID_LENGTH));
}
}
diff --git a/src/Internal/Containers/Models/Environment/EnvironmentVariables.php b/src/Internal/Containers/Models/Environment/EnvironmentVariables.php
deleted file mode 100644
index be0c761..0000000
--- a/src/Internal/Containers/Models/Environment/EnvironmentVariables.php
+++ /dev/null
@@ -1,16 +0,0 @@
-toArray()[$key];
- }
-}
diff --git a/src/Internal/Containers/Started.php b/src/Internal/Containers/Started.php
index 09e9c57..8e8983c 100644
--- a/src/Internal/Containers/Started.php
+++ b/src/Internal/Containers/Started.php
@@ -8,47 +8,55 @@
use TinyBlocks\DockerContainer\Contracts\ContainerStarted;
use TinyBlocks\DockerContainer\Contracts\EnvironmentVariables;
use TinyBlocks\DockerContainer\Contracts\ExecutionCompleted;
-use TinyBlocks\DockerContainer\Internal\CommandHandler;
+use TinyBlocks\DockerContainer\Internal\CommandHandler\CommandHandler;
use TinyBlocks\DockerContainer\Internal\Commands\DockerExecute;
use TinyBlocks\DockerContainer\Internal\Commands\DockerStop;
-use TinyBlocks\DockerContainer\Internal\Containers\Models\Container;
+use TinyBlocks\DockerContainer\Internal\Containers\Address\Address as ContainerAddress;
+use TinyBlocks\DockerContainer\Internal\Containers\Environment\EnvironmentVariables as ContainerEnvironmentVariables;
+use TinyBlocks\DockerContainer\Internal\Containers\Models\ContainerId;
+use TinyBlocks\DockerContainer\Internal\Containers\Models\Name;
-readonly class Started implements ContainerStarted
+final readonly class Started implements ContainerStarted
{
- public function __construct(public Container $container, public CommandHandler $commandHandler)
- {
+ public function __construct(
+ private ContainerId $id,
+ private Name $name,
+ private ContainerAddress $address,
+ private ContainerEnvironmentVariables $environmentVariables,
+ private CommandHandler $commandHandler
+ ) {
}
public function getId(): string
{
- return $this->container->id->value;
+ return $this->id->value;
}
public function getName(): string
{
- return $this->container->name->value;
+ return $this->name->value;
}
public function getAddress(): Address
{
- return $this->container->address;
+ return $this->address;
}
public function getEnvironmentVariables(): EnvironmentVariables
{
- return $this->container->environmentVariables;
+ return $this->environmentVariables;
}
public function stop(int $timeoutInWholeSeconds = self::DEFAULT_TIMEOUT_IN_WHOLE_SECONDS): ExecutionCompleted
{
- $command = DockerStop::from(id: $this->container->id, timeoutInWholeSeconds: $timeoutInWholeSeconds);
+ $command = DockerStop::from(id: $this->id, timeoutInWholeSeconds: $timeoutInWholeSeconds);
return $this->commandHandler->execute(command: $command);
}
public function executeAfterStarted(array $commands): ExecutionCompleted
{
- $command = DockerExecute::from(name: $this->container->name, commandOptions: $commands);
+ $command = DockerExecute::from(name: $this->name, commands: $commands);
return $this->commandHandler->execute(command: $command);
}
diff --git a/src/Internal/Exceptions/ContainerWaitTimeout.php b/src/Internal/Exceptions/ContainerWaitTimeout.php
new file mode 100644
index 0000000..aaad5ad
--- /dev/null
+++ b/src/Internal/Exceptions/ContainerWaitTimeout.php
@@ -0,0 +1,17 @@
+ seconds.';
+
+ parent::__construct(message: sprintf($template, $timeoutInSeconds));
+ }
+}
diff --git a/src/MySQLContainer.php b/src/MySQLContainer.php
index 4359e3b..ce1e841 100644
--- a/src/MySQLContainer.php
+++ b/src/MySQLContainer.php
@@ -5,56 +5,63 @@
namespace TinyBlocks\DockerContainer;
/**
- * Defines operations for creating and managing MySQL Docker containers.
+ * Defines the contract for building and running a MySQL Docker container.
*/
interface MySQLContainer extends DockerContainer
{
/**
* Sets the timezone for the MySQL container.
*
- * @param string $timezone The desired timezone (e.g., 'America/Sao_Paulo').
- * @return static The instance of the MySQL container with the timezone environment variable set.
+ * @param string $timezone The timezone identifier (e.g., "America/Sao_Paulo").
+ * @return static The current container instance for method chaining.
*/
public function withTimezone(string $timezone): static;
/**
- * Sets the MySQL username.
+ * Sets the MySQL user to be created on startup.
*
- * @param string $user The MySQL username to configure.
- * @return static The instance of the MySQL container with the username set.
+ * @param string $user The username.
+ * @return static The current container instance for method chaining.
*/
public function withUsername(string $user): static;
/**
- * Sets the MySQL user password.
+ * Sets the password for the MySQL user created on startup.
*
- * @param string $password The password for the MySQL user.
- * @return static The instance of the MySQL container with the password set.
+ * @param string $password The user password.
+ * @return static The current container instance for method chaining.
*/
public function withPassword(string $password): static;
/**
- * Sets the database to be created in the MySQL container.
+ * Sets the default database to be created on startup.
*
- * @param string $database The name of the database to create.
- * @return static The instance of the MySQL container with the database set.
+ * @param string $database The database name.
+ * @return static The current container instance for method chaining.
*/
public function withDatabase(string $database): static;
/**
- * Sets the root password for MySQL.
+ * Sets the root password for the MySQL instance.
*
- * @param string $rootPassword The root password for MySQL.
- * @return static The instance of the MySQL container with the root password set.
+ * @param string $rootPassword The root password.
+ * @return static The current container instance for method chaining.
*/
public function withRootPassword(string $rootPassword): static;
/**
- * Sets the hosts that the MySQL root user will have privileges for.
- * The default is `['%', '172.%']`.
+ * Sets the hosts to which the root user is granted privileges.
*
- * @param array $hosts List of hosts to grant privileges to the root user.
- * @return static The instance of the MySQL container with the granted hosts set.
+ * @param array $hosts The list of host patterns (e.g., ["%", "172.%"]).
+ * @return static The current container instance for method chaining.
*/
public function withGrantedHosts(array $hosts = ['%', '172.%']): static;
+
+ /**
+ * Sets the maximum time in seconds to wait for MySQL to be ready.
+ *
+ * @param int $timeoutInSeconds The timeout in seconds.
+ * @return static The instance with the readiness timeout set.
+ */
+ public function withReadinessTimeout(int $timeoutInSeconds): static;
}
diff --git a/src/MySQLDockerContainer.php b/src/MySQLDockerContainer.php
index 69110ce..057ab32 100644
--- a/src/MySQLDockerContainer.php
+++ b/src/MySQLDockerContainer.php
@@ -8,37 +8,57 @@
use TinyBlocks\DockerContainer\Internal\Containers\Drivers\MySQL\MySQLCommands;
use TinyBlocks\DockerContainer\Internal\Containers\Drivers\MySQL\MySQLStarted;
use TinyBlocks\DockerContainer\Waits\Conditions\MySQL\MySQLReady;
+use TinyBlocks\DockerContainer\Waits\ContainerWait;
use TinyBlocks\DockerContainer\Waits\ContainerWaitAfterStarted;
+use TinyBlocks\DockerContainer\Waits\ContainerWaitBeforeStarted;
use TinyBlocks\DockerContainer\Waits\ContainerWaitForDependency;
-class MySQLDockerContainer extends GenericDockerContainer implements MySQLContainer
+class MySQLDockerContainer implements MySQLContainer
{
+ /** @var array */
private array $grantedHosts = [];
+ private int $readinessTimeoutInSeconds;
+
+ private GenericDockerContainer $container;
+
+ protected function __construct(GenericDockerContainer $container)
+ {
+ $this->container = $container;
+ $this->readinessTimeoutInSeconds = ContainerWait::DEFAULT_TIMEOUT_IN_SECONDS;
+ }
+
+ public static function from(string $image, ?string $name = null): static
+ {
+ return new static(container: GenericDockerContainer::from(image: $image, name: $name));
+ }
+
public function run(
array $commands = [],
?ContainerWaitAfterStarted $waitAfterStarted = null
): MySQLContainerStarted {
- $containerStarted = parent::run(commands: $commands);
+ $containerStarted = $this->container->run(commands: $commands);
$condition = MySQLReady::from(container: $containerStarted);
- $waitForDependency = ContainerWaitForDependency::untilReady(condition: $condition);
- $waitForDependency->waitBefore();
+ ContainerWaitForDependency::untilReady(
+ condition: $condition,
+ timeoutInSeconds: $this->readinessTimeoutInSeconds
+ )->waitBefore();
$environmentVariables = $containerStarted->getEnvironmentVariables();
$database = $environmentVariables->getValueBy(key: 'MYSQL_DATABASE');
$rootPassword = $environmentVariables->getValueBy(key: 'MYSQL_ROOT_PASSWORD');
if (!empty($database)) {
- $command = MySQLCommands::createDatabase(database: $database, rootPassword: $rootPassword);
- $containerStarted->executeAfterStarted(commands: [$command]);
+ $containerStarted->executeAfterStarted(
+ commands: [MySQLCommands::createDatabase(database: $database, rootPassword: $rootPassword)]
+ );
}
- if (!empty($this->grantedHosts)) {
- foreach ($this->grantedHosts as $host) {
- $command = MySQLCommands::grantPrivilegesToRoot(host: $host, rootPassword: $rootPassword);
- $containerStarted->executeAfterStarted(commands: [$command]);
- }
+ foreach ($this->grantedHosts as $host) {
+ $containerStarted->executeAfterStarted(
+ commands: [MySQLCommands::grantPrivilegesToRoot(host: $host, rootPassword: $rootPassword)]
+ );
}
return MySQLStarted::from(containerStarted: $containerStarted);
@@ -48,42 +68,91 @@ public function runIfNotExists(
array $commands = [],
?ContainerWaitAfterStarted $waitAfterStarted = null
): MySQLContainerStarted {
- $containerStarted = parent::runIfNotExists(commands: $commands);
+ $containerStarted = $this->container->runIfNotExists(commands: $commands);
return MySQLStarted::from(containerStarted: $containerStarted);
}
+ public function copyToContainer(string $pathOnHost, string $pathOnContainer): static
+ {
+ $this->container->copyToContainer(pathOnHost: $pathOnHost, pathOnContainer: $pathOnContainer);
+
+ return $this;
+ }
+
+ public function withNetwork(string $name): static
+ {
+ $this->container->withNetwork(name: $name);
+
+ return $this;
+ }
+
+ public function withPortMapping(int $portOnHost, int $portOnContainer): static
+ {
+ $this->container->withPortMapping(portOnHost: $portOnHost, portOnContainer: $portOnContainer);
+
+ return $this;
+ }
+
+ public function withWaitBeforeRun(ContainerWaitBeforeStarted $wait): static
+ {
+ $this->container->withWaitBeforeRun(wait: $wait);
+
+ return $this;
+ }
+
+ public function withoutAutoRemove(): static
+ {
+ $this->container->withoutAutoRemove();
+
+ return $this;
+ }
+
+ public function withVolumeMapping(string $pathOnHost, string $pathOnContainer): static
+ {
+ $this->container->withVolumeMapping(pathOnHost: $pathOnHost, pathOnContainer: $pathOnContainer);
+
+ return $this;
+ }
+
+ public function withEnvironmentVariable(string $key, string $value): static
+ {
+ $this->container->withEnvironmentVariable(key: $key, value: $value);
+
+ return $this;
+ }
+
public function withTimezone(string $timezone): static
{
- $this->withEnvironmentVariable(key: 'TZ', value: $timezone);
+ $this->container->withEnvironmentVariable(key: 'TZ', value: $timezone);
return $this;
}
public function withUsername(string $user): static
{
- $this->withEnvironmentVariable(key: 'MYSQL_USER', value: $user);
+ $this->container->withEnvironmentVariable(key: 'MYSQL_USER', value: $user);
return $this;
}
public function withPassword(string $password): static
{
- $this->withEnvironmentVariable(key: 'MYSQL_PASSWORD', value: $password);
+ $this->container->withEnvironmentVariable(key: 'MYSQL_PASSWORD', value: $password);
return $this;
}
public function withDatabase(string $database): static
{
- $this->withEnvironmentVariable(key: 'MYSQL_DATABASE', value: $database);
+ $this->container->withEnvironmentVariable(key: 'MYSQL_DATABASE', value: $database);
return $this;
}
public function withRootPassword(string $rootPassword): static
{
- $this->withEnvironmentVariable(key: 'MYSQL_ROOT_PASSWORD', value: $rootPassword);
+ $this->container->withEnvironmentVariable(key: 'MYSQL_ROOT_PASSWORD', value: $rootPassword);
return $this;
}
@@ -94,4 +163,11 @@ public function withGrantedHosts(array $hosts = ['%', '172.%']): static
return $this;
}
+
+ public function withReadinessTimeout(int $timeoutInSeconds): static
+ {
+ $this->readinessTimeoutInSeconds = $timeoutInSeconds;
+
+ return $this;
+ }
}
diff --git a/src/Waits/Conditions/ContainerReady.php b/src/Waits/Conditions/ContainerReady.php
index 07e3420..9a082f2 100644
--- a/src/Waits/Conditions/ContainerReady.php
+++ b/src/Waits/Conditions/ContainerReady.php
@@ -4,18 +4,15 @@
namespace TinyBlocks\DockerContainer\Waits\Conditions;
-use TinyBlocks\DockerContainer\Internal\Exceptions\DockerCommandExecutionFailed;
-
/**
- * Defines the strategy for checking if a Docker container is ready.
+ * Defines a readiness condition used to determine if a container dependency is available.
*/
interface ContainerReady
{
/**
- * Checks if the container is ready based on its specific conditions.
+ * Checks whether the container dependency is ready to accept connections.
*
- * @return bool Returns true if the container is ready, false otherwise.
- * @throws DockerCommandExecutionFailed If the command to check readiness fails.
+ * @return bool True if the dependency is ready, false otherwise.
*/
public function isReady(): bool;
}
diff --git a/src/Waits/Conditions/MySQL/MySQLReady.php b/src/Waits/Conditions/MySQL/MySQLReady.php
index f352715..a2d0a84 100644
--- a/src/Waits/Conditions/MySQL/MySQLReady.php
+++ b/src/Waits/Conditions/MySQL/MySQLReady.php
@@ -4,6 +4,7 @@
namespace TinyBlocks\DockerContainer\Waits\Conditions\MySQL;
+use Throwable;
use TinyBlocks\DockerContainer\Contracts\ContainerStarted;
use TinyBlocks\DockerContainer\Waits\Conditions\ContainerReady;
@@ -20,12 +21,16 @@ public static function from(ContainerStarted $container): MySQLReady
public function isReady(): bool
{
- $rootPassword = $this->container
- ->getEnvironmentVariables()
- ->getValueBy(key: 'MYSQL_ROOT_PASSWORD');
+ try {
+ $rootPassword = $this->container
+ ->getEnvironmentVariables()
+ ->getValueBy(key: 'MYSQL_ROOT_PASSWORD');
- return $this->container
- ->executeAfterStarted(commands: ['mysqladmin', 'ping', '-h', '127.0.0.1', "-p$rootPassword"])
- ->isSuccessful();
+ return $this->container
+ ->executeAfterStarted(commands: ['mysqladmin', 'ping', '-h', '127.0.0.1', "-p$rootPassword"])
+ ->isSuccessful();
+ } catch (Throwable) {
+ return false;
+ }
}
}
diff --git a/src/Waits/ContainerWait.php b/src/Waits/ContainerWait.php
index d9942a9..11946b5 100644
--- a/src/Waits/ContainerWait.php
+++ b/src/Waits/ContainerWait.php
@@ -5,16 +5,14 @@
namespace TinyBlocks\DockerContainer\Waits;
/**
- * Defines the strategy for waiting conditions that ensure a Docker container meets a specific requirement
- * before proceeding with further actions.
+ * Defines constants for waiting for a container to be ready. These constants can be used by any
+ * implementation of a container wait strategy, such as polling or event-based waits.
*/
interface ContainerWait
{
- /**
- * The default wait time in whole seconds.
- *
- * This constant represents the default amount of time the system will wait when no specific condition is set.
- * It can be overridden or used directly in waiting mechanisms.
- */
- public const int WAIT_TIME_IN_WHOLE_SECONDS = 1;
+ /** Default timeout for waiting, in seconds. */
+ public const int DEFAULT_TIMEOUT_IN_SECONDS = 30;
+
+ /** Default interval between polls, in microseconds. */
+ public const int DEFAULT_POLL_INTERVAL_IN_MICROSECONDS = 250_000;
}
diff --git a/src/Waits/ContainerWaitAfterStarted.php b/src/Waits/ContainerWaitAfterStarted.php
index 8ef18ed..70ac8f9 100644
--- a/src/Waits/ContainerWaitAfterStarted.php
+++ b/src/Waits/ContainerWaitAfterStarted.php
@@ -5,20 +5,17 @@
namespace TinyBlocks\DockerContainer\Waits;
use TinyBlocks\DockerContainer\Contracts\ContainerStarted;
-use TinyBlocks\DockerContainer\Internal\Exceptions\DockerCommandExecutionFailed;
/**
- * Defines the strategy for waiting for a condition to be met after a Docker container has started.
+ * Defines a wait strategy to be applied after a container has started.
*/
interface ContainerWaitAfterStarted extends ContainerWait
{
/**
- * Waits for a condition to be met after the container has started.
+ * Waits after the container has started, blocking until the strategy is satisfied.
*
- * @param ContainerStarted $containerStarted The container after it has been started, on which the
- * condition will be checked.
+ * @param ContainerStarted $containerStarted The started container instance.
* @return void
- * @throws DockerCommandExecutionFailed If the command to check the condition after the container start fails.
*/
public function waitAfter(ContainerStarted $containerStarted): void;
}
diff --git a/src/Waits/ContainerWaitBeforeStarted.php b/src/Waits/ContainerWaitBeforeStarted.php
index cf13456..68517bc 100644
--- a/src/Waits/ContainerWaitBeforeStarted.php
+++ b/src/Waits/ContainerWaitBeforeStarted.php
@@ -4,18 +4,15 @@
namespace TinyBlocks\DockerContainer\Waits;
-use TinyBlocks\DockerContainer\Internal\Exceptions\DockerCommandExecutionFailed;
-
/**
- * Defines the strategy for waiting for a condition to be met before a Docker container has started.
+ * Defines a wait strategy to be applied before a container runs.
*/
interface ContainerWaitBeforeStarted extends ContainerWait
{
/**
- * Waits for a condition to be met before the container starts.
+ * Waits before the container runs, blocking until the strategy is satisfied.
*
* @return void
- * @throws DockerCommandExecutionFailed If the command to check the condition before the container start fails.
*/
public function waitBefore(): void;
}
diff --git a/src/Waits/ContainerWaitForDependency.php b/src/Waits/ContainerWaitForDependency.php
index cf3b766..3e74f86 100644
--- a/src/Waits/ContainerWaitForDependency.php
+++ b/src/Waits/ContainerWaitForDependency.php
@@ -4,23 +4,40 @@
namespace TinyBlocks\DockerContainer\Waits;
+use TinyBlocks\DockerContainer\Internal\Exceptions\ContainerWaitTimeout;
use TinyBlocks\DockerContainer\Waits\Conditions\ContainerReady;
final readonly class ContainerWaitForDependency implements ContainerWaitBeforeStarted
{
- private function __construct(private ContainerReady $condition)
- {
+ private function __construct(
+ private ContainerReady $condition,
+ private int $timeoutInSeconds,
+ private int $pollIntervalInMicroseconds
+ ) {
}
- public static function untilReady(ContainerReady $condition): ContainerWaitForDependency
- {
- return new ContainerWaitForDependency(condition: $condition);
+ public static function untilReady(
+ ContainerReady $condition,
+ int $timeoutInSeconds = self::DEFAULT_TIMEOUT_IN_SECONDS,
+ int $pollIntervalInMicroseconds = self::DEFAULT_POLL_INTERVAL_IN_MICROSECONDS
+ ): ContainerWaitForDependency {
+ return new ContainerWaitForDependency(
+ condition: $condition,
+ timeoutInSeconds: $timeoutInSeconds,
+ pollIntervalInMicroseconds: $pollIntervalInMicroseconds
+ );
}
public function waitBefore(): void
{
+ $deadline = microtime(as_float: true) + $this->timeoutInSeconds;
+
while (!$this->condition->isReady()) {
- sleep(self::WAIT_TIME_IN_WHOLE_SECONDS);
+ if (microtime(as_float: true) >= $deadline) {
+ throw new ContainerWaitTimeout(timeoutInSeconds: $this->timeoutInSeconds);
+ }
+
+ usleep($this->pollIntervalInMicroseconds);
}
}
}
diff --git a/src/Waits/ContainerWaitForTime.php b/src/Waits/ContainerWaitForTime.php
index 5a5d8f4..d2df507 100644
--- a/src/Waits/ContainerWaitForTime.php
+++ b/src/Waits/ContainerWaitForTime.php
@@ -8,6 +8,8 @@
final readonly class ContainerWaitForTime implements ContainerWaitBeforeStarted, ContainerWaitAfterStarted
{
+ private const int MICROSECONDS_PER_SECOND = 1_000_000;
+
private function __construct(private int $seconds)
{
}
@@ -19,11 +21,11 @@ public static function forSeconds(int $seconds): ContainerWaitForTime
public function waitBefore(): void
{
- sleep($this->seconds);
+ usleep($this->seconds * self::MICROSECONDS_PER_SECOND);
}
public function waitAfter(ContainerStarted $containerStarted): void
{
- sleep($this->seconds);
+ usleep($this->seconds * self::MICROSECONDS_PER_SECOND);
}
}
diff --git a/tests/Integration/Database/Migrations/V0000__Create_xpto_table.sql b/tests/Integration/Database/Migrations/V0000__Create_xpto_table.sql
index 238b77e..ffde6a8 100644
--- a/tests/Integration/Database/Migrations/V0000__Create_xpto_table.sql
+++ b/tests/Integration/Database/Migrations/V0000__Create_xpto_table.sql
@@ -1,5 +1,5 @@
-CREATE TABLE `xpto`
+CREATE TABLE xpto
(
- `id` INT PRIMARY KEY NOT NULL COMMENT 'Unique identifier.',
- `created_at` TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT 'Date when the record was inserted.'
+ id INT PRIMARY KEY NOT NULL COMMENT 'Unique identifier.',
+ created_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT 'Date when the record was inserted.'
);
diff --git a/tests/Integration/DockerContainerTest.php b/tests/Integration/DockerContainerTest.php
index 95446e0..63d2e93 100644
--- a/tests/Integration/DockerContainerTest.php
+++ b/tests/Integration/DockerContainerTest.php
@@ -13,7 +13,7 @@
final class DockerContainerTest extends TestCase
{
- private const string ROOT = 'root';
+ private const string ROOT = 'xpto';
private const string DATABASE = 'test_adm';
public function testMultipleContainersAreRunSuccessfully(): void
@@ -28,8 +28,8 @@ public function testMultipleContainersAreRunSuccessfully(): void
->withPortMapping(portOnHost: 3306, portOnContainer: 3306)
->withRootPassword(rootPassword: self::ROOT)
->withGrantedHosts()
+ ->withReadinessTimeout(timeoutInSeconds: 60)
->withoutAutoRemove()
- ->withVolumeMapping(pathOnHost: '/var/lib/mysql', pathOnContainer: '/var/lib/mysql')
->runIfNotExists();
/** @And the MySQL container is running */
@@ -53,9 +53,8 @@ public function testMultipleContainersAreRunSuccessfully(): void
->withVolumeMapping(pathOnHost: '/test-adm-migrations', pathOnContainer: '/flyway/sql')
->withWaitBeforeRun(
wait: ContainerWaitForDependency::untilReady(
- condition: MySQLReady::from(
- container: $mySQLContainer
- )
+ condition: MySQLReady::from(container: $mySQLContainer),
+ timeoutInSeconds: 30
)
)
->withEnvironmentVariable(key: 'FLYWAY_URL', value: $jdbcUrl)
diff --git a/tests/Unit/ClientMock.php b/tests/Unit/ClientMock.php
deleted file mode 100644
index 23b402b..0000000
--- a/tests/Unit/ClientMock.php
+++ /dev/null
@@ -1,71 +0,0 @@
-runResponses[] = $data;
- $this->runIsSuccessful = $isSuccessful;
- }
-
- public function withDockerListResponse(string $data): void
- {
- $this->listResponses[] = $data;
- $this->listIsSuccessful = !empty($data);
- }
-
- public function withDockerInspectResponse(array $data): void
- {
- $this->inspectResponses[] = $data;
- $this->inspectIsSuccessful = !empty($data);
- }
-
- public function execute(Command $command): ExecutionCompleted
- {
- [$output, $isSuccessful] = match (get_class($command)) {
- DockerRun::class => [array_shift($this->runResponses), $this->runIsSuccessful],
- DockerList::class => [array_shift($this->listResponses), $this->listIsSuccessful],
- DockerInspect::class => [json_encode([array_shift($this->inspectResponses)]), $this->inspectIsSuccessful],
- default => ['', false]
- };
-
- return new readonly class($output, $isSuccessful) implements ExecutionCompleted {
- public function __construct(private string $output, private bool $isSuccessful)
- {
- }
-
- public function getOutput(): string
- {
- return $this->output;
- }
-
- public function isSuccessful(): bool
- {
- return $this->isSuccessful;
- }
- };
- }
-}
diff --git a/tests/Unit/CommandHandlerMock.php b/tests/Unit/CommandHandlerMock.php
deleted file mode 100644
index 4d05d0b..0000000
--- a/tests/Unit/CommandHandlerMock.php
+++ /dev/null
@@ -1,30 +0,0 @@
-buildFrom(template: 'echo %s', values: $this->command);
- }
-}
diff --git a/tests/Unit/CommandWithTimeoutMock.php b/tests/Unit/CommandWithTimeoutMock.php
deleted file mode 100644
index 4c22cd2..0000000
--- a/tests/Unit/CommandWithTimeoutMock.php
+++ /dev/null
@@ -1,27 +0,0 @@
-buildFrom(template: 'echo %s', values: $this->command);
- }
-
- public function getTimeoutInWholeSeconds(): int
- {
- return $this->timeoutInWholeSeconds;
- }
-}
diff --git a/tests/Unit/GenericDockerContainerTest.php b/tests/Unit/GenericDockerContainerTest.php
new file mode 100644
index 0000000..299d5b5
--- /dev/null
+++ b/tests/Unit/GenericDockerContainerTest.php
@@ -0,0 +1,927 @@
+client = new ClientMock();
+ }
+
+ public function testRunContainerSuccessfully(): void
+ {
+ /** @Given a container configured with an image and a name */
+ $container = TestableGenericDockerContainer::createWith(
+ image: 'alpine:latest',
+ name: 'test-alpine',
+ client: $this->client
+ );
+
+ /** @And the Docker daemon returns a valid container ID and inspect response */
+ $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId());
+ $this->client->withDockerInspectResponse(
+ data: InspectResponseFixture::build(
+ hostname: 'test-alpine',
+ env: ['PATH=/usr/local/bin']
+ )
+ );
+
+ /** @When the container is started */
+ $started = $container->run();
+
+ /** @Then the container should be running with the expected properties */
+ self::assertInstanceOf(ContainerStarted::class, $started);
+ self::assertSame(InspectResponseFixture::shortContainerId(), $started->getId());
+ self::assertSame('test-alpine', $started->getName());
+ self::assertSame('test-alpine', $started->getAddress()->getHostname());
+ self::assertSame('172.22.0.2', $started->getAddress()->getIp());
+ }
+
+ public function testRunContainerWithFullConfiguration(): void
+ {
+ /** @Given a fully configured container */
+ $container = TestableGenericDockerContainer::createWith(
+ image: 'nginx:latest',
+ name: 'web-server',
+ client: $this->client
+ )
+ ->withNetwork(name: 'my-network')
+ ->withPortMapping(portOnHost: 8080, portOnContainer: 80)
+ ->withVolumeMapping(pathOnHost: '/var/www', pathOnContainer: '/usr/share/nginx/html')
+ ->withEnvironmentVariable(key: 'NGINX_HOST', value: 'localhost')
+ ->withEnvironmentVariable(key: 'NGINX_PORT', value: '80');
+
+ /** @And the Docker daemon returns valid responses */
+ $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId());
+ $this->client->withDockerInspectResponse(
+ data: InspectResponseFixture::build(
+ hostname: 'web-server',
+ networkName: 'my-network',
+ env: ['NGINX_HOST=localhost', 'NGINX_PORT=80'],
+ exposedPorts: ['80/tcp' => (object)[]]
+ )
+ );
+
+ /** @When the container is started */
+ $started = $container->run();
+
+ /** @Then the container should expose the configured environment variables */
+ self::assertSame('localhost', $started->getEnvironmentVariables()->getValueBy(key: 'NGINX_HOST'));
+ self::assertSame('80', $started->getEnvironmentVariables()->getValueBy(key: 'NGINX_PORT'));
+
+ /** @And the address should reflect the exposed port */
+ self::assertSame(80, $started->getAddress()->getPorts()->firstExposedPort());
+ self::assertSame([80], $started->getAddress()->getPorts()->exposedPorts());
+ }
+
+ public function testRunContainerWithMultiplePortMappings(): void
+ {
+ /** @Given a container with multiple port mappings */
+ $container = TestableGenericDockerContainer::createWith(
+ image: 'nginx:latest',
+ name: 'multi-port',
+ client: $this->client
+ )
+ ->withPortMapping(portOnHost: 8080, portOnContainer: 80)
+ ->withPortMapping(portOnHost: 8443, portOnContainer: 443);
+
+ /** @And the Docker daemon returns valid responses */
+ $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId());
+ $this->client->withDockerInspectResponse(
+ data: InspectResponseFixture::build(
+ hostname: 'multi-port',
+ exposedPorts: ['80/tcp' => (object)[], '443/tcp' => (object)[]]
+ )
+ );
+
+ /** @When the container is started */
+ $started = $container->run();
+
+ /** @Then both ports should be exposed */
+ self::assertSame([80, 443], $started->getAddress()->getPorts()->exposedPorts());
+ self::assertSame(80, $started->getAddress()->getPorts()->firstExposedPort());
+ }
+
+ public function testRunContainerWithoutAutoRemove(): void
+ {
+ /** @Given a container with auto-remove disabled */
+ $container = TestableGenericDockerContainer::createWith(
+ image: 'alpine:latest',
+ name: 'persistent',
+ client: $this->client
+ )->withoutAutoRemove();
+
+ /** @And the Docker daemon returns valid responses */
+ $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId());
+ $this->client->withDockerInspectResponse(data: InspectResponseFixture::build(hostname: 'persistent'));
+
+ /** @When the container is started */
+ $started = $container->run();
+
+ /** @Then the container should be running */
+ self::assertSame('persistent', $started->getName());
+ }
+
+ public function testRunContainerWithCopyToContainer(): void
+ {
+ /** @Given a container with files to copy */
+ $container = TestableGenericDockerContainer::createWith(
+ image: 'alpine:latest',
+ name: 'copy-test',
+ client: $this->client
+ )->copyToContainer(pathOnHost: '/host/config', pathOnContainer: '/app/config');
+
+ /** @And the Docker daemon returns valid responses */
+ $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId());
+ $this->client->withDockerInspectResponse(data: InspectResponseFixture::build(hostname: 'copy-test'));
+
+ /** @When the container is started (docker cp is automatically called) */
+ $started = $container->run();
+
+ /** @Then the container should be running */
+ self::assertSame('copy-test', $started->getName());
+ }
+
+ public function testRunContainerWithCommands(): void
+ {
+ /** @Given a container */
+ $container = TestableGenericDockerContainer::createWith(
+ image: 'alpine:latest',
+ name: 'cmd-test',
+ client: $this->client
+ );
+
+ /** @And the Docker daemon returns valid responses */
+ $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId());
+ $this->client->withDockerInspectResponse(data: InspectResponseFixture::build(hostname: 'cmd-test'));
+
+ /** @When the container is started with commands */
+ $started = $container->run(commands: ['echo', 'hello']);
+
+ /** @Then the container should be running */
+ self::assertSame('cmd-test', $started->getName());
+ }
+
+ public function testRunContainerWithWaitBeforeRun(): void
+ {
+ /** @Given a condition that is immediately ready */
+ $condition = $this->createMock(ContainerReady::class);
+ $condition->expects(self::once())->method('isReady')->willReturn(true);
+
+ /** @And a container with a wait-before-run condition */
+ $container = TestableGenericDockerContainer::createWith(
+ image: 'alpine:latest',
+ name: 'wait-test',
+ client: $this->client
+ )->withWaitBeforeRun(wait: ContainerWaitForDependency::untilReady(condition: $condition));
+
+ /** @And the Docker daemon returns valid responses */
+ $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId());
+ $this->client->withDockerInspectResponse(data: InspectResponseFixture::build(hostname: 'wait-test'));
+
+ /** @When the container is started */
+ $started = $container->run();
+
+ /** @Then the container should be running (wait was called) */
+ self::assertSame('wait-test', $started->getName());
+ }
+
+ public function testRunIfNotExistsCreatesNewContainer(): void
+ {
+ /** @Given a container that does not exist */
+ $container = TestableGenericDockerContainer::createWith(
+ image: 'alpine:latest',
+ name: 'new-container',
+ client: $this->client
+ )->withEnvironmentVariable(key: 'APP_ENV', value: 'test');
+
+ /** @And the Docker list returns empty (container does not exist) */
+ $this->client->withDockerListResponse(data: '');
+
+ /** @And the Docker daemon returns valid run and inspect responses */
+ $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId());
+ $this->client->withDockerInspectResponse(
+ data: InspectResponseFixture::build(
+ hostname: 'new-container',
+ env: ['APP_ENV=test']
+ )
+ );
+
+ /** @When runIfNotExists is called */
+ $started = $container->runIfNotExists();
+
+ /** @Then a new container should be created */
+ self::assertSame('new-container', $started->getName());
+ self::assertSame('test', $started->getEnvironmentVariables()->getValueBy(key: 'APP_ENV'));
+ }
+
+ public function testRunIfNotExistsReturnsExistingContainer(): void
+ {
+ /** @Given a container that already exists */
+ $container = TestableGenericDockerContainer::createWith(
+ image: 'alpine:latest',
+ name: 'existing',
+ client: $this->client
+ );
+
+ /** @And the Docker list returns the existing container ID */
+ $this->client->withDockerListResponse(data: InspectResponseFixture::containerId());
+
+ /** @And the Docker inspect returns the container details */
+ $this->client->withDockerInspectResponse(
+ data: InspectResponseFixture::build(
+ hostname: 'existing',
+ env: ['EXISTING=true']
+ )
+ );
+
+ /** @When runIfNotExists is called */
+ $started = $container->runIfNotExists();
+
+ /** @Then the existing container should be returned */
+ self::assertSame('existing', $started->getName());
+ self::assertSame(InspectResponseFixture::shortContainerId(), $started->getId());
+ self::assertSame('true', $started->getEnvironmentVariables()->getValueBy(key: 'EXISTING'));
+ }
+
+ public function testStopContainer(): void
+ {
+ /** @Given a running container */
+ $container = TestableGenericDockerContainer::createWith(
+ image: 'alpine:latest',
+ name: 'stop-test',
+ client: $this->client
+ );
+
+ $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId());
+ $this->client->withDockerInspectResponse(data: InspectResponseFixture::build(hostname: 'stop-test'));
+ $this->client->withDockerStopResponse(output: '');
+
+ $started = $container->run();
+
+ /** @When the container is stopped */
+ $result = $started->stop();
+
+ /** @Then the stop should be successful */
+ self::assertTrue($result->isSuccessful());
+ }
+
+ public function testExecuteAfterStarted(): void
+ {
+ /** @Given a running container */
+ $container = TestableGenericDockerContainer::createWith(
+ image: 'alpine:latest',
+ name: 'exec-test',
+ client: $this->client
+ );
+
+ $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId());
+ $this->client->withDockerInspectResponse(data: InspectResponseFixture::build(hostname: 'exec-test'));
+ $this->client->withDockerExecuteResponse(output: 'command output');
+
+ $started = $container->run();
+
+ /** @When commands are executed inside the running container */
+ $result = $started->executeAfterStarted(commands: ['ls', '-la']);
+
+ /** @Then the execution should be successful */
+ self::assertTrue($result->isSuccessful());
+ self::assertSame('command output', $result->getOutput());
+ }
+
+ public function testExceptionWhenRunFails(): void
+ {
+ /** @Given a container that will fail to start */
+ $container = TestableGenericDockerContainer::createWith(
+ image: 'invalid:image',
+ name: 'fail-test',
+ client: $this->client
+ );
+
+ /** @And the Docker daemon returns a failure */
+ $this->client->withDockerRunResponse(data: 'Cannot connect to the Docker daemon.', isSuccessful: false);
+
+ /** @Then a DockerCommandExecutionFailed exception should be thrown */
+ $this->expectException(DockerCommandExecutionFailed::class);
+ $this->expectExceptionMessageMatches('/Cannot connect to the Docker daemon/');
+
+ /** @When the container is started */
+ $container->run();
+ }
+
+ public function testExceptionWhenContainerInspectReturnsEmpty(): void
+ {
+ /** @Given a container whose inspect returns empty data */
+ $container = TestableGenericDockerContainer::createWith(
+ image: 'alpine:latest',
+ name: 'ghost',
+ client: $this->client
+ );
+
+ $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId());
+ $this->client->withDockerInspectResponse(data: []);
+
+ /** @Then a DockerContainerNotFound exception should be thrown */
+ $this->expectException(DockerContainerNotFound::class);
+ $this->expectExceptionMessage('Docker container with name was not found.');
+
+ /** @When the container is started */
+ $container->run();
+ }
+
+ public function testAddressDefaultsWhenNetworkInfoIsEmpty(): void
+ {
+ /** @Given a container with empty network info */
+ $container = TestableGenericDockerContainer::createWith(
+ image: 'alpine:latest',
+ name: 'no-net',
+ client: $this->client
+ );
+
+ $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId());
+ $this->client->withDockerInspectResponse(
+ data: InspectResponseFixture::build(
+ hostname: '',
+ ipAddress: ''
+ )
+ );
+
+ /** @When the container is started */
+ $started = $container->run();
+
+ /** @Then the address should fall back to defaults */
+ self::assertSame('127.0.0.1', $started->getAddress()->getIp());
+ self::assertSame('localhost', $started->getAddress()->getHostname());
+ }
+
+ public function testContainerWithNoExposedPortsReturnsNull(): void
+ {
+ /** @Given a container with no exposed ports */
+ $container = TestableGenericDockerContainer::createWith(
+ image: 'alpine:latest',
+ name: 'no-ports',
+ client: $this->client
+ );
+
+ $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId());
+ $this->client->withDockerInspectResponse(data: InspectResponseFixture::build(hostname: 'no-ports'));
+
+ /** @When the container is started */
+ $started = $container->run();
+
+ /** @Then firstExposedPort should return null */
+ self::assertNull($started->getAddress()->getPorts()->firstExposedPort());
+ self::assertEmpty($started->getAddress()->getPorts()->exposedPorts());
+ }
+
+ public function testEnvironmentVariableReturnsEmptyStringForMissingKey(): void
+ {
+ /** @Given a running container with known environment variables */
+ $container = TestableGenericDockerContainer::createWith(
+ image: 'alpine:latest',
+ name: 'env-test',
+ client: $this->client
+ );
+
+ $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId());
+ $this->client->withDockerInspectResponse(
+ data: InspectResponseFixture::build(
+ hostname: 'env-test',
+ env: ['KNOWN=value']
+ )
+ );
+
+ $started = $container->run();
+
+ /** @When querying for a missing environment variable */
+ $actual = $started->getEnvironmentVariables()->getValueBy(key: 'MISSING');
+
+ /** @Then it should return an empty string */
+ self::assertSame('', $actual);
+ }
+
+ public function testRunContainerWithAutoGeneratedName(): void
+ {
+ /** @Given a container without a name */
+ $container = TestableGenericDockerContainer::createWith(
+ image: 'alpine:latest',
+ name: null,
+ client: $this->client
+ );
+
+ /** @And the Docker daemon returns valid responses (with any hostname from KSUID) */
+ $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId());
+ $this->client->withDockerInspectResponse(data: InspectResponseFixture::build(hostname: 'auto-generated'));
+
+ /** @When the container is started */
+ $started = $container->run();
+
+ /** @Then the container should have an auto-generated name (non-empty) */
+ self::assertNotEmpty($started->getName());
+ }
+
+ public function testRunContainerWithWaitAfterStarted(): void
+ {
+ /** @Given a container */
+ $container = TestableGenericDockerContainer::createWith(
+ image: 'alpine:latest',
+ name: 'wait-after',
+ client: $this->client
+ );
+
+ /** @And the Docker daemon returns valid responses */
+ $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId());
+ $this->client->withDockerInspectResponse(data: InspectResponseFixture::build(hostname: 'wait-after'));
+
+ /** @When the container is started with a wait-after condition */
+ $start = microtime(as_float: true);
+ $started = $container->run(waitAfterStarted: ContainerWaitForTime::forSeconds(seconds: 1));
+ $elapsed = microtime(as_float: true) - $start;
+
+ /** @Then the container should have waited after starting */
+ self::assertSame('wait-after', $started->getName());
+ self::assertGreaterThanOrEqual(0.9, $elapsed);
+ }
+
+ public function testStopContainerWithCustomTimeout(): void
+ {
+ /** @Given a running container */
+ $container = TestableGenericDockerContainer::createWith(
+ image: 'alpine:latest',
+ name: 'stop-timeout',
+ client: $this->client
+ );
+
+ $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId());
+ $this->client->withDockerInspectResponse(data: InspectResponseFixture::build(hostname: 'stop-timeout'));
+ $this->client->withDockerStopResponse(output: '');
+
+ $started = $container->run();
+
+ /** @When the container is stopped with a custom timeout */
+ $result = $started->stop(timeoutInWholeSeconds: 10);
+
+ /** @Then the stop should be successful */
+ self::assertTrue($result->isSuccessful());
+ }
+
+ public function testExecuteAfterStartedReturnsFailure(): void
+ {
+ /** @Given a running container */
+ $container = TestableGenericDockerContainer::createWith(
+ image: 'alpine:latest',
+ name: 'exec-fail',
+ client: $this->client
+ );
+
+ $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId());
+ $this->client->withDockerInspectResponse(data: InspectResponseFixture::build(hostname: 'exec-fail'));
+ $this->client->withDockerExecuteResponse(output: 'command not found', isSuccessful: false);
+
+ $started = $container->run();
+
+ /** @When an invalid command is executed */
+ $result = $started->executeAfterStarted(commands: ['invalid-command']);
+
+ /** @Then the result should indicate failure */
+ self::assertFalse($result->isSuccessful());
+ self::assertSame('command not found', $result->getOutput());
+ }
+
+ public function testRunIfNotExistsWithWaitBeforeRun(): void
+ {
+ /** @Given a condition that is immediately ready */
+ $condition = $this->createMock(ContainerReady::class);
+ $condition->expects(self::once())->method('isReady')->willReturn(true);
+
+ /** @And a container with a wait-before-run that does not exist */
+ $container = TestableGenericDockerContainer::createWith(
+ image: 'alpine:latest',
+ name: 'wait-new',
+ client: $this->client
+ )->withWaitBeforeRun(wait: ContainerWaitForDependency::untilReady(condition: $condition));
+
+ /** @And the Docker list returns empty */
+ $this->client->withDockerListResponse(data: '');
+
+ /** @And the Docker daemon returns valid run and inspect responses */
+ $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId());
+ $this->client->withDockerInspectResponse(data: InspectResponseFixture::build(hostname: 'wait-new'));
+
+ /** @When runIfNotExists is called */
+ $started = $container->runIfNotExists();
+
+ /** @Then the wait-before-run should have been evaluated and the container created */
+ self::assertSame('wait-new', $started->getName());
+ }
+
+ public function testExceptionWhenWaitBeforeRunTimesOut(): void
+ {
+ /** @Given a condition that never becomes ready */
+ $condition = $this->createMock(ContainerReady::class);
+ $condition->method('isReady')->willReturn(false);
+
+ /** @And a container with a wait-before-run that has a short timeout */
+ $container = TestableGenericDockerContainer::createWith(
+ image: 'alpine:latest',
+ name: 'timeout-wait',
+ client: $this->client
+ )->withWaitBeforeRun(
+ wait: ContainerWaitForDependency::untilReady(
+ condition: $condition,
+ timeoutInSeconds: 1,
+ pollIntervalInMicroseconds: 50_000
+ )
+ );
+
+ /** @Then a ContainerWaitTimeout exception should be thrown */
+ $this->expectException(ContainerWaitTimeout::class);
+
+ /** @When the container is started */
+ $container->run();
+ }
+
+ public function testRunContainerWithMultipleVolumeMappings(): void
+ {
+ /** @Given a container with multiple volume mappings */
+ $container = TestableGenericDockerContainer::createWith(
+ image: 'alpine:latest',
+ name: 'multi-vol',
+ client: $this->client
+ )
+ ->withVolumeMapping(pathOnHost: '/data', pathOnContainer: '/app/data')
+ ->withVolumeMapping(pathOnHost: '/config', pathOnContainer: '/app/config');
+
+ /** @And the Docker daemon returns valid responses */
+ $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId());
+ $this->client->withDockerInspectResponse(data: InspectResponseFixture::build(hostname: 'multi-vol'));
+
+ /** @When the container is started */
+ $started = $container->run();
+
+ /** @Then the container should be running */
+ self::assertSame('multi-vol', $started->getName());
+ }
+
+ public function testRunContainerWithMultipleEnvironmentVariables(): void
+ {
+ /** @Given a container with multiple environment variables */
+ $container = TestableGenericDockerContainer::createWith(
+ image: 'alpine:latest',
+ name: 'multi-env',
+ client: $this->client
+ )
+ ->withEnvironmentVariable(key: 'DB_HOST', value: 'localhost')
+ ->withEnvironmentVariable(key: 'DB_PORT', value: '5432')
+ ->withEnvironmentVariable(key: 'DB_NAME', value: 'mydb');
+
+ /** @And the Docker daemon returns valid responses */
+ $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId());
+ $this->client->withDockerInspectResponse(
+ data: InspectResponseFixture::build(
+ hostname: 'multi-env',
+ env: ['DB_HOST=localhost', 'DB_PORT=5432', 'DB_NAME=mydb']
+ )
+ );
+
+ /** @When the container is started */
+ $started = $container->run();
+
+ /** @Then all environment variables should be accessible */
+ self::assertSame('localhost', $started->getEnvironmentVariables()->getValueBy(key: 'DB_HOST'));
+ self::assertSame('5432', $started->getEnvironmentVariables()->getValueBy(key: 'DB_PORT'));
+ self::assertSame('mydb', $started->getEnvironmentVariables()->getValueBy(key: 'DB_NAME'));
+ }
+
+ public function testRunContainerWithMultipleCopyInstructions(): void
+ {
+ /** @Given a container with multiple copy instructions */
+ $container = TestableGenericDockerContainer::createWith(
+ image: 'alpine:latest',
+ name: 'multi-copy',
+ client: $this->client
+ )
+ ->copyToContainer(pathOnHost: '/host/sql', pathOnContainer: '/app/sql')
+ ->copyToContainer(pathOnHost: '/host/config', pathOnContainer: '/app/config');
+
+ /** @And the Docker daemon returns valid responses */
+ $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId());
+ $this->client->withDockerInspectResponse(data: InspectResponseFixture::build(hostname: 'multi-copy'));
+
+ /** @When the container is started */
+ $started = $container->run();
+
+ /** @Then the container should be running (both docker cp calls were made) */
+ self::assertSame('multi-copy', $started->getName());
+ }
+
+ public function testExceptionWhenImageNameIsEmpty(): void
+ {
+ /** @Then an InvalidArgumentException should be thrown */
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('Image name cannot be empty.');
+
+ /** @When creating a container with an empty image name */
+ GenericDockerContainer::from(image: '');
+ }
+
+ public function testExceptionWhenDockerReturnsEmptyContainerId(): void
+ {
+ /** @Given a container */
+ $container = TestableGenericDockerContainer::createWith(
+ image: 'alpine:latest',
+ name: 'empty-id',
+ client: $this->client
+ );
+
+ /** @And the Docker daemon returns an empty container ID */
+ $this->client->withDockerRunResponse(data: ' ');
+
+ /** @Then an InvalidArgumentException should be thrown */
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('Container ID cannot be empty.');
+
+ /** @When the container is started */
+ $container->run();
+ }
+
+ public function testExceptionWhenDockerReturnsTooShortContainerId(): void
+ {
+ /** @Given a container */
+ $container = TestableGenericDockerContainer::createWith(
+ image: 'alpine:latest',
+ name: 'short-id',
+ client: $this->client
+ );
+
+ /** @And the Docker daemon returns a too-short container ID */
+ $this->client->withDockerRunResponse(data: 'abc123');
+
+ /** @Then an InvalidArgumentException should be thrown */
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('Container ID is too short. Minimum length is <12> characters.');
+
+ /** @When the container is started */
+ $container->run();
+ }
+
+ public function testRunCommandLineIncludesPortMapping(): void
+ {
+ /** @Given a container with a port mapping */
+ $container = TestableGenericDockerContainer::createWith(
+ image: 'nginx:latest',
+ name: 'port-cmd',
+ client: $this->client
+ )->withPortMapping(portOnHost: 8080, portOnContainer: 80);
+
+ /** @And the Docker daemon returns valid responses */
+ $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId());
+ $this->client->withDockerInspectResponse(data: InspectResponseFixture::build(hostname: 'port-cmd'));
+
+ /** @When the container is started */
+ $container->run();
+
+ /** @Then the executed docker run command should contain the port mapping argument */
+ $runCommand = $this->client->getExecutedCommandLines()[0];
+ self::assertStringContainsString('--publish 8080:80', $runCommand);
+ }
+
+ public function testRunCommandLineIncludesMultiplePortMappings(): void
+ {
+ /** @Given a container with multiple port mappings */
+ $container = TestableGenericDockerContainer::createWith(
+ image: 'nginx:latest',
+ name: 'multi-port-cmd',
+ client: $this->client
+ )
+ ->withPortMapping(portOnHost: 8080, portOnContainer: 80)
+ ->withPortMapping(portOnHost: 8443, portOnContainer: 443);
+
+ /** @And the Docker daemon returns valid responses */
+ $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId());
+ $this->client->withDockerInspectResponse(data: InspectResponseFixture::build(hostname: 'multi-port-cmd'));
+
+ /** @When the container is started */
+ $container->run();
+
+ /** @Then the docker run command should contain both port mapping arguments */
+ $runCommand = $this->client->getExecutedCommandLines()[0];
+ self::assertStringContainsString('--publish 8080:80', $runCommand);
+ self::assertStringContainsString('--publish 8443:443', $runCommand);
+ }
+
+ public function testRunCommandLineIncludesVolumeMapping(): void
+ {
+ /** @Given a container with a volume mapping */
+ $container = TestableGenericDockerContainer::createWith(
+ image: 'alpine:latest',
+ name: 'vol-cmd',
+ client: $this->client
+ )->withVolumeMapping(pathOnHost: '/host/data', pathOnContainer: '/app/data');
+
+ /** @And the Docker daemon returns valid responses */
+ $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId());
+ $this->client->withDockerInspectResponse(data: InspectResponseFixture::build(hostname: 'vol-cmd'));
+
+ /** @When the container is started */
+ $container->run();
+
+ /** @Then the docker run command should contain the volume mapping argument */
+ $runCommand = $this->client->getExecutedCommandLines()[0];
+ self::assertStringContainsString('--volume /host/data:/app/data', $runCommand);
+ }
+
+ public function testRunCommandLineIncludesEnvironmentVariable(): void
+ {
+ /** @Given a container with an environment variable */
+ $container = TestableGenericDockerContainer::createWith(
+ image: 'alpine:latest',
+ name: 'env-cmd',
+ client: $this->client
+ )->withEnvironmentVariable(key: 'APP_ENV', value: 'production');
+
+ /** @And the Docker daemon returns valid responses */
+ $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId());
+ $this->client->withDockerInspectResponse(data: InspectResponseFixture::build(hostname: 'env-cmd'));
+
+ /** @When the container is started */
+ $container->run();
+
+ /** @Then the docker run command should contain the environment variable argument */
+ $runCommand = $this->client->getExecutedCommandLines()[0];
+ self::assertStringContainsString("--env APP_ENV='production'", $runCommand);
+ }
+
+ public function testRunCommandLineIncludesNetwork(): void
+ {
+ /** @Given a container with a network */
+ $container = TestableGenericDockerContainer::createWith(
+ image: 'alpine:latest',
+ name: 'net-cmd',
+ client: $this->client
+ )->withNetwork(name: 'my-network');
+
+ /** @And the Docker daemon returns valid responses */
+ $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId());
+ $this->client->withDockerInspectResponse(data: InspectResponseFixture::build(hostname: 'net-cmd'));
+
+ /** @When the container is started */
+ $container->run();
+
+ /** @Then the docker run command should contain the network argument */
+ $runCommand = $this->client->getExecutedCommandLines()[0];
+ self::assertStringContainsString('--network=my-network', $runCommand);
+ }
+
+ public function testRunCommandLineIncludesAutoRemoveByDefault(): void
+ {
+ /** @Given a container with default configuration */
+ $container = TestableGenericDockerContainer::createWith(
+ image: 'alpine:latest',
+ name: 'rm-cmd',
+ client: $this->client
+ );
+
+ /** @And the Docker daemon returns valid responses */
+ $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId());
+ $this->client->withDockerInspectResponse(data: InspectResponseFixture::build(hostname: 'rm-cmd'));
+
+ /** @When the container is started */
+ $container->run();
+
+ /** @Then the docker run command should contain --rm */
+ $runCommand = $this->client->getExecutedCommandLines()[0];
+ self::assertStringContainsString('--rm', $runCommand);
+ }
+
+ public function testRunCommandLineExcludesAutoRemoveWhenDisabled(): void
+ {
+ /** @Given a container with auto-remove disabled */
+ $container = TestableGenericDockerContainer::createWith(
+ image: 'alpine:latest',
+ name: 'no-rm-cmd',
+ client: $this->client
+ )->withoutAutoRemove();
+
+ /** @And the Docker daemon returns valid responses */
+ $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId());
+ $this->client->withDockerInspectResponse(data: InspectResponseFixture::build(hostname: 'no-rm-cmd'));
+
+ /** @When the container is started */
+ $container->run();
+
+ /** @Then the docker run command should NOT contain --rm */
+ $runCommand = $this->client->getExecutedCommandLines()[0];
+ self::assertStringNotContainsString('--rm', $runCommand);
+ }
+
+ public function testRunCommandLineIncludesCommands(): void
+ {
+ /** @Given a container */
+ $container = TestableGenericDockerContainer::createWith(
+ image: 'alpine:latest',
+ name: 'args-cmd',
+ client: $this->client
+ );
+
+ /** @And the Docker daemon returns valid responses */
+ $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId());
+ $this->client->withDockerInspectResponse(data: InspectResponseFixture::build(hostname: 'args-cmd'));
+
+ /** @When the container is started with commands */
+ $container->run(commands: ['-connectRetries=15', 'clean', 'migrate']);
+
+ /** @Then the docker run command should end with the commands */
+ $runCommand = $this->client->getExecutedCommandLines()[0];
+ self::assertStringContainsString('-connectRetries=15 clean migrate', $runCommand);
+ }
+
+ public function testCopyToContainerExecutesDockerCpCommand(): void
+ {
+ /** @Given a container with a copy instruction */
+ $container = TestableGenericDockerContainer::createWith(
+ image: 'alpine:latest',
+ name: 'cp-cmd',
+ client: $this->client
+ )->copyToContainer(pathOnHost: '/host/sql', pathOnContainer: '/app/sql');
+
+ /** @And the Docker daemon returns valid responses */
+ $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId());
+ $this->client->withDockerInspectResponse(data: InspectResponseFixture::build(hostname: 'cp-cmd'));
+
+ /** @When the container is started */
+ $container->run();
+
+ /** @Then the second executed command should be a docker cp with the correct arguments */
+ $cpCommand = $this->client->getExecutedCommandLines()[2];
+ self::assertStringStartsWith('docker cp', $cpCommand);
+ self::assertStringContainsString('/host/sql', $cpCommand);
+ self::assertStringContainsString('/app/sql', $cpCommand);
+ }
+
+ public function testStopExecutesDockerStopCommand(): void
+ {
+ /** @Given a running container */
+ $container = TestableGenericDockerContainer::createWith(
+ image: 'alpine:latest',
+ name: 'stop-cmd',
+ client: $this->client
+ );
+
+ $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId());
+ $this->client->withDockerInspectResponse(data: InspectResponseFixture::build(hostname: 'stop-cmd'));
+ $this->client->withDockerStopResponse(output: '');
+
+ $started = $container->run();
+
+ /** @When the container is stopped */
+ $started->stop();
+
+ /** @Then a docker stop command should have been executed with the container ID */
+ $stopCommand = $this->client->getExecutedCommandLines()[2];
+ self::assertStringStartsWith('docker stop', $stopCommand);
+ self::assertStringContainsString(InspectResponseFixture::shortContainerId(), $stopCommand);
+ }
+
+ public function testExecuteAfterStartedRunsDockerExecCommand(): void
+ {
+ /** @Given a running container */
+ $container = TestableGenericDockerContainer::createWith(
+ image: 'alpine:latest',
+ name: 'exec-cmd',
+ client: $this->client
+ );
+
+ $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId());
+ $this->client->withDockerInspectResponse(data: InspectResponseFixture::build(hostname: 'exec-cmd'));
+ $this->client->withDockerExecuteResponse(output: '', isSuccessful: true);
+
+ $started = $container->run();
+
+ /** @When executing commands inside the container */
+ $started->executeAfterStarted(commands: ['ls', '-la', '/tmp']);
+
+ /** @Then a docker exec command should have been executed with the container name and commands */
+ $execCommand = $this->client->getExecutedCommandLines()[2];
+ self::assertSame('docker exec exec-cmd ls -la /tmp', $execCommand);
+ }
+}
diff --git a/tests/Unit/Internal/Client/DockerClientTest.php b/tests/Unit/Internal/Client/DockerClientTest.php
index 613f553..20159ee 100644
--- a/tests/Unit/Internal/Client/DockerClientTest.php
+++ b/tests/Unit/Internal/Client/DockerClientTest.php
@@ -2,47 +2,71 @@
declare(strict_types=1);
-namespace TinyBlocks\DockerContainer\Internal\Client;
+namespace Test\Unit\Internal\Client;
use PHPUnit\Framework\TestCase;
-use Test\Unit\CommandMock;
-use Test\Unit\CommandWithTimeoutMock;
+use Test\Unit\Mocks\CommandMock;
+use Test\Unit\Mocks\CommandWithTimeoutMock;
+use TinyBlocks\DockerContainer\Internal\Client\DockerClient;
use TinyBlocks\DockerContainer\Internal\Exceptions\DockerCommandExecutionFailed;
final class DockerClientTest extends TestCase
{
- private Client $client;
+ private DockerClient $client;
protected function setUp(): void
{
$this->client = new DockerClient();
}
- public function testDockerCommandExecution(): void
+ public function testExecuteCommandSuccessfully(): void
{
/** @Given a command that will succeed */
- $command = new CommandMock(command: [' Hello, World! ']);
+ $command = new CommandMock(command: 'echo Hello');
/** @When the command is executed */
$actual = $this->client->execute(command: $command);
- /** @Then the output should be the expected one */
+ /** @Then the output should contain the expected result */
self::assertTrue($actual->isSuccessful());
- self::assertEquals("Hello, World!\n", $actual->getOutput());
+ self::assertStringContainsString('Hello', $actual->getOutput());
}
- public function testExceptionWhenDockerCommandExecutionFailed(): void
+ public function testExecuteCommandWithValidTimeout(): void
{
- /** @Given a command that will fail due to invalid timeout */
- $command = new CommandWithTimeoutMock(command: ['Hello, World!'], timeoutInWholeSeconds: -10);
+ /** @Given a command with a valid timeout */
+ $command = new CommandWithTimeoutMock(command: 'echo Hello', timeoutInWholeSeconds: 10);
- /** @Then an exception indicating that the Docker command execution failed should be thrown */
+ /** @When the command is executed */
+ $actual = $this->client->execute(command: $command);
+
+ /** @Then the execution should succeed */
+ self::assertTrue($actual->isSuccessful());
+ }
+
+ public function testExceptionFromProcessWhenTimeoutIsInvalid(): void
+ {
+ /** @Given a command with an invalid negative timeout */
+ $command = new CommandWithTimeoutMock(command: 'echo Hello', timeoutInWholeSeconds: -10);
+
+ /** @Then a DockerCommandExecutionFailed exception should be thrown via fromProcess */
$this->expectException(DockerCommandExecutionFailed::class);
- $this->expectExceptionMessage(
- 'Failed to execute command in Docker container. Reason: The timeout value must be a valid positive integer or float number.'
- );
+ $this->expectExceptionMessageMatches('/Failed to execute command .* Reason: .*timeout/i');
/** @When the command is executed */
$this->client->execute(command: $command);
}
+
+ public function testExecuteCommandReturnsErrorOutput(): void
+ {
+ /** @Given a command that will fail */
+ $command = new CommandMock(command: 'cat /nonexistent/file/path');
+
+ /** @When the command is executed */
+ $actual = $this->client->execute(command: $command);
+
+ /** @Then the execution should indicate failure */
+ self::assertFalse($actual->isSuccessful());
+ self::assertNotEmpty($actual->getOutput());
+ }
}
diff --git a/tests/Unit/Internal/CommandHandlerTest.php b/tests/Unit/Internal/CommandHandlerTest.php
deleted file mode 100644
index 69b7ce0..0000000
--- a/tests/Unit/Internal/CommandHandlerTest.php
+++ /dev/null
@@ -1,195 +0,0 @@
-client = new ClientMock();
- $this->commandHandler = new ContainerCommandHandler(client: $this->client);
- }
-
- public function testShouldRunContainerSuccessfully(): void
- {
- /** @Given a DockerRun command */
- $command = DockerRun::from(
- commands: [],
- container: Container::create(name: 'alpine', image: 'alpine:latest'),
- network: NetworkOption::from(name: 'bridge'),
- detached: SimpleCommandOption::DETACH,
- autoRemove: SimpleCommandOption::REMOVE,
- environmentVariables: CommandOptions::createFromOptions(
- commandOption: EnvironmentVariableOption::from(key: 'PASSWORD', value: 'root')
- )
- );
-
- /** @And the DockerRun command was executed and returned the container ID */
- $this->client->withDockerRunResponse(data: '6acae5967be05d8441b4109eea3e4dec5e775068a2a99d95808afb21b2e0a2c8');
-
- /** @And the DockerInspect command was executed and returned the container's details */
- $this->client->withDockerInspectResponse(data: [
- 'Id' => '6acae5967be05d8441b4109eea3e4dec5e775068a2a99d95808afb21b2e0a2c8',
- 'Name' => '/alpine',
- 'Config' => [
- 'Hostname' => 'alpine',
- 'ExposedPorts' => [],
- 'Env' => [
- 'PASSWORD=root'
- ]
- ],
- 'NetworkSettings' => [
- 'Networks' => [
- 'bridge' => [
- 'IPAddress' => '172.22.0.2'
- ]
- ]
- ]
- ]);
-
- /** @When running the container */
- $container = $this->commandHandler->run(dockerRun: $command);
-
- /** @Then the container should be created with the correct details */
- self::assertSame('root', $container->environmentVariables->getValueBy(key: 'PASSWORD'));
- self::assertSame('alpine', $container->name->value);
- self::assertSame('alpine', $container->address->getHostname());
- self::assertSame('172.22.0.2', $container->address->getIp());
- self::assertSame('6acae5967be0', $container->id->value);
- self::assertSame('alpine:latest', $container->image->name);
- }
-
- public function testShouldFindContainerSuccessfully(): void
- {
- /** @Given a DockerList command */
- $command = DockerList::from(container: Container::create(name: 'alpine', image: 'alpine:latest'));
-
- /** @And the DockerList command was executed and returned the container ID */
- $this->client->withDockerListResponse(data: '6acae5967be05d8441b4109eea3e4dec5e775068a2a99d95808afb21b2e0a2c8');
-
- /** @And the DockerInspect command was executed and returned the container details */
- $this->client->withDockerInspectResponse(data: [
- 'Id' => '6acae5967be05d8441b4109eea3e4dec5e775068a2a99d95808afb21b2e0a2c8',
- 'Name' => '/alpine',
- 'Config' => [
- 'Hostname' => 'alpine',
- 'ExposedPorts' => [],
- 'Env' => [
- 'PASSWORD=root'
- ]
- ],
- 'NetworkSettings' => [
- 'Networks' => [
- 'bridge' => [
- 'IPAddress' => '172.22.0.2'
- ]
- ]
- ]
- ]);
-
- /** @When finding the container */
- $container = $this->commandHandler->findBy(dockerList: $command);
-
- /** @Then the container should be returned with the correct details */
- self::assertSame('root', $container->environmentVariables->getValueBy(key: 'PASSWORD'));
- self::assertSame('alpine', $container->name->value);
- self::assertSame('alpine', $container->address->getHostname());
- self::assertSame('172.22.0.2', $container->address->getIp());
- self::assertSame('6acae5967be0', $container->id->value);
- self::assertSame('alpine:latest', $container->image->name);
- }
-
- public function testShouldExecuteCommandSuccessfully(): void
- {
- /** @Given a DockerList command */
- $command = DockerList::from(container: Container::create(name: 'alpine', image: 'alpine:latest'));
-
- /** @And the DockerList command was executed and returned the container ID */
- $this->client->withDockerListResponse(data: '6acae5967be05d8441b4109eea3e4dec5e775068a2a99d95808afb21b2e0a2c8');
-
- /** @When executing the DockerList command */
- $executionCompleted = $this->commandHandler->execute(command: $command);
-
- /** @Then the execution should be successful and return the correct output */
- self::assertTrue($executionCompleted->isSuccessful());
- self::assertSame(
- '6acae5967be05d8441b4109eea3e4dec5e775068a2a99d95808afb21b2e0a2c8',
- $executionCompleted->getOutput()
- );
- }
-
- public function testExceptionWhenDockerContainerNotFound(): void
- {
- /** @Given a DockerRun command */
- $command = DockerRun::from(
- commands: [],
- container: Container::create(name: 'alpine', image: 'alpine:latest'),
- network: NetworkOption::from(name: 'bridge'),
- detached: SimpleCommandOption::DETACH,
- autoRemove: SimpleCommandOption::REMOVE,
- environmentVariables: CommandOptions::createFromOptions(
- commandOption: EnvironmentVariableOption::from(key: 'PASSWORD', value: 'root')
- )
- );
-
- /** @And the DockerRun command was executed and returned the container ID */
- $this->client->withDockerRunResponse(data: '6acae5967be05d8441b4109eea3e4dec5e775068a2a99d95808afb21b2e0a2c8');
-
- /** @And the DockerInspect command was executed but returned an empty response */
- $this->client->withDockerInspectResponse(data: []);
-
- /** @Then an exception indicating that the Docker container was not found should be thrown */
- $this->expectException(DockerContainerNotFound::class);
- $this->expectExceptionMessage('Docker container with name was not found.');
-
- /** @When running the container */
- $this->commandHandler->run(dockerRun: $command);
- }
-
- public function testExceptionWhenDockerConnectionFailure(): void
- {
- /** @Given a DockerRun command */
- $command = DockerRun::from(
- commands: [],
- container: Container::create(name: 'alpine', image: 'alpine:latest'),
- network: NetworkOption::from(name: 'bridge'),
- detached: SimpleCommandOption::DETACH,
- autoRemove: SimpleCommandOption::REMOVE,
- environmentVariables: CommandOptions::createFromOptions(
- commandOption: EnvironmentVariableOption::from(key: 'PASSWORD', value: 'root')
- )
- );
-
- /** @And the DockerRun command was executed and returned the container ID */
- $this->client->withDockerRunResponse(data: 'Cannot connect to the Docker daemon.', isSuccessful: false);
-
- /** @Then an exception indicating cannot connect to the Docker daemon */
- $template = 'Failed to execute command <%s> in Docker container. Reason: %s';
- $this->expectException(DockerCommandExecutionFailed::class);
- $this->expectExceptionMessage(
- sprintf($template, $command->toCommandLine(), 'Cannot connect to the Docker daemon.')
- );
-
- /** @When running the container */
- $this->commandHandler->run(dockerRun: $command);
- }
-}
diff --git a/tests/Unit/Internal/Commands/DockerCopyTest.php b/tests/Unit/Internal/Commands/DockerCopyTest.php
deleted file mode 100644
index 795acbc..0000000
--- a/tests/Unit/Internal/Commands/DockerCopyTest.php
+++ /dev/null
@@ -1,33 +0,0 @@
-toCommandLine();
-
- /** @Then the command line should be as expected */
- self::assertSame('docker cp /path/to/source abc123abc123:/path/to/destination', $actual);
- }
-}
diff --git a/tests/Unit/Internal/Commands/DockerExecuteTest.php b/tests/Unit/Internal/Commands/DockerExecuteTest.php
deleted file mode 100644
index c1a505e..0000000
--- a/tests/Unit/Internal/Commands/DockerExecuteTest.php
+++ /dev/null
@@ -1,26 +0,0 @@
-toCommandLine();
-
- /** @Then the command line should be as expected */
- self::assertSame('docker exec container-name ls -la', $actual);
- }
-}
diff --git a/tests/Unit/Internal/Commands/DockerInspectTest.php b/tests/Unit/Internal/Commands/DockerInspectTest.php
deleted file mode 100644
index 465d604..0000000
--- a/tests/Unit/Internal/Commands/DockerInspectTest.php
+++ /dev/null
@@ -1,23 +0,0 @@
-toCommandLine();
-
- /** @Then the command line should be as expected */
- self::assertSame('docker inspect abc123abc123', $actual);
- }
-}
diff --git a/tests/Unit/Internal/Commands/DockerListTest.php b/tests/Unit/Internal/Commands/DockerListTest.php
deleted file mode 100644
index 546035b..0000000
--- a/tests/Unit/Internal/Commands/DockerListTest.php
+++ /dev/null
@@ -1,23 +0,0 @@
-toCommandLine();
-
- /** @Then the command line should be as expected */
- self::assertSame('docker ps --all --quiet --filter name=container-name', $actual);
- }
-}
diff --git a/tests/Unit/Internal/Commands/DockerRunTest.php b/tests/Unit/Internal/Commands/DockerRunTest.php
deleted file mode 100644
index 769cbc8..0000000
--- a/tests/Unit/Internal/Commands/DockerRunTest.php
+++ /dev/null
@@ -1,48 +0,0 @@
-toCommandLine();
-
- /** @Then the command line should be as expected */
- self::assertSame(
- "docker run --user root --name alpine --hostname alpine --publish 8080:80 --network=host --volume /path/to/source:/path/to/destination --detach --rm --env key1='value1' alpine:latest",
- $actual
- );
- }
-}
diff --git a/tests/Unit/Internal/Commands/DockerStopTest.php b/tests/Unit/Internal/Commands/DockerStopTest.php
deleted file mode 100644
index 87e5282..0000000
--- a/tests/Unit/Internal/Commands/DockerStopTest.php
+++ /dev/null
@@ -1,24 +0,0 @@
-toCommandLine();
-
- /** @And the timeout should be correct */
- self::assertSame('docker stop 1234567890ab', $actual);
- self::assertSame(10, $command->getTimeoutInWholeSeconds());
- }
-}
diff --git a/tests/Unit/Internal/Containers/Drivers/MySQL/MySQLStartedTest.php b/tests/Unit/Internal/Containers/Drivers/MySQL/MySQLStartedTest.php
deleted file mode 100644
index 5598ce5..0000000
--- a/tests/Unit/Internal/Containers/Drivers/MySQL/MySQLStartedTest.php
+++ /dev/null
@@ -1,91 +0,0 @@
-commandHandler = new CommandHandlerMock();
- }
-
- public function testJdbcUrlWithDefaultOptions(): void
- {
- /** @Given a container with default configuration */
- $container = Container::from(
- id: ContainerId::from(value: 'abc123abc123'),
- name: Name::from(value: 'mysql'),
- image: Image::from(image: 'mysql:latest'),
- address: Address::create(),
- environmentVariables: EnvironmentVariables::createFrom(elements: ['MYSQL_DATABASE' => 'test_db'])
- );
-
- /** @And a MySQLStarted instance is created with the container */
- $mysqlStarted = new MySQLStarted(container: $container, commandHandler: $this->commandHandler);
-
- /** @When calling getJdbcUrl without any additional options */
- $actual = $mysqlStarted->getJdbcUrl();
-
- /** @Then the returned JDBC URL should include default options */
- self::assertSame(
- 'jdbc:mysql://localhost:3306/test_db?useSSL=false&useUnicode=yes&characterEncoding=UTF-8&allowPublicKeyRetrieval=true',
- $actual
- );
- }
-
- public function testJdbcUrlWithCustomOptions(): void
- {
- /** @Given a container with default configuration */
- $container = Container::from(
- id: ContainerId::from(value: 'abc123abc123'),
- name: Name::from(value: 'mysql'),
- image: Image::from(image: 'mysql:latest'),
- address: Address::create(),
- environmentVariables: EnvironmentVariables::createFrom(elements: ['MYSQL_DATABASE' => 'test_db'])
- );
-
- /** @And a MySQLStarted instance is created with the container */
- $mysqlStarted = new MySQLStarted(container: $container, commandHandler: $this->commandHandler);
-
- /** @When calling getJdbcUrl with custom options */
- $actual = $mysqlStarted->getJdbcUrl(options: ['connectTimeout' => '5000', 'useSSL' => 'true']);
-
- /** @Then the returned JDBC URL should include the custom options */
- self::assertSame('jdbc:mysql://localhost:3306/test_db?connectTimeout=5000&useSSL=true', $actual);
- }
-
- public function testJdbcUrlWithoutOptions(): void
- {
- /** @Given a container with default configuration */
- $container = Container::from(
- id: ContainerId::from(value: 'abc123abc123'),
- name: Name::from(value: 'mysql'),
- image: Image::from(image: 'mysql:latest'),
- address: Address::create(),
- environmentVariables: EnvironmentVariables::createFrom(elements: ['MYSQL_DATABASE' => 'test_db'])
- );
-
- /** @And a MySQLStarted instance is created with the container */
- $mysqlStarted = new MySQLStarted(container: $container, commandHandler: $this->commandHandler);
-
- /** @When calling getJdbcUrl with an empty options array */
- $actual = $mysqlStarted->getJdbcUrl(options: []);
-
- /** @Then the returned JDBC URL should not include any query string */
- self::assertSame('jdbc:mysql://localhost:3306/test_db', $actual);
- }
-}
diff --git a/tests/Unit/Internal/Containers/Models/ContainerIdTest.php b/tests/Unit/Internal/Containers/Models/ContainerIdTest.php
deleted file mode 100644
index 3414a03..0000000
--- a/tests/Unit/Internal/Containers/Models/ContainerIdTest.php
+++ /dev/null
@@ -1,62 +0,0 @@
-expectException(InvalidArgumentException::class);
- $this->expectExceptionMessage('Container ID cannot be empty.');
-
- /** @When the container ID is created with the empty value */
- ContainerId::from(value: $value);
- }
-
- public function testExceptionWhenIdIsTooShort(): void
- {
- /** @Given a value with less than 12 characters */
- $value = 'abc123';
-
- /** @Then an InvalidArgumentException should be thrown */
- $this->expectException(InvalidArgumentException::class);
- $this->expectExceptionMessage('Container ID is too short. Minimum length is <12> characters.');
-
- /** @When the container ID is created with the short value */
- ContainerId::from(value: $value);
- }
-
- public function testContainerIdIsAcceptedWhenExactly12Characters(): void
- {
- /** @Given a value with exactly 12 characters */
- $value = 'abc123abc123';
-
- /** @When the container ID is created */
- $containerId = ContainerId::from(value: $value);
-
- /** @Then the container ID should be the same as the input value */
- $this->assertSame('abc123abc123', $containerId->value);
- }
-
- public function testContainerIdIsTruncatedIfLongerThan12Characters(): void
- {
- /** @Given a value with more than 12 characters */
- $value = 'abc123abc123abc123';
-
- /** @When the container ID is created */
- $containerId = ContainerId::from(value: $value);
-
- /** @Then the container ID should be truncated to 12 characters */
- $this->assertSame('abc123abc123', $containerId->value);
- }
-}
diff --git a/tests/Unit/Internal/Containers/Models/ImageTest.php b/tests/Unit/Internal/Containers/Models/ImageTest.php
deleted file mode 100644
index c9142d5..0000000
--- a/tests/Unit/Internal/Containers/Models/ImageTest.php
+++ /dev/null
@@ -1,25 +0,0 @@
-expectException(InvalidArgumentException::class);
- $this->expectExceptionMessage('Image name cannot be empty.');
-
- /** @When the image name is created with the empty value */
- Image::from(image: $value);
- }
-}
diff --git a/tests/Unit/Mocks/ClientMock.php b/tests/Unit/Mocks/ClientMock.php
new file mode 100644
index 0000000..8d7a0fe
--- /dev/null
+++ b/tests/Unit/Mocks/ClientMock.php
@@ -0,0 +1,124 @@
+ */
+ private array $runResponses = [];
+
+ /** @var array */
+ private array $listResponses = [];
+
+ /** @var array> */
+ private array $inspectResponses = [];
+
+ /** @var array */
+ private array $executeResponses = [];
+
+ /** @var array */
+ private array $stopResponses = [];
+
+ /** @var array */
+ private array $executedCommandLines = [];
+
+ private bool $runIsSuccessful = true;
+
+ public function withDockerRunResponse(string $data, bool $isSuccessful = true): void
+ {
+ $this->runResponses[] = $data;
+ $this->runIsSuccessful = $isSuccessful;
+ }
+
+ public function withDockerListResponse(string $data): void
+ {
+ $this->listResponses[] = $data;
+ }
+
+ public function withDockerInspectResponse(array $data): void
+ {
+ $this->inspectResponses[] = $data;
+ }
+
+ public function withDockerExecuteResponse(string $output, bool $isSuccessful = true): void
+ {
+ $this->executeResponses[] = [$output, $isSuccessful];
+ }
+
+ public function withDockerExecuteException(Throwable $exception): void
+ {
+ $this->executeResponses[] = $exception;
+ }
+
+ public function withDockerStopResponse(string $output, bool $isSuccessful = true): void
+ {
+ $this->stopResponses[] = [$output, $isSuccessful];
+ }
+
+ public function getExecutedCommandLines(): array
+ {
+ return $this->executedCommandLines;
+ }
+
+ public function execute(Command $command): ExecutionCompleted
+ {
+ $this->executedCommandLines[] = $command->toCommandLine();
+
+ [$output, $isSuccessful] = match (true) {
+ $command instanceof DockerRun => [array_shift($this->runResponses) ?? '', $this->runIsSuccessful],
+ $command instanceof DockerList => $this->resolveListResponse(),
+ $command instanceof DockerInspect => $this->resolveInspectResponse(),
+ $command instanceof DockerCopy => ['', true],
+ $command instanceof DockerExecute => $this->resolveExecuteResponse(),
+ $command instanceof DockerStop => $this->resolveStopResponse(),
+ default => ['', false]
+ };
+
+ return new ExecutionCompletedMock(output: (string)$output, successful: $isSuccessful);
+ }
+
+ private function resolveListResponse(): array
+ {
+ $data = array_shift($this->listResponses) ?? '';
+
+ return [$data, !empty($data)];
+ }
+
+ private function resolveInspectResponse(): array
+ {
+ $data = array_shift($this->inspectResponses);
+
+ return [json_encode([$data]), !empty($data)];
+ }
+
+ private function resolveExecuteResponse(): array
+ {
+ $response = array_shift($this->executeResponses);
+
+ if ($response instanceof Throwable) {
+ throw $response;
+ }
+
+ return $response ?? ['', true];
+ }
+
+ private function resolveStopResponse(): array
+ {
+ $response = array_shift($this->stopResponses);
+
+ return $response ?? ['', true];
+ }
+}
diff --git a/tests/Unit/Mocks/CommandMock.php b/tests/Unit/Mocks/CommandMock.php
new file mode 100644
index 0000000..26c6831
--- /dev/null
+++ b/tests/Unit/Mocks/CommandMock.php
@@ -0,0 +1,19 @@
+command;
+ }
+}
diff --git a/tests/Unit/Mocks/CommandWithTimeoutMock.php b/tests/Unit/Mocks/CommandWithTimeoutMock.php
new file mode 100644
index 0000000..eff2bcd
--- /dev/null
+++ b/tests/Unit/Mocks/CommandWithTimeoutMock.php
@@ -0,0 +1,24 @@
+command;
+ }
+
+ public function getTimeoutInWholeSeconds(): int
+ {
+ return $this->timeoutInWholeSeconds;
+ }
+}
diff --git a/tests/Unit/Mocks/ExecutionCompletedMock.php b/tests/Unit/Mocks/ExecutionCompletedMock.php
new file mode 100644
index 0000000..d4dcc6b
--- /dev/null
+++ b/tests/Unit/Mocks/ExecutionCompletedMock.php
@@ -0,0 +1,24 @@
+output;
+ }
+
+ public function isSuccessful(): bool
+ {
+ return $this->successful;
+ }
+}
diff --git a/tests/Unit/Mocks/InspectResponseFixture.php b/tests/Unit/Mocks/InspectResponseFixture.php
new file mode 100644
index 0000000..790e7c9
--- /dev/null
+++ b/tests/Unit/Mocks/InspectResponseFixture.php
@@ -0,0 +1,48 @@
+ $env
+ * @param array $exposedPorts
+ */
+ public static function build(
+ string $id = '6acae5967be05d8441b4109eea3e4dec5e775068a2a99d95808afb21b2e0a2c8',
+ string $hostname = 'alpine',
+ string $ipAddress = '172.22.0.2',
+ string $networkName = 'bridge',
+ array $env = [],
+ array $exposedPorts = []
+ ): array {
+ return [
+ 'Id' => $id,
+ 'Name' => sprintf('/%s', $hostname),
+ 'Config' => [
+ 'Hostname' => $hostname,
+ 'ExposedPorts' => $exposedPorts,
+ 'Env' => $env
+ ],
+ 'NetworkSettings' => [
+ 'Networks' => [
+ $networkName => [
+ 'IPAddress' => $ipAddress
+ ]
+ ]
+ ]
+ ];
+ }
+
+ public static function containerId(): string
+ {
+ return '6acae5967be05d8441b4109eea3e4dec5e775068a2a99d95808afb21b2e0a2c8';
+ }
+
+ public static function shortContainerId(): string
+ {
+ return '6acae5967be0';
+ }
+}
diff --git a/tests/Unit/Mocks/TestableGenericDockerContainer.php b/tests/Unit/Mocks/TestableGenericDockerContainer.php
new file mode 100644
index 0000000..740ecd0
--- /dev/null
+++ b/tests/Unit/Mocks/TestableGenericDockerContainer.php
@@ -0,0 +1,21 @@
+client = new ClientMock();
+ }
+
+ public function testRunMySQLContainerSuccessfully(): void
+ {
+ /** @Given a MySQL container with full configuration */
+ $container = TestableMySQLDockerContainer::createWith(
+ image: 'mysql:8.1',
+ name: 'test-db',
+ client: $this->client
+ )
+ ->withNetwork(name: 'my-net')
+ ->withTimezone(timezone: 'America/Sao_Paulo')
+ ->withUsername(user: 'app_user')
+ ->withPassword(password: 'secret')
+ ->withDatabase(database: 'test_adm')
+ ->withPortMapping(portOnHost: 3306, portOnContainer: 3306)
+ ->withRootPassword(rootPassword: 'root')
+ ->withGrantedHosts(hosts: ['%', '172.%'])
+ ->withoutAutoRemove()
+ ->withVolumeMapping(pathOnHost: '/var/lib/mysql', pathOnContainer: '/var/lib/mysql');
+
+ /** @And the Docker daemon returns valid responses */
+ $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId());
+ $this->client->withDockerInspectResponse(
+ data: InspectResponseFixture::build(
+ hostname: 'test-db',
+ networkName: 'my-net',
+ env: [
+ 'TZ=America/Sao_Paulo',
+ 'MYSQL_USER=app_user',
+ 'MYSQL_PASSWORD=secret',
+ 'MYSQL_DATABASE=test_adm',
+ 'MYSQL_ROOT_PASSWORD=root'
+ ],
+ exposedPorts: ['3306/tcp' => (object)[]]
+ )
+ );
+
+ /** @And the MySQL readiness check succeeds */
+ $this->client->withDockerExecuteResponse(output: 'mysqld is alive');
+
+ /** @And the CREATE DATABASE command succeeds */
+ $this->client->withDockerExecuteResponse(output: '');
+
+ /** @And the GRANT PRIVILEGES commands succeed (one per host) */
+ $this->client->withDockerExecuteResponse(output: '');
+ $this->client->withDockerExecuteResponse(output: '');
+
+ /** @When the MySQL container is started */
+ $started = $container->run();
+
+ /** @Then it should return a MySQLContainerStarted instance */
+ self::assertInstanceOf(MySQLContainerStarted::class, $started);
+ self::assertSame('test-db', $started->getName());
+ self::assertSame(InspectResponseFixture::shortContainerId(), $started->getId());
+
+ /** @And the environment variables should be accessible */
+ self::assertSame('test_adm', $started->getEnvironmentVariables()->getValueBy(key: 'MYSQL_DATABASE'));
+ self::assertSame('app_user', $started->getEnvironmentVariables()->getValueBy(key: 'MYSQL_USER'));
+ self::assertSame('secret', $started->getEnvironmentVariables()->getValueBy(key: 'MYSQL_PASSWORD'));
+ self::assertSame('root', $started->getEnvironmentVariables()->getValueBy(key: 'MYSQL_ROOT_PASSWORD'));
+
+ /** @And the port should be exposed */
+ self::assertSame(3306, $started->getAddress()->getPorts()->firstExposedPort());
+ }
+
+ public function testRunIfNotExistsReturnsMySQLContainerStarted(): void
+ {
+ /** @Given a MySQL container */
+ $container = TestableMySQLDockerContainer::createWith(
+ image: 'mysql:8.1',
+ name: 'existing-db',
+ client: $this->client
+ )
+ ->withDatabase(database: 'my_db')
+ ->withRootPassword(rootPassword: 'root');
+
+ /** @And the container already exists */
+ $this->client->withDockerListResponse(data: InspectResponseFixture::containerId());
+ $this->client->withDockerInspectResponse(
+ data: InspectResponseFixture::build(
+ hostname: 'existing-db',
+ env: ['MYSQL_DATABASE=my_db', 'MYSQL_ROOT_PASSWORD=root'],
+ exposedPorts: ['3306/tcp' => (object)[]]
+ )
+ );
+
+ /** @When runIfNotExists is called */
+ $started = $container->runIfNotExists();
+
+ /** @Then it should return a MySQLContainerStarted wrapping the existing container */
+ self::assertInstanceOf(MySQLContainerStarted::class, $started);
+ self::assertSame('existing-db', $started->getName());
+ }
+
+ public function testRunIfNotExistsCreatesNewMySQLContainer(): void
+ {
+ /** @Given a MySQL container that does not exist */
+ $container = TestableMySQLDockerContainer::createWith(
+ image: 'mysql:8.1',
+ name: 'new-db',
+ client: $this->client
+ )
+ ->withDatabase(database: 'new_db')
+ ->withRootPassword(rootPassword: 'root');
+
+ /** @And the Docker list returns empty (container does not exist) */
+ $this->client->withDockerListResponse(data: '');
+
+ /** @And the Docker daemon returns valid run and inspect responses */
+ $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId());
+ $this->client->withDockerInspectResponse(
+ data: InspectResponseFixture::build(
+ hostname: 'new-db',
+ env: ['MYSQL_DATABASE=new_db', 'MYSQL_ROOT_PASSWORD=root']
+ )
+ );
+
+ /** @And the MySQL readiness check and CREATE DATABASE succeed */
+ $this->client->withDockerExecuteResponse(output: 'mysqld is alive');
+ $this->client->withDockerExecuteResponse(output: '');
+
+ /** @When runIfNotExists is called */
+ $started = $container->runIfNotExists();
+
+ /** @Then a new container should be created */
+ self::assertInstanceOf(MySQLContainerStarted::class, $started);
+ self::assertSame('new-db', $started->getName());
+ }
+
+ public function testRunMySQLContainerRetriesReadinessCheckBeforeSucceeding(): void
+ {
+ /** @Given a MySQL container */
+ $container = TestableMySQLDockerContainer::createWith(
+ image: 'mysql:8.1',
+ name: 'retry-db',
+ client: $this->client
+ )
+ ->withDatabase(database: 'test_db')
+ ->withRootPassword(rootPassword: 'root')
+ ->withReadinessTimeout(timeoutInSeconds: 10);
+
+ /** @And the Docker daemon starts the container */
+ $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId());
+ $this->client->withDockerInspectResponse(
+ data: InspectResponseFixture::build(
+ hostname: 'retry-db',
+ env: ['MYSQL_DATABASE=test_db', 'MYSQL_ROOT_PASSWORD=root']
+ )
+ );
+
+ /** @And the MySQL readiness check fails twice before succeeding */
+ $this->client->withDockerExecuteResponse(output: 'not ready', isSuccessful: false);
+ $this->client->withDockerExecuteResponse(output: 'not ready', isSuccessful: false);
+ $this->client->withDockerExecuteResponse(output: 'mysqld is alive');
+
+ /** @And the CREATE DATABASE command succeeds */
+ $this->client->withDockerExecuteResponse(output: '');
+
+ /** @When the MySQL container is started */
+ $started = $container->run();
+
+ /** @Then the container should start after retries */
+ self::assertSame('retry-db', $started->getName());
+ }
+
+ public function testRunMySQLContainerRetriesWhenReadinessCheckThrowsException(): void
+ {
+ /** @Given a MySQL container */
+ $container = TestableMySQLDockerContainer::createWith(
+ image: 'mysql:8.1',
+ name: 'exception-db',
+ client: $this->client
+ )
+ ->withDatabase(database: 'test_db')
+ ->withRootPassword(rootPassword: 'root')
+ ->withReadinessTimeout(timeoutInSeconds: 10);
+
+ /** @And the Docker daemon starts the container */
+ $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId());
+ $this->client->withDockerInspectResponse(
+ data: InspectResponseFixture::build(
+ hostname: 'exception-db',
+ env: ['MYSQL_DATABASE=test_db', 'MYSQL_ROOT_PASSWORD=root']
+ )
+ );
+
+ /** @And the MySQL readiness check throws an exception first, then succeeds */
+ $this->client->withDockerExecuteException(
+ exception: new DockerCommandExecutionFailed(reason: 'container not running', command: 'docker exec')
+ );
+ $this->client->withDockerExecuteResponse(output: 'mysqld is alive');
+
+ /** @And the CREATE DATABASE command succeeds */
+ $this->client->withDockerExecuteResponse(output: '');
+
+ /** @When the MySQL container is started */
+ $started = $container->run();
+
+ /** @Then the container should start after the exception was caught and retried */
+ self::assertSame('exception-db', $started->getName());
+ }
+
+ public function testRunMySQLContainerWithSingleGrantedHost(): void
+ {
+ /** @Given a MySQL container with a single granted host */
+ $container = TestableMySQLDockerContainer::createWith(
+ image: 'mysql:8.1',
+ name: 'single-grant',
+ client: $this->client
+ )
+ ->withDatabase(database: 'test_db')
+ ->withRootPassword(rootPassword: 'root')
+ ->withGrantedHosts(hosts: ['%']);
+
+ /** @And the Docker daemon returns valid responses */
+ $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId());
+ $this->client->withDockerInspectResponse(
+ data: InspectResponseFixture::build(
+ hostname: 'single-grant',
+ env: ['MYSQL_DATABASE=test_db', 'MYSQL_ROOT_PASSWORD=root']
+ )
+ );
+
+ /** @And readiness, CREATE DATABASE, and one GRANT PRIVILEGES succeed */
+ $this->client->withDockerExecuteResponse(output: 'mysqld is alive');
+ $this->client->withDockerExecuteResponse(output: '');
+ $this->client->withDockerExecuteResponse(output: '');
+
+ /** @When the container is started */
+ $started = $container->run();
+
+ /** @Then the container should start successfully */
+ self::assertSame('single-grant', $started->getName());
+ }
+
+ public function testRunMySQLContainerWithCopyToContainer(): void
+ {
+ /** @Given a MySQL container with files to copy */
+ $container = TestableMySQLDockerContainer::createWith(
+ image: 'mysql:8.1',
+ name: 'copy-db',
+ client: $this->client
+ )
+ ->withRootPassword(rootPassword: 'root')
+ ->copyToContainer(pathOnHost: '/host/init', pathOnContainer: '/docker-entrypoint-initdb.d');
+
+ /** @And the Docker daemon returns valid responses */
+ $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId());
+ $this->client->withDockerInspectResponse(
+ data: InspectResponseFixture::build(
+ hostname: 'copy-db',
+ env: ['MYSQL_ROOT_PASSWORD=root']
+ )
+ );
+
+ /** @And the readiness check succeeds */
+ $this->client->withDockerExecuteResponse(output: 'mysqld is alive');
+
+ /** @When the container is started */
+ $started = $container->run();
+
+ /** @Then the container should be running with copy instructions executed */
+ self::assertSame('copy-db', $started->getName());
+ }
+
+ public function testRunMySQLContainerWithWaitBeforeRun(): void
+ {
+ /** @Given a MySQL container with a wait-before-run condition */
+ $condition = $this->createMock(ContainerReady::class);
+ $condition->expects(self::once())->method('isReady')->willReturn(true);
+
+ $container = TestableMySQLDockerContainer::createWith(
+ image: 'mysql:8.1',
+ name: 'wait-db',
+ client: $this->client
+ )
+ ->withRootPassword(rootPassword: 'root')
+ ->withWaitBeforeRun(
+ wait: ContainerWaitForDependency::untilReady(condition: $condition)
+ );
+
+ /** @And the Docker daemon returns valid responses */
+ $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId());
+ $this->client->withDockerInspectResponse(
+ data: InspectResponseFixture::build(
+ hostname: 'wait-db',
+ env: ['MYSQL_ROOT_PASSWORD=root']
+ )
+ );
+
+ /** @And the MySQL readiness check succeeds */
+ $this->client->withDockerExecuteResponse(output: 'mysqld is alive');
+
+ /** @When the container is started */
+ $started = $container->run();
+
+ /** @Then the wait-before-run condition should have been evaluated */
+ self::assertSame('wait-db', $started->getName());
+ }
+
+ public function testGetJdbcUrlWithDefaultOptions(): void
+ {
+ /** @Given a running MySQL container */
+ $started = $this->createRunningMySQLContainer(
+ hostname: 'test-db',
+ database: 'test_adm',
+ port: 3306
+ );
+
+ /** @When getting the JDBC URL with default options */
+ $actual = $started->getJdbcUrl();
+
+ /** @Then the URL should include default JDBC options */
+ self::assertSame(
+ 'jdbc:mysql://test-db:3306/test_adm?useSSL=false&useUnicode=yes&characterEncoding=UTF-8&allowPublicKeyRetrieval=true',
+ $actual
+ );
+ }
+
+ public function testGetJdbcUrlWithCustomOptions(): void
+ {
+ /** @Given a running MySQL container */
+ $started = $this->createRunningMySQLContainer(
+ hostname: 'test-db',
+ database: 'test_adm',
+ port: 3306
+ );
+
+ /** @When getting the JDBC URL with custom options */
+ $actual = $started->getJdbcUrl(options: ['connectTimeout' => '5000', 'useSSL' => 'true']);
+
+ /** @Then the URL should include the custom options */
+ self::assertSame('jdbc:mysql://test-db:3306/test_adm?connectTimeout=5000&useSSL=true', $actual);
+ }
+
+ public function testGetJdbcUrlWithoutOptions(): void
+ {
+ /** @Given a running MySQL container */
+ $started = $this->createRunningMySQLContainer(
+ hostname: 'test-db',
+ database: 'test_adm',
+ port: 3306
+ );
+
+ /** @When getting the JDBC URL with empty options */
+ $actual = $started->getJdbcUrl(options: []);
+
+ /** @Then the URL should not include any query string */
+ self::assertSame('jdbc:mysql://test-db:3306/test_adm', $actual);
+ }
+
+ public function testGetJdbcUrlDefaultsToPort3306WhenNoPortExposed(): void
+ {
+ /** @Given a running MySQL container with no exposed ports */
+ $started = $this->createRunningMySQLContainer(
+ hostname: 'test-db',
+ database: 'test_adm',
+ port: null
+ );
+
+ /** @When getting the JDBC URL */
+ $actual = $started->getJdbcUrl(options: []);
+
+ /** @Then the URL should use the default MySQL port 3306 */
+ self::assertSame('jdbc:mysql://test-db:3306/test_adm', $actual);
+ }
+
+ public function testRunMySQLContainerWithoutDatabase(): void
+ {
+ /** @Given a MySQL container without a database configured */
+ $container = TestableMySQLDockerContainer::createWith(
+ image: 'mysql:8.1',
+ name: 'no-db',
+ client: $this->client
+ )->withRootPassword(rootPassword: 'root');
+
+ /** @And the Docker daemon returns valid responses with no MYSQL_DATABASE */
+ $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId());
+ $this->client->withDockerInspectResponse(
+ data: InspectResponseFixture::build(
+ hostname: 'no-db',
+ env: ['MYSQL_ROOT_PASSWORD=root']
+ )
+ );
+
+ /** @And the MySQL readiness check succeeds */
+ $this->client->withDockerExecuteResponse(output: 'mysqld is alive');
+
+ /** @When the MySQL container is started (no CREATE DATABASE should be called) */
+ $started = $container->run();
+
+ /** @Then the container should start without errors */
+ self::assertSame('no-db', $started->getName());
+ }
+
+ public function testRunMySQLContainerWithoutGrantedHosts(): void
+ {
+ /** @Given a MySQL container without granted hosts */
+ $container = TestableMySQLDockerContainer::createWith(
+ image: 'mysql:8.1',
+ name: 'no-grants',
+ client: $this->client
+ )
+ ->withDatabase(database: 'test_db')
+ ->withRootPassword(rootPassword: 'root');
+
+ /** @And the Docker daemon returns valid responses */
+ $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId());
+ $this->client->withDockerInspectResponse(
+ data: InspectResponseFixture::build(
+ hostname: 'no-grants',
+ env: ['MYSQL_DATABASE=test_db', 'MYSQL_ROOT_PASSWORD=root']
+ )
+ );
+
+ /** @And the MySQL readiness and CREATE DATABASE calls succeed */
+ $this->client->withDockerExecuteResponse(output: 'mysqld is alive');
+ $this->client->withDockerExecuteResponse(output: '');
+
+ /** @When the MySQL container is started (no GRANT PRIVILEGES should be called) */
+ $started = $container->run();
+
+ /** @Then the container should start without errors */
+ self::assertSame('no-grants', $started->getName());
+ }
+
+ public function testMySQLContainerDelegatesStopCorrectly(): void
+ {
+ /** @Given a running MySQL container */
+ $started = $this->createRunningMySQLContainer(
+ hostname: 'stop-db',
+ database: 'test_adm',
+ port: 3306
+ );
+
+ /** @And the Docker stop command succeeds */
+ $this->client->withDockerStopResponse(output: '');
+
+ /** @When the container is stopped */
+ $result = $started->stop();
+
+ /** @Then the stop should be successful */
+ self::assertTrue($result->isSuccessful());
+ }
+
+ public function testMySQLContainerDelegatesStopWithCustomTimeout(): void
+ {
+ /** @Given a running MySQL container */
+ $started = $this->createRunningMySQLContainer(
+ hostname: 'stop-timeout-db',
+ database: 'test_adm',
+ port: 3306
+ );
+
+ /** @And the Docker stop command succeeds */
+ $this->client->withDockerStopResponse(output: '');
+
+ /** @When the container is stopped with a custom timeout */
+ $result = $started->stop(timeoutInWholeSeconds: 10);
+
+ /** @Then the stop should be successful */
+ self::assertTrue($result->isSuccessful());
+ }
+
+ public function testMySQLContainerDelegatesExecuteAfterStarted(): void
+ {
+ /** @Given a running MySQL container */
+ $started = $this->createRunningMySQLContainer(
+ hostname: 'exec-db',
+ database: 'test_adm',
+ port: 3306
+ );
+
+ /** @And a command execution returns output */
+ $this->client->withDockerExecuteResponse(output: 'SHOW DATABASES output');
+
+ /** @When commands are executed inside the container */
+ $result = $started->executeAfterStarted(commands: ['mysql', '-e', 'SHOW DATABASES']);
+
+ /** @Then the execution should return the output */
+ self::assertTrue($result->isSuccessful());
+ self::assertSame('SHOW DATABASES output', $result->getOutput());
+ }
+
+ public function testMySQLContainerDelegatesGetAddress(): void
+ {
+ /** @Given a running MySQL container */
+ $started = $this->createRunningMySQLContainer(
+ hostname: 'address-db',
+ database: 'test_adm',
+ port: 3306
+ );
+
+ /** @When getting the container address */
+ $address = $started->getAddress();
+
+ /** @Then the address should delegate correctly */
+ self::assertSame('address-db', $address->getHostname());
+ self::assertSame('172.22.0.2', $address->getIp());
+ self::assertSame(3306, $address->getPorts()->firstExposedPort());
+ self::assertSame([3306], $address->getPorts()->exposedPorts());
+ }
+
+ public function testExceptionWhenMySQLNeverBecomesReady(): void
+ {
+ /** @Given a MySQL container with a very short readiness timeout */
+ $container = TestableMySQLDockerContainer::createWith(
+ image: 'mysql:8.1',
+ name: 'stuck-db',
+ client: $this->client
+ )
+ ->withDatabase(database: 'test_db')
+ ->withRootPassword(rootPassword: 'root')
+ ->withReadinessTimeout(timeoutInSeconds: 1);
+
+ /** @And the Docker daemon starts the container */
+ $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId());
+ $this->client->withDockerInspectResponse(
+ data: InspectResponseFixture::build(
+ hostname: 'stuck-db',
+ env: ['MYSQL_DATABASE=test_db', 'MYSQL_ROOT_PASSWORD=root']
+ )
+ );
+
+ /** @And the MySQL readiness check always fails */
+ for ($i = 0; $i < 100; $i++) {
+ $this->client->withDockerExecuteResponse(output: 'mysqld is not ready', isSuccessful: false);
+ }
+
+ /** @Then a ContainerWaitTimeout exception should be thrown */
+ $this->expectException(ContainerWaitTimeout::class);
+ $this->expectExceptionMessage('Container readiness check timed out after <1> seconds.');
+
+ /** @When attempting to start the MySQL container */
+ $container->run();
+ }
+
+ public function testExceptionWhenMySQLReadinessCheckAlwaysThrowsExceptions(): void
+ {
+ /** @Given a MySQL container with a very short readiness timeout */
+ $container = TestableMySQLDockerContainer::createWith(
+ image: 'mysql:8.1',
+ name: 'crash-db',
+ client: $this->client
+ )
+ ->withRootPassword(rootPassword: 'root')
+ ->withReadinessTimeout(timeoutInSeconds: 1);
+
+ /** @And the Docker daemon starts the container */
+ $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId());
+ $this->client->withDockerInspectResponse(
+ data: InspectResponseFixture::build(
+ hostname: 'crash-db',
+ env: ['MYSQL_ROOT_PASSWORD=root']
+ )
+ );
+
+ /** @And the MySQL readiness check always throws exceptions */
+ for ($i = 0; $i < 100; $i++) {
+ $this->client->withDockerExecuteException(
+ exception: new DockerCommandExecutionFailed(reason: 'container crashed', command: 'docker exec')
+ );
+ }
+
+ /** @Then a ContainerWaitTimeout exception should be thrown (not DockerCommandExecutionFailed) */
+ $this->expectException(ContainerWaitTimeout::class);
+
+ /** @When attempting to start the MySQL container */
+ $container->run();
+ }
+
+ public function testCustomReadinessTimeoutIsUsed(): void
+ {
+ /** @Given a MySQL container with a custom readiness timeout */
+ $container = TestableMySQLDockerContainer::createWith(
+ image: 'mysql:8.1',
+ name: 'timeout-db',
+ client: $this->client
+ )
+ ->withDatabase(database: 'test_db')
+ ->withRootPassword(rootPassword: 'root')
+ ->withReadinessTimeout(timeoutInSeconds: 60);
+
+ /** @And the Docker daemon starts the container */
+ $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId());
+ $this->client->withDockerInspectResponse(
+ data: InspectResponseFixture::build(
+ hostname: 'timeout-db',
+ env: ['MYSQL_DATABASE=test_db', 'MYSQL_ROOT_PASSWORD=root']
+ )
+ );
+
+ /** @And the MySQL readiness check succeeds on first attempt */
+ $this->client->withDockerExecuteResponse(output: 'mysqld is alive');
+ $this->client->withDockerExecuteResponse(output: '');
+
+ /** @When the MySQL container is started */
+ $started = $container->run();
+
+ /** @Then the container should start successfully */
+ self::assertSame('timeout-db', $started->getName());
+ }
+
+ private function createRunningMySQLContainer(
+ string $hostname,
+ string $database,
+ ?int $port
+ ): MySQLContainerStarted {
+ $container = TestableMySQLDockerContainer::createWith(
+ image: 'mysql:8.1',
+ name: $hostname,
+ client: $this->client
+ )
+ ->withDatabase(database: $database)
+ ->withRootPassword(rootPassword: 'root');
+
+ $exposedPorts = $port !== null ? [sprintf('%d/tcp', $port) => (object)[]] : [];
+
+ $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId());
+ $this->client->withDockerInspectResponse(
+ data: InspectResponseFixture::build(
+ hostname: $hostname,
+ env: [
+ sprintf('MYSQL_DATABASE=%s', $database),
+ 'MYSQL_ROOT_PASSWORD=root'
+ ],
+ exposedPorts: $exposedPorts
+ )
+ );
+
+ $this->client->withDockerExecuteResponse(output: 'mysqld is alive');
+ $this->client->withDockerExecuteResponse(output: '');
+
+ return $container->run();
+ }
+
+ public function testMySQLContainerWithEnvironmentVariableDirectly(): void
+ {
+ /** @Given a MySQL container with a custom environment variable */
+ $container = TestableMySQLDockerContainer::createWith(
+ image: 'mysql:8.1',
+ name: 'env-db',
+ client: $this->client
+ )
+ ->withRootPassword(rootPassword: 'root')
+ ->withEnvironmentVariable(key: 'CUSTOM_KEY', value: 'custom_value');
+
+ /** @And the Docker daemon returns valid responses including the custom env var */
+ $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId());
+ $this->client->withDockerInspectResponse(
+ data: InspectResponseFixture::build(
+ hostname: 'env-db',
+ env: ['MYSQL_ROOT_PASSWORD=root', 'CUSTOM_KEY=custom_value']
+ )
+ );
+
+ /** @And the MySQL readiness check succeeds */
+ $this->client->withDockerExecuteResponse(output: 'mysqld is alive');
+
+ /** @When the MySQL container is started */
+ $started = $container->run();
+
+ /** @Then the custom environment variable should be accessible */
+ self::assertSame('custom_value', $started->getEnvironmentVariables()->getValueBy(key: 'CUSTOM_KEY'));
+ }
+}
diff --git a/tests/Unit/Waits/ContainerWaitForDependencyTest.php b/tests/Unit/Waits/ContainerWaitForDependencyTest.php
index 851bf35..cc04ebf 100644
--- a/tests/Unit/Waits/ContainerWaitForDependencyTest.php
+++ b/tests/Unit/Waits/ContainerWaitForDependencyTest.php
@@ -2,46 +2,90 @@
declare(strict_types=1);
-namespace TinyBlocks\DockerContainer\Waits;
+namespace Test\Unit\Waits;
use PHPUnit\Framework\TestCase;
+use TinyBlocks\DockerContainer\Internal\Exceptions\ContainerWaitTimeout;
use TinyBlocks\DockerContainer\Waits\Conditions\ContainerReady;
+use TinyBlocks\DockerContainer\Waits\ContainerWaitForDependency;
final class ContainerWaitForDependencyTest extends TestCase
{
- public function testWaitBefore(): void
+ public function testWaitBeforeWhenConditionIsImmediatelyReady(): void
{
- /** @Given I have a condition */
+ /** @Given a condition that is immediately ready */
$condition = $this->createMock(ContainerReady::class);
+ $condition->expects(self::once())->method('isReady')->willReturn(true);
- /** @And the condition does not initially indicate the dependency is ready */
- $condition->expects(self::exactly(2))
- ->method('isReady')
- ->willReturnOnConsecutiveCalls(false, true);
-
- /** @When I wait until the condition is satisfied */
+ /** @When waiting for the dependency */
$wait = ContainerWaitForDependency::untilReady(condition: $condition);
$wait->waitBefore();
- /** @Then the condition should eventually return true, indicating the dependency is ready */
+ /** @Then the condition should have been checked exactly once */
self::assertTrue(true);
}
- public function testWaitBeforeWhenConditionIsReady(): void
+ public function testWaitBeforeRetriesUntilReady(): void
{
- /** @Given I have a condition */
+ /** @Given a condition that becomes ready after two retries */
$condition = $this->createMock(ContainerReady::class);
-
- /** @And the condition initially indicates the dependency is ready */
- $condition->expects(self::once())
+ $condition->expects(self::exactly(3))
->method('isReady')
- ->willReturn(true);
+ ->willReturnOnConsecutiveCalls(false, false, true);
- /** @When I wait until the condition is satisfied */
- $wait = ContainerWaitForDependency::untilReady(condition: $condition);
+ /** @When waiting for the dependency with a generous timeout */
+ $wait = ContainerWaitForDependency::untilReady(
+ condition: $condition,
+ timeoutInSeconds: 10,
+ pollIntervalInMicroseconds: 1_000
+ );
$wait->waitBefore();
- /** @Then the condition should return true immediately, indicating the dependency is ready */
+ /** @Then the condition should have been checked three times */
self::assertTrue(true);
}
+
+ public function testExceptionWhenWaitTimesOut(): void
+ {
+ /** @Given a condition that never becomes ready */
+ $condition = $this->createMock(ContainerReady::class);
+ $condition->method('isReady')->willReturn(false);
+
+ /** @Then a ContainerWaitTimeout exception should be thrown */
+ $this->expectException(ContainerWaitTimeout::class);
+ $this->expectExceptionMessage('Container readiness check timed out after <1> seconds.');
+
+ /** @When waiting with a short timeout */
+ $wait = ContainerWaitForDependency::untilReady(
+ condition: $condition,
+ timeoutInSeconds: 1,
+ pollIntervalInMicroseconds: 50_000
+ );
+ $wait->waitBefore();
+ }
+
+ public function testCustomPollIntervalIsRespected(): void
+ {
+ /** @Given a condition that becomes ready after some retries */
+ $callCount = 0;
+ $condition = $this->createMock(ContainerReady::class);
+ $condition->method('isReady')->willReturnCallback(function () use (&$callCount): bool {
+ $callCount++;
+ return $callCount >= 3;
+ });
+
+ /** @When waiting with a very fast poll interval */
+ $start = microtime(as_float: true);
+ $wait = ContainerWaitForDependency::untilReady(
+ condition: $condition,
+ timeoutInSeconds: 5,
+ pollIntervalInMicroseconds: 10_000
+ );
+ $wait->waitBefore();
+ $elapsed = microtime(as_float: true) - $start;
+
+ /** @Then the wait should complete quickly (well under 1 second) */
+ self::assertLessThan(1.0, $elapsed);
+ self::assertSame(3, $callCount);
+ }
}
diff --git a/tests/Unit/Waits/ContainerWaitForTimeTest.php b/tests/Unit/Waits/ContainerWaitForTimeTest.php
new file mode 100644
index 0000000..52d9d4f
--- /dev/null
+++ b/tests/Unit/Waits/ContainerWaitForTimeTest.php
@@ -0,0 +1,57 @@
+waitBefore();
+ $elapsed = microtime(as_float: true) - $start;
+
+ /** @Then at least 0.9 seconds should have elapsed */
+ self::assertGreaterThanOrEqual(0.9, $elapsed);
+ }
+
+ public function testWaitAfterPausesForSpecifiedDuration(): void
+ {
+ /** @Given a wait-for-time of 1 second */
+ $wait = ContainerWaitForTime::forSeconds(seconds: 1);
+
+ /** @And a mock container started */
+ $containerStarted = $this->createMock(ContainerStarted::class);
+
+ /** @When waiting after */
+ $start = microtime(as_float: true);
+ $wait->waitAfter(containerStarted: $containerStarted);
+ $elapsed = microtime(as_float: true) - $start;
+
+ /** @Then at least 0.9 seconds should have elapsed */
+ self::assertGreaterThanOrEqual(0.9, $elapsed);
+ }
+
+ public function testWaitForZeroSecondsReturnsImmediately(): void
+ {
+ /** @Given a wait-for-time of 0 seconds */
+ $wait = ContainerWaitForTime::forSeconds(seconds: 0);
+
+ /** @When waiting before */
+ $start = microtime(as_float: true);
+ $wait->waitBefore();
+ $elapsed = microtime(as_float: true) - $start;
+
+ /** @Then the wait should complete almost instantly */
+ self::assertLessThan(0.1, $elapsed);
+ }
+}