From 9f15b16dfe6803bfbaea85e0e3ff7b142c5791a6 Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Tue, 31 Mar 2026 08:30:19 -0300 Subject: [PATCH] feat: Introduce new Docker command classes and improve existing command handling. --- Makefile | 2 +- README.md | 51 +- composer.json | 3 +- src/Contracts/Address.php | 19 +- src/Contracts/ContainerStarted.php | 26 +- src/Contracts/EnvironmentVariables.php | 8 +- src/Contracts/ExecutionCompleted.php | 10 +- src/Contracts/MySQL/MySQLContainerStarted.php | 16 +- src/Contracts/Ports.php | 11 +- src/DockerContainer.php | 111 +-- src/GenericDockerContainer.php | 97 +- src/Internal/Client/Client.php | 8 +- src/Internal/Client/DockerClient.php | 2 +- src/Internal/CommandHandler.php | 45 - .../CommandHandler/CommandHandler.php | 45 + .../ContainerCommandHandler.php | 96 ++ src/Internal/Commands/Command.php | 8 +- src/Internal/Commands/CommandWithTimeout.php | 6 +- src/Internal/Commands/DockerCopy.php | 16 +- src/Internal/Commands/DockerExecute.php | 19 +- src/Internal/Commands/DockerInspect.php | 4 +- src/Internal/Commands/DockerList.php | 28 +- src/Internal/Commands/DockerRun.php | 78 +- src/Internal/Commands/DockerStop.php | 4 +- src/Internal/Commands/LineBuilder.php | 13 - .../Commands/Options/CommandOption.php | 18 - .../Commands/Options/CommandOptions.php | 31 - .../Options/EnvironmentVariableOption.php | 26 - .../Commands/Options/GenericCommandOption.php | 29 - .../Commands/Options/ItemToCopyOption.php | 30 - .../Commands/Options/NetworkOption.php | 26 - src/Internal/Commands/Options/PortOption.php | 26 - .../Commands/Options/SimpleCommandOption.php | 23 - .../Commands/Options/VolumeOption.php | 26 - src/Internal/ContainerCommandHandler.php | 59 -- .../{Models => }/Address/Address.php | 13 +- src/Internal/Containers/Address/Hostname.php | 19 + src/Internal/Containers/Address/IP.php | 19 + src/Internal/Containers/Address/Ports.php | 33 + .../Definitions/ContainerDefinition.php | 130 +++ .../Definitions/CopyInstruction.php | 24 + .../Definitions/EnvironmentVariable.php | 22 + .../Containers/Definitions/PortMapping.php | 22 + .../Containers/Definitions/VolumeMapping.php | 22 + .../Containers/Drivers/MySQL/MySQLStarted.php | 48 +- .../Environment/EnvironmentVariables.php | 25 + .../Containers/Factories/AddressFactory.php | 27 - .../Containers/Factories/ContainerFactory.php | 46 - .../Factories/EnvironmentVariablesFactory.php | 26 - .../Factories/InspectResultParser.php | 50 + .../Containers/Models/Address/Hostname.php | 26 - src/Internal/Containers/Models/Address/IP.php | 26 - .../Containers/Models/Address/Ports.php | 40 - src/Internal/Containers/Models/Container.php | 56 -- .../Containers/Models/ContainerId.php | 14 +- .../Environment/EnvironmentVariables.php | 16 - src/Internal/Containers/Started.php | 30 +- .../Exceptions/ContainerWaitTimeout.php | 17 + src/MySQLContainer.php | 45 +- src/MySQLDockerContainer.php | 110 ++- src/Waits/Conditions/ContainerReady.php | 9 +- src/Waits/Conditions/MySQL/MySQLReady.php | 17 +- src/Waits/ContainerWait.php | 16 +- src/Waits/ContainerWaitAfterStarted.php | 9 +- src/Waits/ContainerWaitBeforeStarted.php | 7 +- src/Waits/ContainerWaitForDependency.php | 29 +- src/Waits/ContainerWaitForTime.php | 6 +- .../Migrations/V0000__Create_xpto_table.sql | 6 +- tests/Integration/DockerContainerTest.php | 9 +- tests/Unit/ClientMock.php | 71 -- tests/Unit/CommandHandlerMock.php | 30 - tests/Unit/CommandMock.php | 22 - tests/Unit/CommandWithTimeoutMock.php | 27 - tests/Unit/GenericDockerContainerTest.php | 927 ++++++++++++++++++ .../Unit/Internal/Client/DockerClientTest.php | 54 +- tests/Unit/Internal/CommandHandlerTest.php | 195 ---- .../Unit/Internal/Commands/DockerCopyTest.php | 33 - .../Internal/Commands/DockerExecuteTest.php | 26 - .../Internal/Commands/DockerInspectTest.php | 23 - .../Unit/Internal/Commands/DockerListTest.php | 23 - .../Unit/Internal/Commands/DockerRunTest.php | 48 - .../Unit/Internal/Commands/DockerStopTest.php | 24 - .../Drivers/MySQL/MySQLStartedTest.php | 91 -- .../Containers/Models/ContainerIdTest.php | 62 -- .../Internal/Containers/Models/ImageTest.php | 25 - tests/Unit/Mocks/ClientMock.php | 124 +++ tests/Unit/Mocks/CommandMock.php | 19 + tests/Unit/Mocks/CommandWithTimeoutMock.php | 24 + tests/Unit/Mocks/ExecutionCompletedMock.php | 24 + tests/Unit/Mocks/InspectResponseFixture.php | 48 + .../Mocks/TestableGenericDockerContainer.php | 21 + .../Mocks/TestableMySQLDockerContainer.php | 18 + tests/Unit/MySQLDockerContainerTest.php | 690 +++++++++++++ .../Waits/ContainerWaitForDependencyTest.php | 82 +- tests/Unit/Waits/ContainerWaitForTimeTest.php | 57 ++ 95 files changed, 3055 insertions(+), 1767 deletions(-) delete mode 100644 src/Internal/CommandHandler.php create mode 100644 src/Internal/CommandHandler/CommandHandler.php create mode 100644 src/Internal/CommandHandler/ContainerCommandHandler.php delete mode 100644 src/Internal/Commands/LineBuilder.php delete mode 100644 src/Internal/Commands/Options/CommandOption.php delete mode 100644 src/Internal/Commands/Options/CommandOptions.php delete mode 100644 src/Internal/Commands/Options/EnvironmentVariableOption.php delete mode 100644 src/Internal/Commands/Options/GenericCommandOption.php delete mode 100644 src/Internal/Commands/Options/ItemToCopyOption.php delete mode 100644 src/Internal/Commands/Options/NetworkOption.php delete mode 100644 src/Internal/Commands/Options/PortOption.php delete mode 100644 src/Internal/Commands/Options/SimpleCommandOption.php delete mode 100644 src/Internal/Commands/Options/VolumeOption.php delete mode 100644 src/Internal/ContainerCommandHandler.php rename src/Internal/Containers/{Models => }/Address/Address.php (60%) create mode 100644 src/Internal/Containers/Address/Hostname.php create mode 100644 src/Internal/Containers/Address/IP.php create mode 100644 src/Internal/Containers/Address/Ports.php create mode 100644 src/Internal/Containers/Definitions/ContainerDefinition.php create mode 100644 src/Internal/Containers/Definitions/CopyInstruction.php create mode 100644 src/Internal/Containers/Definitions/EnvironmentVariable.php create mode 100644 src/Internal/Containers/Definitions/PortMapping.php create mode 100644 src/Internal/Containers/Definitions/VolumeMapping.php create mode 100644 src/Internal/Containers/Environment/EnvironmentVariables.php delete mode 100644 src/Internal/Containers/Factories/AddressFactory.php delete mode 100644 src/Internal/Containers/Factories/ContainerFactory.php delete mode 100644 src/Internal/Containers/Factories/EnvironmentVariablesFactory.php create mode 100644 src/Internal/Containers/Factories/InspectResultParser.php delete mode 100644 src/Internal/Containers/Models/Address/Hostname.php delete mode 100644 src/Internal/Containers/Models/Address/IP.php delete mode 100644 src/Internal/Containers/Models/Address/Ports.php delete mode 100644 src/Internal/Containers/Models/Container.php delete mode 100644 src/Internal/Containers/Models/Environment/EnvironmentVariables.php create mode 100644 src/Internal/Exceptions/ContainerWaitTimeout.php delete mode 100644 tests/Unit/ClientMock.php delete mode 100644 tests/Unit/CommandHandlerMock.php delete mode 100644 tests/Unit/CommandMock.php delete mode 100644 tests/Unit/CommandWithTimeoutMock.php create mode 100644 tests/Unit/GenericDockerContainerTest.php delete mode 100644 tests/Unit/Internal/CommandHandlerTest.php delete mode 100644 tests/Unit/Internal/Commands/DockerCopyTest.php delete mode 100644 tests/Unit/Internal/Commands/DockerExecuteTest.php delete mode 100644 tests/Unit/Internal/Commands/DockerInspectTest.php delete mode 100644 tests/Unit/Internal/Commands/DockerListTest.php delete mode 100644 tests/Unit/Internal/Commands/DockerRunTest.php delete mode 100644 tests/Unit/Internal/Commands/DockerStopTest.php delete mode 100644 tests/Unit/Internal/Containers/Drivers/MySQL/MySQLStartedTest.php delete mode 100644 tests/Unit/Internal/Containers/Models/ContainerIdTest.php delete mode 100644 tests/Unit/Internal/Containers/Models/ImageTest.php create mode 100644 tests/Unit/Mocks/ClientMock.php create mode 100644 tests/Unit/Mocks/CommandMock.php create mode 100644 tests/Unit/Mocks/CommandWithTimeoutMock.php create mode 100644 tests/Unit/Mocks/ExecutionCompletedMock.php create mode 100644 tests/Unit/Mocks/InspectResponseFixture.php create mode 100644 tests/Unit/Mocks/TestableGenericDockerContainer.php create mode 100644 tests/Unit/Mocks/TestableMySQLDockerContainer.php create mode 100644 tests/Unit/MySQLDockerContainerTest.php create mode 100644 tests/Unit/Waits/ContainerWaitForTimeTest.php 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); + } +}